feat(addons): installable add-ons foundation and the Tables add-on (#12702)

* feat(addons): add the add-on runtime, navigation and first-party add-ons

Add the add-on runtime to LocalAI: manifests, the host operation broker,
admin gating by account role, the add-on UI bundle builder and the SDK
for add-on pages. Move the navigation to Chat, Models, Add-ons and
Settings, with pinned add-ons in the sidebar and an Add-ons drawer.

Ship the first-party add-ons (voice, face, fine-tuning, quantization) as
packages for linux/amd64, linux/arm64 and darwin/arm64. Intel macOS
(darwin/amd64) is enabled in the platform files.

Squashed from the review branch. The tree is unchanged; the pre-squash
history is kept on a local backup branch.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(gosec): tighten file modes and annotate false positives

Write job state, configs and imported models with mode 0600, clean
the paths that are read from disk, and mark the route tables, the
header name and the retry jitter as reviewed false positives.

Assisted-by: Claude Code:claude-sonnet-5-5 gosec
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): keep API keys off recovery mutations and same-site cookies

POST /api/addons/dispatch forwarded the recovery and update
mutations to the owner for any administrator key, which skipped the
browser-session rule of their own routes. Refuse those actions
there. Also apply the same-origin rule to the legacy token cookie,
not only the session cookie, because the global CSRF check lets
same-site requests through.

Do not fail the start of LocalAI when an add-on library cannot be
refreshed; log it and keep the installed add-ons up.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): refuse a reinstall that would migrate retained data unbacked

An upgrade takes a verified backup before the new package migrates
the data. A reinstall over retained data has no running generation
to stop, so the new package would migrate it without one. Refuse a
reinstall whose data format differs from the retained one.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): release the manager lock while a release is fetched

Submit held the state lock across the catalog lookup and the package
download, so the apps list and status reads stalled for the whole
install. Serialize Submit and Reconcile with their own lock and drop
the state lock around the fetch only.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): kill an add-on only after three missed health probes

One slow 100 ms probe killed a busy add-on and dropped the work it
was serving. Require three misses in a row.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): move settings under the operate hub

The Operate hub already has Settings as a tab, so the sidebar no longer shows a separate Settings entry. The /app/settings route stays, and the hub tab still reaches it. The navigation test now expects Chat, Models and Add-ons as the primary items.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): accept same-host browser requests that carry no fetch metadata

Browsers do not send Sec-Fetch-* headers over plain HTTP on an address
that is not localhost, so every add-on request from a LAN address was
refused as cross-site. Keep refusing a Sec-Fetch-Site value other than
same-origin. When the header is absent, accept the request only if its
Origin, or failing that its Referer, names the same host and port as
the request.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): show the server's reason when an add-on request is refused

A 403 or 400 answer showed a generic message and hid the reason in the
response body, so a refusal by the browser guard could not be told from
a missing permission. Show error.message from the body as the server
sent it, and keep the generic text when the body has none.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): run the add-on owner in the normal binary

The owner was only enabled when an embedding host set the add-on config,
so a normal run answered every add-on request as unavailable.

Add --addons-enabled (default true) and --addons-ownership-mode
(default named-user, or local-owner; anything else stops startup).
The node is its own owner: state lives under the data path, and the
carrier uses loopback keys made at each start and kept in memory.
--addons-enabled=false turns the owner off completely.

Named-user mode needs the account database. With authentication off
the owner stays off and a warning says how to use local-owner.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): keep the loopback owner address stable across restarts

The single-node loopback transport took a new ephemeral port at each
start. The owner stores its address in its identity file and refuses to
start when the address differs, so every start after the first failed
with "invalid addon owner configuration".

Choose the port once, keep it in the add-on state root (loopback-port,
mode 0600) and reuse it. If the port is taken at a later start, stop
with an error that names the port and --addons-enabled=false, because
another port would break the identity check. The keys and certificate
stay in memory.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): let add-on pages keep the host modules they were written against

A page written for the web UI imports React, the router, i18next and a
few shared components. The add-on builder refuses a bundle that holds a
host file, so moving such a page into an add-on meant rewriting it.

Add a compat mode to the shared builder. It answers those imports from a
directory of stand-ins that read the SDK, bundles the dynamic import of
the page code into the one output file, and collects the style sheets the
page imports. A relative import that is not in the add-on's own source
resolves to the file at the same path under the compat directory.

Widen the SDK for these pages, without a version change: more host
components (spinner, modal, lightbox, model picker) and hooks (models,
resources, voice profiles, operations, MCP client), a style on icons,
the icon names the pages draw, sdk.navigation.openApp to open a page of
the app (only addresses under /app, and the way back as the only state)
and sdk.portal. The shell passes the query string to the page.

The stand-ins cover the router (the tab in the path, the model in the
query), the translator over the host locale files, the core API calls
through sdk.core.fetch, and byte copies of the pure helpers, with tests
that keep the copies equal to the originals.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): ship Studio and Talk as add-ons instead of built-in pages

Studio and Talk were compiled into the web bundle and always listed in
the Add-ons drawer. They are add-ons now, like Voice, Face, fine-tuning
and quantization: a person installs and removes them, the drawer lists
them only while they are installed, and they are packaged the same way.

Move the pages, their components, hooks and helpers into addons/studio
and addons/talk. Each has a manifest, a UI bundle, a release platform
matrix and a catalog template, and is built by the compat mode of the
shared builder. The core API stays where it is: the pages call it with
the signed-in person's session. The history keeps its browser storage
keys. The unit tests of the moved helpers move with them.

In the core, drop the two pages, the Create group of the sidebar (Chat
joins Home and Models in the old layout), the built-in catalog entries
(the catalog is empty) and the talk locale namespace. The old addresses
(/app/studio, /app/talk and the generator links) redirect to the add-on
pages, with the model as ?model=. The Home /studio command is offered
only while the Studio add-on is installed and opens it.

The release workflow and its tests now list seven add-ons.

Assisted-by: Claude Code:claude-sonnet-5-5 go node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): run the Studio and Talk specs against the add-on bundles

The pages are add-ons now, so a spec that draws one installs the real
built bundle first: the apps list names it and its module and style
sheet are served from the build output, the way the add-on owner serves
them. studio-addon.js holds that helper, and the Studio and Talk
fixtures call it.

Update the specs for the new addresses (/app/addons/studio/studio, the
model as ?model=), the session permission the add-on route needs, the
legacy redirects, the Home /studio command that appears only while the
add-on is installed, and the sidebar without a Create group.

Add specs for the add-ons themselves: every tab draws with no script
error, the shell titles the page once, the old addresses say so when the
add-on is not installed, a button of the page reaches a core page with
the way back, and the drawer lists both only after they are installed.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): centre the icon of a built-in tile in its icon box

The drawer drew the icon of a built-in tile (Studio's palette was one)
in a bare 40px box. Only the placeholder box was laid out as a centred
grid with the 20px size, so the real icon sat in the top-left corner at
the size of the page text.

Give the built-in tile the same classes as the placeholder box, and
check in a browser that the icon is centred.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs(addons): describe Studio and Talk as add-ons

Replace the built-in list (now empty) with the Studio and Talk add-on
sections, the new targets of the old links, the Home command, and the
seven packaged add-ons. Describe the SDK additions the pages use:
components, hooks, icons, openApp and portal. Say on the Studio page
that it is installed from the drawer.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): click the shell title in the Studio slash-key spec

The add-on shell titles the page, and the Studio overview no longer draws
a second title, but the generator tabs keep theirs, so a bare h1 locator
matched two elements. Click the first, which is the shell's.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): read the loopback port file through os.Root

The persisted port file was read with a path built from the add-on
root, which gosec reports as a file inclusion through a variable. Open
it through os.Root so the name cannot leave the root, even by a
symbolic link, and require a regular file with no group or other
access. A looser file now fails closed instead of being trusted.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): expect Settings under the Operate hub, not the sidebar

Settings moved out of the sidebar into the Operate hub. The navigation
spec still expected a sidebar link. Assert that the sidebar has no
Settings entry, and that the Operate hub opens Settings as a tab.

Assisted-by: Claude Code:claude-sonnet-5-5 playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): drop Settings from the 404 places

Settings is a tab of the Operate hub and not a destination of its own,
so the 404 page does not list it. Assert that it is absent.

Assisted-by: Claude Code:claude-sonnet-5-5 playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): drive the media history and TTS specs through the Studio add-on

The image, video, sound and speech generators are pages of the Studio
add-on now, and the old addresses redirect to it. Install the add-on
with the Studio fixtures, open its pages and use its compose card, so
the specs run against the page that exists.

Assisted-by: Claude Code:claude-sonnet-5-5 playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): count the first-run steps, not every list item

The recommended models are a list inside the second step, so counting
all list items read 3 only while the models were still loading. Count
the step elements, which does not depend on the load order.

Assisted-by: Claude Code:claude-sonnet-5-5 playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): fail the node refresh by state, not by call count

The spec served the list to the first request and failed the rest. The
first request is not always the one from the page, so the page could
get the failure first and show no list. Serve the list until the page
shows it, then fail the refreshes.

Assisted-by: Claude Code:claude-sonnet-5-5 playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): default to local-owner when auth is off

A server without authentication has no accounts, so named-user left
the add-on owner off. Resolve the unset mode in one place: local-owner
when authentication is off, named-user when it is on. A flag or
environment value still wins, and an unknown value is still refused.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): register an official add-on library by default

Add gallery/addons/index.yaml, a YAML index of the four first-party
add-ons (fine-tuning, quantization, voice, face) in the library format.
The entries come from addonpkg catalog over locally built linux/amd64
packages, so the digests pin those images.

On the first start, when the owner has no sources.json, register the
raw GitHub URL of that index on master as the "official" library and
write the file. The file is then the record: a library the admin
removes stays removed, and an existing file is never changed.
--addons-official-library=false skips the default.

Assisted-by: Claude Code:claude-sonnet-5-5 go node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs(addons): document the ownership default and the official library

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): list Studio and Talk in the first-party index

Add the Studio and Talk entries to gallery/addons/index.yaml next to
the four existing ones. They come from addonpkg catalog over locally
built linux/amd64 packages, so the digests pin those images.

Assisted-by: Claude Code:claude-sonnet-5-5 go node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): expect Studio and Talk in the official index

The official index now lists six add-ons. Update the test that
decodes it so it accepts Studio and Talk.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): add a real icon to each first-party add-on

Each icon is a 256 by 256 PNG: a rounded square in one colour of the
UI kit palette with a white outline glyph from the kit sprite. Fine-tuning
uses sliders, quantization layers, voice waveform, face user, studio
palette, talk phone and tables database. They were rendered with headless
Chrome from the sprite paths.

Assisted-by: Claude Code:claude-sonnet-5-5 chrome
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): ship a committed icon.png instead of the generated square

The shared UI builder and the Tables builder copy ui/icon.png from the
add-on source tree to the package unchanged when the file exists. They
write the generated placeholder only when it does not. Add a fixture with
an icon and a test for both cases.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): list the tables add-on in the first-party index

Add the Tables entry to gallery/addons/index.yaml next to the six
existing ones. It comes from addonpkg catalog over a locally built
linux/amd64 package, so the digest pins that image. The official index
test now expects seven add-ons.

Assisted-by: Claude Code:claude-sonnet-5-5 go node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): make the Voice add-on one page with tabs

The Voice add-on had four pages: recognition, library, create and
speech, and the drawer listed two of them. It now has one page, "voice",
and one drawer entry. Recognition, Library, Text to speech and From a
recording are tabs of that page, drawn with the same tab strip the
recognition page already used. The tab is the first path segment
(voice/library), as in the Studio add-on, and the form that adds a voice
is a view of the Library tab (voice/library/new).

The old page names stay declared in the manifest. Each one redirects to
its tab and keeps the query string, so saved links still open.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): send the old voice routes to the Voice tabs

/app/voice, /app/voice-library and /app/voice-library/new now open the
matching tab of the Voice add-on page while the add-on is installed and
running, and the core pages otherwise. The moved-page helper compares
the first segment of the target with the declared pages, so a target
can name a tab. The docs and the navigation spec list the new addresses.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): cover the Voice tabs and the old page redirects

The add-on unit tests render the tabs instead of the four pages. The
Playwright spec checks the tab strip, the address of each tab, the
missing Library tab for a member, and that the old page addresses of the
add-on redirect to their tab with the query string kept. The migration
table test follows the new targets.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(studio): keep the model menu inside a narrow screen

The Home model picker sizes its menu from the viewport, and assumes the
picker sits at the page gutter. In the Studio composer card the field is
further in, so on a 390px screen the menu of the Sound expansion picker
ended 7px past the right edge. Size the menu from the field it opens
under instead.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): build the add-on bundles once for the whole run

Each worker built the Studio and Talk bundles into the same dist
directory, and the build empties that directory first. A worker that
started, or restarted after a failure, removed the files another worker
was serving, so a Studio spec saw "the style sheet of the add-on could
not be loaded". Build once in a global setup before the workers start.
A run without the setup still builds per worker.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): register the worker when the add-node spec says so

The mocked roster added the worker on the third read of /api/nodes. The
shell and the page both read it, and which one asks first depends on
load, so the page could take the new worker as part of the roster it
opened with and never show it as found. The worker now joins after the
page shows it is listening.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): hold the gallery answer until the skeleton is seen

The spec delayed the answer by a fixed 800 ms and then looked for the
skeleton. The assertion polls at growing intervals, so on a slow machine
it could step over that window. The answer now waits until the skeleton
has been seen, and the spec then checks that the skeleton goes away.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): give the PDF tag the time the other PDF specs have

pdf.js and its worker load on first use. The other specs in this file
wait up to ten seconds for the result of that load. The first one waited
five, and missed the tag on a busy machine.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): build the Face and Fine-tuning bundles in the global setup

The navigation spec serves the built Fine-tuning bundle, but nothing
built it, so two specs failed on a fresh checkout when the dist folder
was missing. The spec also built the Face bundle in a hook that every
worker runs, and a build rewrites the folder the other workers serve.

The global setup now builds Studio, Talk, Face and Fine-tuning once,
before the workers start. The spec builds the two bundles itself only
when it runs without the global setup.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): fix the Home answers in the stale pin spec

The spec asserts that the page shows no alert. The Home page raises its
own alert when the model list fails or a staging operation has failed,
and both answers come from the shared test server. A slow reply or a
failed install left there counted as an alert about the pin.

The spec now fixes both answers, so the alert count tells only about the
pin. The assertions are unchanged.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): read the server address in the add-node spec from the page

The spec expected the register address to carry port 8089, so it failed
on any other port. It now expects the origin the page was opened from.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): give the navigation routes spec its own catalog

No page is built in today, so the catalog is empty and the three specs
that expect the studio and talk entries failed on the list and on the
pin seed. They passed only while Studio and Talk were built in.

The spec now sets a catalog of two ungated entries for each example and
restores the old one afterwards. The assertions are unchanged.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(cli): report a failed write of the MLX hostfile

The tunnel callback ignored the error of os.WriteFile and set the
hostfile variable even when the file was not written. It now logs the
error and leaves the variable alone.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): discard the close error of the held listener

errcheck flags the deferred Close of the listener that keeps the port
busy. The test has no use for that error, so the defer drops it
explicitly.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): move the add-on tool tests to Ginkgo

The fine-tuning, quantization and add-on host library tests used
t.Errorf, t.Fatal and t.Run, which the forbidigo rule rejects. They now
use Describe, It and Gomega, with a suite bootstrap in each package. The
cases and the assertions are the same.

The tables in the host client and the tool schema tests keep one entry
per case.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): move the host operation tests to Ginkgo

The hostops tests used the testing package calls that forbidigo rejects.
They now run as Ginkgo specs under one suite bootstrap. Every check and
every per-operation case is kept.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): move the pin store tests to Ginkgo

The pin service, file store, sweep and shared store contract tests used
the testing package calls that forbidigo rejects. The store contract is
now a function that registers its specs for each store, so the file
store, the memory-only store and the database store run the same cases.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): move the remaining add-on tests to Ginkgo

The built-in catalog, tool arguments, loopback carrier, host operation
contract, packaging, application, config, CLI and endpoint tests used
the testing package calls that forbidigo rejects. They now run as Ginkgo
specs. The endpoint tests also drop the unchecked Close errors that
errcheck reports when the auth tag is on.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): wait for the targeted Reinstall button before the install click

The "generic deletion blocks targeted install at confirmation" spec
dispatches a popstate to set the target node, then clicks Reinstall with
a script click. The click can run before the targeted page has rendered
or while the button is still disabled, so no install request is sent and
the poll on the install count times out. It failed 1 run in 10 with two
workers and on every retry in CI.

The spec now waits for the button to be enabled first. The assertions
are unchanged.

Assisted-by: Claude Code:claude-sonnet-5-5 playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* chore(addons): refresh the voice package digest in the first-party index

The Voice add-on became one page with tabs and got a new icon, so the
digest in gallery/addons/index.yaml no longer matched the build. Render
the entry again with addonpkg catalog over a locally built linux/amd64
package; the main page is now voice. The other entries are unchanged.

Assisted-by: Claude Code:claude-sonnet-5-5 go node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): wait for the target node's Reinstall button after the popstate

The "generic deletion blocks targeted install at confirmation" spec
dispatches a popstate to set the target node, then waits for a button
named Reinstall and clicks it. Until React renders the targeted page,
the page still draws the generic Reinstall button. It is enabled and has
the same name, so the wait passed on it, the click started a global
install, and no node install request was sent. The poll on the install
count timed out with the confirm dialog still open.

With the CPU throttled to 8x, the spec failed on 20 of 20 runs, and the
button it clicked had the title "Reinstall". The spec now waits for the
button titled "Reinstall on <node>". With the throttle it passes 30 of
30 runs. The assertions are unchanged.

Assisted-by: Claude Code:claude-sonnet-5-5 playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): give the Studio hand-off spec a budget for the cold bundle load

The "a result made through a hand-off records which result it came from"
spec opens the Studio add-on straight from an address, twice. Each is a
cold load: the page shows "Loading" until the add-on module has run, and
the next assertion had the default five seconds. The load takes about
two seconds on an idle run and seven to ten under CPU load, so the spec
failed on a loaded machine with the hand-off note not yet drawn.

With the CPU throttled to 6x and eight busy loops running, it failed on
21 of 30 runs. The first assertion after each load now waits up to 20
seconds, and the spec then passed 30 of 30 under the same load. The
assertions are unchanged.

Assisted-by: Claude Code:claude-sonnet-5-5 playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): carry the identity page styles in the Face add-on

The Face page is drawn with the shared identity views, but its style sheet
held only the clip and error rules. The page, card, model list, known-faces
table, enrol sheet and distance scale classes had no rules. They looked
right only when the host had already loaded the style sheet of its own
Faces page, which a visit through the add-on drawer does not do. Opened
that way, the cards had no gap, the model list showed bullets, and the
install buttons ran into the names.

The Face sheet now holds the same identity rules as the core sheet, so the
page looks the same whichever way it is opened.

Assisted-by: Claude Code:claude-sonnet-5-5 playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): scope the narrow-screen identity rules of the Voice add-on

The rules for narrow screens (single-column pairs, smaller tool padding,
compact rank rows) were copied into the Voice style sheet without their
media query, so they applied at every width. They now sit in a
max-width 720px query, and the reduced-motion rule in its own query, as in
the core sheet.

Assisted-by: Claude Code:claude-sonnet-5-5 playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): drop the repeated title from the Voice and Face pages

The add-on shell already titles the page, so the recognition pages showed the name twice: "Voice" above "Voices", "Face" above "Faces". Keep the supporting line and the model picker, as the Studio page does, and remove the unused title strings.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): replace the Pinned sidebar section with an Apps entry

The sidebar no longer lists pinned add-ons. It shows one Apps entry that
opens the add-ons drawer, with a Show all link below it that opens the
same drawer. Pins are used from the drawer, where a pinned tile keeps
its pin and move buttons. The server pins API is unchanged.

Remove the PinnedNav component and its locale title, rename the
items.addons label to Apps, and update the e2e specs and the add-ons
documentation to match.

Assisted-by: Claude Code:claude-sonnet-5-5 playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): assert the single shell title on the Voice and Face pages

The add-on shell now shows the one title, "Voice" or "Face", and the page no longer repeats it. The tests still looked for the h1 "Voices" and "Faces". Assert that the add-on page has exactly one h1 and that it is the shell title. The core pages keep their own "Voices" and "Faces" titles.

Assisted-by: Claude Code:claude-sonnet-5-5 playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): drop the repeated title from the Voice Library and Text to speech pages

The add-on shell already titles the page "Voice", so these two tabs showed a second heading below it ("Speech voices", "Speak text"). Keep the supporting line, as the Studio and recognition pages do, and remove the unused title strings. The e2e spec now checks that each tab has exactly one h1, the shell title.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): list pinned apps under Apps in the sidebar

A pinned app now shows as an indented child of the Apps entry, with its icon and title, in pin order. Show all stays the last child. The pins come from GET /api/addons/pins through the navigation context, and the apps from the add-on registry, which the drawer feeds on every list read, so the links follow the drawer. A pin without an installed app is not drawn, nothing extra shows when no app is pinned, and the children are hidden when the sidebar is collapsed to the rail.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs(addons): say that pinned apps are listed under Apps in the sidebar

The sidebar lists the pinned apps between Apps and Show all, in pin order. The pins section and the navigation section said that pins do not appear in the sidebar.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): remove the Show all link under Apps in the sidebar

The link repeated the Apps entry, which opens the same drawer, and looked redundant when no app was pinned. Apps is now the only add-on entry; pinned apps still list under it in pin order. The link, its locale key and the style comment are gone, and the e2e spec and the add-ons docs now say that Apps is the only entry.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): show a first-run card on Apps when nothing is installed

An administrator who opens Apps with no add-on installed now sees a
short card with three steps: choose a library, browse and install,
start the add-on. Its button opens the Browse tab. The card is hidden
once an add-on is installed and for anyone who cannot install.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): hide the pinned apps in the icon rail

When the window is narrower than 1024 px the sidebar shrinks to the icon rail by CSS alone, without the collapsed class. The pinned app links were still drawn there, with the indent meant for the full sidebar, so their icons sat to the right of the rail and were cut off. Hide the pinned app links in both rail states. The Apps entry keeps its centred icon. The e2e spec checks that the rail shows no pinned app and that every icon is centred at 1280 px collapsed and at 1000 px.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): stop the backend redirect test racing the installed list

The test reads the redirected URL right after the path changes. The
Backends page then loads the installed list and drops a selected backend
that is not installed, so the backend parameter was sometimes already gone
when the test read it. The mock server has no llama-cpp, and about 15 of
40 repeated runs failed on the backend assertion. The redirect itself was
correct, including show_all.

Serve the selected backend in the installed list, so the page keeps the
selection and the assertions stay as strict as before.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): accept a same-host Referer or Origin for human confirmation

Browsers send no Sec-Fetch-* headers over plain HTTP on an address that
is not localhost, so creating a resource from a LAN address failed with
"Creating a resource requires your own browser session".

When Sec-Fetch-Site is present it must still be same-origin with an
empty destination, as before. When it is absent, Origin or, failing
that, Referer must name the request host and port. A destination or
mode that is present must still be the expected one. This matches the
rule in BrowserProtection. The credential, token cookie, API key and
tool-context checks are unchanged.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): keep hub entries off the Apps route in the sidebar

The Build hub still owned /app/addons through its legacy Add-ons tab,
so the sidebar lit both Apps and Build on the Apps page. A tab whose
gate is closed now owns no route when the sidebar asks, so only the
Apps entry is active there. The hubs keep their own routes, and the
legacy navigation still gives /app/addons to the Build hub.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): label the pin control on app tiles and in the detail view

The pin was a small icon-only button and hard to find. Each app tile now
shows a button reading Pin, or Pinned with a filled pin, and the detail
view has the same control in its header. Both toggle the same stored pin.

The Apps grid lists pinned apps first, in pin order, then the others.
The sidebar list and the server pins API are unchanged.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): check the Voice From a recording tab has one title

The Library and Text to speech tabs already assert that the add-on shell
title is the only h1. Do the same for the From a recording tab, so a
heading added there later fails the navigation spec.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): stop the add-on shell drawing a title over add-on pages

The app decides what its page shows. The shell drew the add-on name as a
header above every add-on page, so an add-on could not choose its own
title. The shell now draws no header on a live, loading or frozen
add-on page; each add-on titles its own pages. The unavailable and
missing-page states keep the name, because no add-on page is drawn there.

The runtime and shell specs now check that the shell adds no page title.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): let each add-on draw its own page title

The shell no longer titles add-on pages, so each add-on draws one h1 per
page. Voice titles its tabs "Voices" (Recognition and From a recording),
"Speech voices" (Library) and "Speak text" (Text to speech); "Add a speech
voice" already had its own. Face draws "Faces", Studio "Studio" and Talk
"Talk". Tables promotes the database list and database name from h2 to h1.
Fine-tuning and Quantization already drew a title.

The add-on unit tests and the navigation and Studio specs now assert
exactly one h1 per page, with the page's own title.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): add a kind to library entries and read unknown kinds tolerantly

A library entry now has an optional kind: addon (the default when it is
absent), backend or model. Every index written before kinds existed stays
valid and its entries are add-ons.

A reader keeps the entries of the kinds it knows and skips the others
without reading their fields, so a newer kind never makes an older host
refuse the whole index. The refresh result and the library state report
how many entries were skipped and of which kinds, and the refresh logs a
warning. A kind that is not a plain kind name still makes the index
malformed. Search lists add-ons unless the caller asks for a kind, and
Resolve returns add-ons only, so an add-on install cannot be pointed at
a backend image.

A library policy may name the signer with an anchored identityRegex
instead of an exact identity, so builds from master and from tags both
verify. An add-on install refuses a policy that carries one.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(backends): install a backend from a library entry

A library can now list backends. An entry of kind backend names the
image of the backend gallery by digest, and POST /backends/library/install
installs it on the local node. The gallery stays the source of truth and
its install path is the one used: the library resolves the entry, takes
the signature policy from the library, pins the image by digest and calls
the same install as the gallery. Installing a name that came from a
gallery, another library or by hand is refused and changes nothing.

The signature policy of a backend library names the signer with an
anchored identityRegex, so images signed by the merge workflow on master
and on tags both verify. A pattern that is not anchored is refused.

An install is bounded by a size limit per kind: add-ons keep 512 MiB and
a backend image defaults to 8 GiB, set with --addons-backend-max-bytes.
The limit reads the manifest after the signature check and before any
layer is fetched. The metadata of an installed backend records the
library it came from.

scripts/backends/cmd/backendcatalog renders entries from
backend/index.yaml and pins them against the registry. The pilot is the
CPU build of local-store, in gallery/backends/index.yaml. The library is
registered on the first start only with --addons-backend-library, which
is off by default.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): show the kind of library entries and install backends from them

The Browse tab lists the entries of every kind this host reads and marks
each one as an add-on or a backend. A backend installs through the
backend route and not through the add-on lifecycle: the confirmation
says that the backend gallery is not changed and that a backend runs
native code, and a refusal of the host, such as a name installed from
another origin, is shown with its own reason. The details give the kind,
the alias and the signer pattern.

The library editor can name the signer with a pattern. Without this a
save would drop the pattern of a backend library. The Libraries tab
reports the entries a refresh skipped because their kind is not read.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs(addons): describe backends in the library

Document the entry kinds, how a reader skips a kind it does not know,
the backend entry format, the signer identity pattern, the install
route, the size limit of each kind, the pilot library and the command
that renders it. The backend page links to it.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(mcp): install a backend from a library entry

The backend library route installs a backend from an add-on library
entry, and the admin REST surface and the MCP catalog must stay in step.
Add the install_library_backend tool with an in-process and an HTTP
client, the route map entry, the safety-rule listing and a skill recipe.

Listing library entries stays on the add-on routes, which the assistant
does not expose, so no list tool is added.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): carry the add-on library keys in every locale

The add-on library and backend library strings existed only in English,
so the other locales had no value for them. Add the missing keys of the
addons namespace to de, es, id, it, ko, pt-BR and zh-CN. The values are
the English text until a translation is contributed.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Revert "feat(mcp): install a backend from a library entry"

This reverts commit 7493cff727.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Revert "docs(addons): describe backends in the library"

This reverts commit 57816ed7e6.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Revert "feat(ui): show the kind of library entries and install backends from them"

This reverts commit 49b2b151b7.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Revert "feat(backends): install a backend from a library entry"

This reverts commit 1d8d3e3421.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Revert "feat(addons): add a kind to library entries and read unknown kinds tolerantly"

This reverts commit 484813f856.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): drop the backend library keys from the locales

Revert the part of 39f77ae29 that added strings for backend libraries:
the kind labels, the backend install texts, the signer identity pattern
and the skipped entries notice. The add-on library keys stay.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): add an add-ons control to the chat header

Add-on tools were on or off only through the /addons command, with a toast
and no state a person could see. The chat header now has an Add-ons button
in the style of the MCP dropdown. It lists each running add-on that ships
tools with a switch, and a badge shows how many are on.

The chat keeps the ids that are on (addonToolIds) next to the older
boolean, and both are saved with the chat. A chat that has only the boolean
means every add-on. The request sends addon_tools alone when every add-on is
on, and adds addon_tool_ids when only some are. /addons turns all on or all
off and uses the same state.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): let a chat request name the add-ons whose tools it uses

addon_tools=true still offers the tools of every add-on the caller may use.
A new addon_tool_ids metadata key, a comma-separated list of add-on ids,
narrows that to some add-ons. The list counts only with the opt-in, and a
list that names nothing offers no tool rather than all of them.

The executor drops the tools of add-ons that are not listed and refuses a
call to one as an unknown tool, so a model cannot reach a tool the person
turned off. Both keys are consumed so they do not reach the backend.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): cover the per-add-on tool list and the chat header control

Go tests cover the list form (the list filters the tools offered and the
calls allowed) and the unchanged boolean form. Unit tests cover the chat
add-on rules. Playwright specs cover the header control: listing only
add-ons that ship tools, toggling each one, the count, /addons in step with
the switches, and the request metadata.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): check each add-on page has one title when not enabled

The shell names an add-on only where the add-on page cannot mount (not
enabled, failed, no UI, bundle not loaded, no such page). Once the page
mounts it draws its own title, including when it says it is not enabled
on this server, and the shell draws none. The shell code already works
this way; this change states the rule beside the unavailable state and
pins it in the specs.

The shell spec now expects exactly one title in the unavailable states.
The navigation spec checks the Face page without a face model (the
add-on title only) and the Face page of a stopped add-on (the shell
title only).

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): upgrade add-ons on their own when the setting is on

A new auto_upgrade_addons setting (flag LOCALAI_AUTO_UPGRADE_ADDONS,
default off) lets the owner upgrade an installed add-on when its library
lists a newer version. It follows the backend setting: a startup flag, a
runtime setting, and the same schedule as the backend update check (a
first pass shortly after start, then every six hours).

Each pass fetches the library of every installed add-on, and for each
available update builds the same pinned upgrade command an administrator
sends and runs it through the same lifecycle path. The channel rules, the
signer identity, the digest checks, the SDK compatibility check and the
backup before the upgrade all apply unchanged. The only difference is the
gate: the owner acts on its own setting, so no administrator is asked.
A refused or failed upgrade changes nothing and is logged; a candidate
that failed is not retried until the library lists another digest.

With the setting off, nothing runs and upgrades stay manual.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): add the Upgrade add-ons automatically toggle to Settings

The toggle sits beside Upgrade backends automatically and is off by
default. It saves the auto_upgrade_addons runtime setting. The English
locale gets the label key, and the Settings test fixture lists the new
field.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): cover the automatic upgrade setting and document it

The setting defaults to off in the config, the runtime settings and the
flag, and is read back from the saved settings file. With it on, a real
owner upgrades an installed add-on through the manager; with it off,
nothing changes, and a change of the setting at run time takes effect on
the next pass. The manager specs show the upgrade goes through the same
pinned path as the manual one, and that a corrupted package, a failed
signature policy, an incompatible release, a missing update checker and
a caller who is not an administrator each leave the installed version in
place. The new specs are added to the lifecycle case inventory.

The add-ons page describes the setting, its schedule and its default.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(addons): name the library entry identifier id

The library entry of an add-on used addonId while the shared envelope
and the model entries use id. Release now has the field ID, written as
id, and every writer emits it: the index encoder, the package workflow
templates, the catalog generator and gallery/addons/index.yaml. The
digests in the index did not change.

DecodeIndex still reads addonId as a legacy alias for one release, so
an existing library and a library served by an older tool keep
working. An entry that holds both names is refused. The Apps UI reads
id and falls back to addonId.

Assisted-by: Claude Code:claude-sonnet-5-5 go node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): use the id field in the auto-upgrade test release

The auto-upgrade test arrived after the identifier rename and still
named the release field AddonID, so the application tests did not
build.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): annotate the auto-upgrade actor label for gosec

The actor label looks like a credential to gosec G101. It is a fixed name for upgrades the owner starts itself.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(schema): persist the per-chat add-on tool fields

The chat header persists addonTools and addonToolIds, but Conversation had no fields for them, so the server dropped them on save. Add both and update the generated swagger files by hand because swag is not installed here.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): count the add-ons auto-upgrade setting in the settings groups

The setting sits in the Backends and galleries group, once. The page now lists 54 settings plus the three branding images, so the spec expects 57.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): pause the fake clock ahead of now in the clear-all spec

The fake clock runs from install, so pausing at the current time failed with 'Cannot fast-forward to the past' on a slow runner. The save itself is synchronous and was not lost.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): hold the Studio capabilities reply longer in the skeleton spec

800 ms was shorter than a slow runner needs to render the page, so the skeleton was gone before the check. The product shows it.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(i18n): add the French add-ons locale

Master added the fr locale after this branch forked. The add-ons unit test requires every locale to carry the add-ons namespace, so fr needs its own addons.json.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
mudler-agentandEttore Di Giacinto authored and GitHub committed 2026-10-22 17:57:19 +02:00
1 parent 099d6c3dd0
commit e45dc5e9f7
964 files changed
+159560 -515

No files matched your search

+244
View File
@@ -0,0 +1,244 @@
// SPDX-License-Identifier: MIT
// This executable is a real protocol fixture, not a model backend.
package main
import (
"crypto/subtle"
"encoding/json"
addonruntime "github.com/mudler/LocalAI/core/services/addons/runtime"
"github.com/mudler/LocalAI/pkg/addons/contract"
"io"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
func main() {
if len(os.Args) != 2 || os.Args[1] != "--localai-addon-v1" {
os.Exit(2)
}
parent := os.NewFile(4, "startup")
d := json.NewDecoder(parent)
var startup addonruntime.Startup
if d.Decode(&startup) != nil {
os.Exit(3)
}
var config struct {
Mode string `json:"mode"`
// Effects names a file that makes the add-on's data durable: one line
// per committed operation, and "<Effects>.applies" with one line per
// apply request that reached the process, duplicates included. Empty
// keeps the receipts in memory only.
Effects string `json:"effects"`
}
b, _ := os.ReadFile("fixture.json")
_ = json.Unmarshal(b, &config)
// The "orphan" mode ignores the closing of the startup channel, as a
// misbehaving add-on would: only the owner's ledger can then stop it.
if config.Mode != "orphan" {
go func() { _, _ = io.Copy(io.Discard, io.MultiReader(d.Buffered(), parent)); os.Exit(0) }()
}
_ = os.WriteFile("fixture.pid", []byte(strconv.Itoa(os.Getpid())), 0600)
// The data directory the owner passed is recorded as received, so a test
// can assert what the startup object carried. Writing a probe file there
// shows the add-on can use it.
_ = os.WriteFile("fixture.datadir", []byte(startup.DataDir), 0600)
if startup.DataDir != "" {
_ = os.WriteFile(filepath.Join(startup.DataDir, "fixture.probe"), []byte("ok"), 0600)
}
// The host operation endpoint and credential, as received, so a test can
// assert that an add-on the host does not name gets neither.
_ = os.WriteFile("fixture.hostops", []byte(startup.HostEndpoint+"\n"+startup.HostCredential), 0600)
if config.Mode == "exit-start" {
os.Exit(4)
}
if config.Mode == "env-check" && (os.Getenv("LOCALAI_RUNTIME_TEST_SECRET") != "" || os.Getenv("HTTP_PROXY") != "" || len(os.Environ()) != 2) { //nolint:forbidigo // the fixture checks the exact environment the host gives a child
os.Exit(5)
}
listener, e := net.FileListener(os.NewFile(3, "listener"))
if e != nil {
os.Exit(6)
}
var mu sync.Mutex
receipts := map[string]contract.Receipt{}
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), []byte("Bearer "+startup.Credential)) != 1 || r.Header.Get("X-LocalAI-Incarnation") != startup.Registration.Incarnation {
http.Error(w, "denied", 401)
return
}
if r.Method != "POST" || r.Header.Get("Content-Type") != "application/json" {
http.Error(w, "invalid request", 400)
return
}
r.Body = http.MaxBytesReader(w, r.Body, startup.Limits.EnvelopeBytes)
defer func() { _ = r.Body.Close() }()
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
read := func(v any) bool {
if decoder.Decode(v) != nil || decoder.Decode(new(any)) != io.EOF {
http.Error(w, "invalid request", 400)
return false
}
return true
}
valid := func(v contract.Envelope, scope string) bool {
if v.Validate(time.Now(), startup.Limits) != nil || v.Authority.Scope != scope || v.Request.AddonID != startup.Registration.AddonID || v.Request.Generation != startup.Registration.Generation {
http.Error(w, "invalid authority", 403)
return false
}
return true
}
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/health":
if config.Mode == "never-ready" {
http.Error(w, "starting", 503)
return
}
reg := startup.Registration
switch config.Mode {
case "forged":
reg.Capabilities.Tools = []string{"forged"}
case "nonloopback":
reg.Endpoint = "http://192.0.2.1:80"
case "stale":
reg.Incarnation = "stale"
case "wrong-version":
reg.Protocol = 2
}
_ = json.NewEncoder(w).Encode(reg)
case "/read", "/preview":
var v contract.Envelope
if !read(&v) || !valid(v, r.URL.Path[1:]) {
return
}
if v.Request.Action == "delay" {
select {
case <-r.Context().Done():
return
case <-time.After(time.Second):
}
}
if r.URL.Path == "/preview" {
_ = json.NewEncoder(w).Encode(contract.Preview{OperationID: v.Request.OperationID, Revision: contract.Revision{Schema: 1, Data: 1}, ExpiresAt: time.Now().Add(time.Minute), Summary: json.RawMessage(`{"rows":1}`)})
return
}
payload, _ := json.Marshal(map[string]any{"actor": v.Authority.Actor.UserID, "arguments": v.Request.Arguments})
result := contract.Result{Payload: payload}
// The tool "export" answers with a file result, and the owner
// turns it into a private artifact.
var tool struct {
Tool string `json:"tool"`
}
if json.Unmarshal(v.Request.Arguments, &tool) == nil && tool.Tool == "export" {
result.Outputs = []contract.OutputDescription{{Handle: "report", Name: "report.txt", MediaType: "text/plain", Size: int64(len("fixture\n"))}}
}
_ = json.NewEncoder(w).Encode(result)
case "/apply":
var v addonruntime.ApplyRequest
if !read(&v) || !valid(v.Envelope, "apply") {
return
}
mu.Lock()
defer mu.Unlock()
id := v.Envelope.Request.OperationID
noteLine(config.Effects+".applies", id)
receipt, ok := lookup(config.Effects, receipts, id)
fresh := !ok
if fresh {
receipt = contract.Receipt{OperationID: id, State: contract.ReceiptCommitted, Revision: contract.Revision{Schema: v.Revision.Schema, Data: v.Revision.Data + 1}}
receipts[id] = receipt
noteLine(config.Effects, id)
}
// "lose-reply": the change is committed and the connection is
// dropped before the reply, as a crash or a network cut would.
if fresh && config.Mode == "lose-reply" {
if conn, _, e := w.(http.Hijacker).Hijack(); e == nil {
_ = conn.Close()
return
}
}
_ = json.NewEncoder(w).Encode(receipt)
case "/receipt":
var v addonruntime.ReceiptRequest
if !read(&v) {
return
}
mu.Lock()
defer mu.Unlock()
receipt, ok := lookup(config.Effects, receipts, v.OperationID)
if !ok {
receipt = contract.Receipt{OperationID: v.OperationID, State: contract.ReceiptUnknown}
}
_ = json.NewEncoder(w).Encode(receipt)
case "/output":
var v addonruntime.OutputRequest
if !read(&v) || !valid(v.Envelope, "output") {
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.WriteHeader(200)
if v.Handle == "slow" {
w.(http.Flusher).Flush()
<-r.Context().Done()
return
}
if v.Handle == "oversized" {
for n := int64(0); n <= startup.Limits.ArtifactBytes; n++ {
if _, e := w.Write([]byte{'x'}); e != nil {
return
}
}
return
}
_, _ = io.WriteString(w, "fixture\n")
default:
http.NotFound(w, r)
}
})
if config.Mode == "crash" {
go func() { time.Sleep(100 * time.Millisecond); os.Exit(7) }()
}
server := &http.Server{Handler: handler, ReadHeaderTimeout: time.Second, ReadTimeout: 2 * time.Second, IdleTimeout: time.Second, MaxHeaderBytes: 16 << 10}
_ = server.Serve(listener)
}
// noteLine appends one line to a durable log. An empty path keeps nothing.
func noteLine(path, line string) {
if path == "" || path == ".applies" {
return
}
f, e := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) // #nosec G304 -- test harness reads files it created in its own work directory
if e != nil {
return
}
_, _ = f.WriteString(line + "\n")
_ = f.Sync()
_ = f.Close()
}
// lookup finds the receipt of an operation, in memory and then in the durable
// log, so that a restarted process still knows what it committed.
func lookup(path string, memory map[string]contract.Receipt, id string) (contract.Receipt, bool) {
if r, ok := memory[id]; ok {
return r, true
}
if path == "" {
return contract.Receipt{}, false
}
b, e := os.ReadFile(path) // #nosec G304 -- test harness reads files it created in its own work directory
if e != nil {
return contract.Receipt{}, false
}
for _, line := range strings.Split(string(b), "\n") {
if line == id {
return contract.Receipt{OperationID: id, State: contract.ReceiptCommitted, Revision: contract.Revision{Schema: 1, Data: 1}}, true
}
}
return contract.Receipt{}, false
}
@@ -0,0 +1 @@
{"schemaVersion":1,"id":"bare","version":"1.0.0","executable":"bin/fixture","os":"linux","arch":"amd64","host":{"min":1,"max":2},"api":{"min":1,"max":1},"protocol":{"min":1,"max":1},"dataFormat":{"min":1,"max":2},"launchMode":"always","capabilities":{"service":true,"tools":["ping"],"ui":{"entry":"ui.js","sdk":{"min":1,"max":2},"pages":["home"],"mainPage":"home","title":"Bare import","description":"Imports React by bare specifier, which the host never resolves."}}}
+7
View File
@@ -0,0 +1,7 @@
// What a bundler-less build that did not follow the SDK contract looks like:
// a bare import of React. The host has no import map, so the module fails to
// load and no second React is ever resolved for it.
import React from 'react'
export function createAddon() {
return { Component: () => React.createElement('p', null, 'unreachable'), dispose() {} }
}
@@ -0,0 +1 @@
{"schemaVersion":1,"id":"cssb","version":"1.0.0","executable":"bin/fixture","os":"linux","arch":"amd64","host":{"min":1,"max":2},"api":{"min":1,"max":1},"protocol":{"min":1,"max":1},"dataFormat":{"min":1,"max":2},"launchMode":"always","capabilities":{"service":true,"tools":["ping"],"ui":{"entry":"ui.js","css":["style.css"],"sdk":{"min":1,"max":2},"pages":["home"],"navigation":["home"],"mainPage":"home","title":"CSS B","description":"A second add-on with the same class names."}}}
+2
View File
@@ -0,0 +1,2 @@
.title { color: rgb(0, 0, 255); }
body { margin: 40px; }
+8
View File
@@ -0,0 +1,8 @@
// A second add-on whose style sheet uses the same class names as the first.
export function createAddon(sdk) {
const h = sdk.React.createElement
return {
Component: () => h('section', null, h('h2', { className: 'title', 'data-testid': 'cssb-title' }, 'Second add-on')),
dispose() {},
}
}
@@ -0,0 +1 @@
{"schemaVersion":1,"id":"slots","version":"1.0.0","executable":"bin/fixture","os":"linux","arch":"amd64","host":{"min":1,"max":2},"api":{"min":1,"max":1},"protocol":{"min":1,"max":1},"dataFormat":{"min":1,"max":2},"launchMode":"always","capabilities":{"service":true,"tools":["ping","summarize"],"ui":{"entry":"ui.js","css":["ui/style.css"],"sdk":{"min":1,"max":2},"pages":["home"],"mainPage":"home","title":{"en":"Slots fixture","it":"Fixture degli slot"},"description":"Contributes to every host slot, with a failing contribution and a style collision.","chatActions":[{"id":"summarize","label":{"en":"Summarize chat","it":"Riassumi la chat"},"icon":"sparkles","placement":"composer","target":{"tool":"summarize"}},{"id":"approve","label":"Review a request","icon":"check-circle","placement":"composer","target":{"component":"ApprovePanel"}},{"id":"inspect","label":"Inspect message","icon":"eye","placement":"message","target":{"component":"InspectPanel"}},{"id":"explode","label":"Break on purpose","icon":"warning","placement":"message","target":{"component":"ExplodePanel"}}],"slots":[{"id":"composer-chip","slot":"chat.composer.actions","component":"ComposerChip"},{"id":"broken-chip","slot":"chat.composer.actions","component":"BrokenChip"},{"id":"message-chip","slot":"chat.message.actions","component":"MessageChip"},{"id":"nav-chip","slot":"app.nav","component":"NavChip"},{"id":"settings-card","slot":"settings.sections","component":"SettingsCard"},{"id":"broken-section","slot":"settings.sections","component":"BrokenSection"}],"renderers":[{"kind":"approval-card","tool":"ping","component":"PingCard"},{"kind":"tool-result","tool":"summarize","component":"SummaryResult"}]}}}
+67
View File
@@ -0,0 +1,67 @@
// Slots fixture, version 1. Hand-written in the compiled form an add-on build
// produces: no bare `react` import. It contributes to every host slot, with a
// contribution that throws and a style sheet that collides with host classes.
const state = (window.__slotsFixture = window.__slotsFixture || { events: [] })
const log = (name) => { state.events.push(name) }
export function createAddon(sdk) {
const h = sdk.React.createElement
const { Button } = sdk.ui
const generation = sdk.generation
log('create:' + generation)
sdk.signal.addEventListener('abort', () => log('abort:' + generation))
const props = (testid) => (p) => h('span', { className: 'fx-chip', 'data-testid': testid, 'data-props': JSON.stringify(p) }, testid)
function NavChip() {
const href = sdk.navigation.href('home')
return h('a', {
className: 'fx-chip', href, 'data-testid': 'fx-nav',
onClick: (e) => { e.preventDefault(); sdk.navigation.navigate('home') },
}, 'Slots home')
}
function SettingsCard() {
return h('div', { className: 'fx-card', 'data-testid': 'fx-settings' },
h('h2', null, 'Fixture setting'),
h('p', null, 'Settings contributed by the slots fixture.'),
h(Button, null, 'Fixture button'))
}
function BrokenChip() { throw new Error('fixture composer contribution failure') }
function BrokenSection() { throw new Error('fixture settings contribution failure') }
function ExplodePanel() { throw new Error('fixture dialog failure') }
function InspectPanel(p) {
return h('div', { 'data-testid': 'fx-inspect', 'data-props': JSON.stringify({ conversationId: p.conversationId, messageId: p.messageId, role: p.role }) },
h('p', null, 'Inspecting message ' + p.messageId),
h(Button, { onClick: p.close }, 'Done'))
}
function ApprovePanel(p) {
return h('div', { 'data-testid': 'fx-approve' },
h('p', null, 'This add-on asks the host to show a request.'),
h(Button, { onClick: () => { p.close(); sdk.approvals.present('op-fixture-1') } }, 'Show the request'))
}
function PingCard(p) {
return h('div', { className: 'fx-card', 'data-testid': 'fx-card' },
h('p', null, 'Card for ' + p.tool + ': ' + p.effects.count + ' changes'),
h(Button, { onClick: () => log('card-clicked') }, 'Card button'))
}
function SummaryResult(p) {
const ctx = p.result && p.result.arguments && p.result.arguments.context
return h('div', { className: 'fx-card', 'data-testid': 'fx-result-card' },
h('p', null, 'Summary of ' + (ctx && ctx.conversationId)),
h('p', null, 'tool ' + p.tool))
}
function Home() { return h('section', null, h('h2', null, 'Slots home')) }
return {
Component: Home,
components: {
ComposerChip: props('fx-composer-chip'),
MessageChip: props('fx-message-chip'),
NavChip, SettingsCard, BrokenChip, BrokenSection, ExplodePanel, InspectPanel, ApprovePanel, PingCard, SummaryResult,
// Not in the manifest: the host must never draw these.
Rogue: () => h('span', { 'data-testid': 'fx-rogue' }, 'ROGUE'),
},
registerSlot() { log('register-slot-called') },
dispose() { log('dispose:' + generation) },
}
}
@@ -0,0 +1,10 @@
/* Selectors that collide with host classes on purpose. The host scopes this
sheet to the add-on root, so none of them may reach a host node. */
.cx-acts { display: none !important; }
.home-attach-btn { background: red !important; width: 1px !important; }
.dk-btn { background: red !important; color: yellow !important; }
.page-title, h1, h2 { color: red !important; font-size: 48px !important; }
.hub-subnav__link { display: none !important; }
body { background: red !important; }
.fx-chip { color: rgb(1, 2, 3); font-weight: 700; }
.fx-card { border: 2px solid rgb(4, 5, 6); padding: 4px; }
@@ -0,0 +1 @@
{"schemaVersion":1,"id":"uifixture","version":"2.0.0","executable":"bin/fixture","os":"linux","arch":"amd64","host":{"min":1,"max":2},"api":{"min":1,"max":1},"protocol":{"min":1,"max":1},"dataFormat":{"min":1,"max":2},"launchMode":"always","capabilities":{"service":true,"tools":["ping"],"ui":{"entry":"ui.js","css":["ui/style.css"],"sdk":{"min":1,"max":2},"pages":["home","boom","dialog"],"navigation":["home","dialog"],"chatActions":["summarize"],"mainPage":"home","title":{"en":"UI fixture","it":"Fixture interfaccia"},"description":"Exercises the add-on UI runtime."}}}
+11
View File
@@ -0,0 +1,11 @@
// UI runtime fixture, version 2: the package an update installs.
const events = (window.__uiFixture = window.__uiFixture || { events: [] }).events
export function createAddon(sdk) {
const h = sdk.React.createElement
events.push('create-v2:' + sdk.generation)
return {
Component: () => h('section', null, h('h2', { 'data-testid': 'fx-title' }, 'Fixture version 2'), h('p', { 'data-testid': 'fx-generation' }, 'generation ' + sdk.generation + ' version 2')),
chatActions: [],
dispose() { events.push('dispose-v2:' + sdk.generation) },
}
}
@@ -0,0 +1,9 @@
@import url("https://evil.example/theme.css");
/* Rules that would restyle the host if they were not scoped. */
body { background: rgb(255, 0, 255); }
h1 { color: rgb(255, 0, 0); font-size: 11px; }
.page-title { color: rgb(255, 0, 0); }
.dk-btn { outline: 5px solid rgb(255, 0, 0); }
:root { --fx-token: 1; }
.title { color: rgb(1, 2, 3); }
.fx-home { display: block; background-image: url(https://evil.example/pixel.gif); }
@@ -0,0 +1 @@
{"schemaVersion":1,"id":"uifixture","version":"1.0.0","executable":"bin/fixture","os":"linux","arch":"amd64","host":{"min":1,"max":2},"api":{"min":1,"max":1},"protocol":{"min":1,"max":1},"dataFormat":{"min":1,"max":2},"launchMode":"always","capabilities":{"service":true,"tools":["ping"],"ui":{"entry":"ui.js","css":["ui/style.css"],"sdk":{"min":1,"max":2},"pages":["home","boom","dialog"],"navigation":["home","dialog"],"chatActions":["summarize"],"mainPage":"home","title":{"en":"UI fixture","it":"Fixture interfaccia"},"description":"Exercises the add-on UI runtime."}}}
+95
View File
@@ -0,0 +1,95 @@
// UI runtime fixture, version 1. Hand-written in the compiled form an add-on
// build produces: classic JSX turned into factory-scoped React.createElement
// calls over the React the host SDK hands in. There is no bare `react` import
// and no bundler at test time.
const events = (window.__uiFixture = window.__uiFixture || { events: [] }).events
const log = (name) => { events.push(name) }
export function createAddon(sdk) {
const React = sdk.React
const h = React.createElement
const { Button, Stack, Text, Badge } = sdk.ui
const generation = sdk.generation
log('create:' + generation)
sdk.signal.addEventListener('abort', () => log('abort:' + generation))
sdk.onDispose(() => log('sdk-dispose:' + generation))
sdk.locale.define('en', { greeting: 'Hello {{name}}' })
sdk.locale.define('it', { greeting: 'Ciao {{name}}' })
function Home(props) {
const [count, setCount] = React.useState(0)
const [draft, setDraft] = React.useState('')
const [result, setResult] = React.useState('idle')
React.useEffect(() => { log('home-mounted:' + generation); return () => log('home-unmounted:' + generation) }, [])
const onInput = (event) => {
setDraft(event.target.value)
sdk.markDirty(event.target.value !== '')
}
const read = async () => {
setResult('pending')
try {
const out = await sdk.api.read({ arguments: { q: draft } })
setResult('ok:' + JSON.stringify(out))
} catch (error) {
setResult('error:' + (error && (error.kind || error.name)) + ':' + (error && error.status))
}
}
return h('section', { className: 'fx-home' },
h('h2', { className: 'title', 'data-testid': 'fx-title' }, sdk.locale.t('greeting', 'Hello {{name}}', { name: 'fixture' })),
h('p', { 'data-testid': 'fx-where' }, 'page=' + props.page + ' path=' + props.path),
h('p', { 'data-testid': 'fx-generation' }, 'generation ' + generation + ' version 1'),
h(Stack, null,
h(Badge, { tone: 'healthy' }, 'hooks ' + count),
h(Button, { onClick: () => setCount((n) => n + 1) }, 'Increment'),
h('label', null, 'Draft ', h('input', { 'data-testid': 'fx-draft', value: draft, onChange: onInput })),
h(Button, { onClick: read }, 'Read'),
h(Button, { onClick: () => { sdk.markDirty(false); setDraft('') } }, 'Save draft'),
h(Button, { onClick: () => sdk.navigation.navigate('dialog/opened?from=home') }, 'Go to dialog'),
h(Button, { onClick: () => { try { sdk.navigation.navigate('/app/settings') } catch (e) { setResult('refused:' + e.code) } } }, 'Leave'),
h('p', { 'data-testid': 'fx-result' }, result),
h(sdk.Icon, { name: 'table', title: 'table' })));
}
function Boom() {
throw new Error('fixture render failure')
}
function Dialog(props) {
return h('section', null,
h('h2', { className: 'title' }, 'Dialog page'),
h('p', { 'data-testid': 'fx-where' }, 'page=' + props.page + ' path=' + props.path),
h(Button, {
onClick: () => sdk.layers.openDialog({
title: 'Fixture dialog',
render: ({ close }) => h('div', null, h('p', null, 'Opened by the add-on.'), h(Button, { onClick: close }, 'Done')),
}),
}, 'Open dialog'),
h(Button, {
onClick: () => sdk.layers.openDialog({
title: 'Broken dialog',
render: () => { throw new Error('fixture layer failure') },
}),
}, 'Open broken dialog'),
h(Button, {
onClick: () => sdk.layers.openPanel({
title: 'Broken panel',
render: () => { throw new Error('fixture panel failure') },
}),
}, 'Open broken panel'))
}
function Root(props) {
if (props.page === 'boom') return h(Boom)
if (props.page === 'dialog') return h(Dialog, props)
return h(Home, props)
}
return {
Component: Root,
chatActions: [{
id: 'summarize', label: 'Summarize conversation', icon: 'sparkles',
open: (context) => { log('chat-action:' + JSON.stringify(context)) },
}],
dispose() { log('dispose:' + generation) },
}
}
@@ -0,0 +1,9 @@
@import url("https://evil.example/theme.css");
/* Rules that would restyle the host if they were not scoped. */
body { background: rgb(255, 0, 255); }
h1 { color: rgb(255, 0, 0); font-size: 11px; }
.page-title { color: rgb(255, 0, 0); }
.dk-btn { outline: 5px solid rgb(255, 0, 0); }
:root { --fx-token: 1; }
.title { color: rgb(1, 2, 3); }
.fx-home { display: block; background-image: url(https://evil.example/pixel.gif); }
+55
View File
@@ -0,0 +1,55 @@
{
"schemaVersion": 1,
"id": "sample",
"version": "1.2.3",
"executable": "bin/sample",
"os": "linux",
"arch": "amd64",
"host": {
"min": 1,
"max": 2
},
"api": {
"min": 1,
"max": 1
},
"protocol": {
"min": 1,
"max": 1
},
"dataFormat": {
"min": 1,
"max": 2
},
"launchMode": "always",
"capabilities": {
"service": true,
"tools": [
"query"
],
"ui": {
"entry": "ui/main.js",
"css": [
"ui/main.css"
],
"sdk": {
"min": 1,
"max": 1
},
"pages": [
"home",
"settings"
],
"navigation": [
"home"
],
"mainPage": "home",
"icon": "ui/icon.png",
"title": {
"en": "Sample",
"it": "Esempio"
},
"description": "A sample addon."
}
}
}
+36
View File
@@ -0,0 +1,36 @@
{
"schemaVersion": 1,
"id": "sample",
"version": "1.2.3",
"executable": "bin/sample",
"os": "linux",
"arch": "amd64",
"host": {
"min": 1,
"max": 2
},
"api": {
"min": 1,
"max": 1
},
"protocol": {
"min": 1,
"max": 1
},
"dataFormat": {
"min": 1,
"max": 2
},
"capabilities": {
"ui": {
"entry": "ui/main.js",
"sdk": {
"min": 1,
"max": 1
},
"chatActions": [
"open"
]
}
}
}
@@ -0,0 +1 @@
{"request":{"addonId":"sample","resourceId":"resource-1","operationId":"operation-1","generation":1,"action":"query","arguments":{"limit":1}},"authority":{"actor":{"userId":"user-1","credentialId":"key-1","mode":"named-user"},"scope":"read","expiresAt":"2030-01-01T00:00:00Z"}}
@@ -0,0 +1,30 @@
{
"schemaVersion": 1,
"id": "sample",
"version": "1.2.3",
"executable": "bin/sample",
"os": "linux",
"arch": "amd64",
"host": {
"min": 1,
"max": 2
},
"api": {
"min": 1,
"max": 1
},
"protocol": {
"min": 1,
"max": 1
},
"dataFormat": {
"min": 1,
"max": 2
},
"capabilities": {
"service": true,
"tools": [
"query"
]
}
}
@@ -0,0 +1,65 @@
[
{
"host": 3,
"api": 1,
"protocol": 1,
"sdk": 1,
"dataFormat": 1,
"os": "linux",
"arch": "amd64"
},
{
"host": 1,
"api": 3,
"protocol": 1,
"sdk": 1,
"dataFormat": 1,
"os": "linux",
"arch": "amd64"
},
{
"host": 1,
"api": 1,
"protocol": 3,
"sdk": 1,
"dataFormat": 1,
"os": "linux",
"arch": "amd64"
},
{
"host": 1,
"api": 1,
"protocol": 1,
"sdk": 3,
"dataFormat": 1,
"os": "linux",
"arch": "amd64"
},
{
"host": 1,
"api": 1,
"protocol": 1,
"sdk": 1,
"dataFormat": 3,
"os": "linux",
"arch": "amd64"
},
{
"host": 1,
"api": 1,
"protocol": 1,
"sdk": 1,
"dataFormat": 1,
"os": "unsupported",
"arch": "amd64"
},
{
"host": 1,
"api": 1,
"protocol": 1,
"sdk": 1,
"dataFormat": 1,
"os": "linux",
"arch": "unsupported"
}
]
+42
View File
@@ -0,0 +1,42 @@
{
"schemaVersion": 1,
"id": "sample",
"version": "1.2.3",
"executable": "bin/sample",
"os": "linux",
"arch": "amd64",
"host": {
"min": 1,
"max": 2
},
"api": {
"min": 1,
"max": 1
},
"protocol": {
"min": 1,
"max": 1
},
"dataFormat": {
"min": 1,
"max": 2
},
"capabilities": {
"ui": {
"entry": "ui/main.js",
"css": [
"ui/main.css"
],
"sdk": {
"min": 1,
"max": 1
},
"pages": [
"home"
],
"navigation": [
"home"
]
}
}
}
@@ -0,0 +1 @@
{"schemaVersion":1,"id":"sample","version":"1.2.3","executable":"bin/sample","os":"linux","arch":"amd64","host":{"min":1,"max":2},"api":{"min":1,"max":1},"protocol":{"min":1,"max":1},"dataFormat":{"min":1,"max":2},"capabilities":{"service":true}}
@@ -0,0 +1 @@
{"schemaVersion":1,"id":"sample","version":"1.2.3","executable":"bin/sample","os":"linux","arch":"amd64","host":{"min":1,"max":2},"api":{"min":1,"max":1},"protocol":{"min":1,"max":1},"dataFormat":{"min":1,"max":2},"capabilities":{"service":true}}
+158
View File
@@ -0,0 +1,158 @@
// SPDX-License-Identifier: MIT
// Package signing builds hermetic cryptographic fixtures, never production trust.
package signing
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/base64"
"encoding/hex"
"encoding/json"
"math/big"
"time"
"encoding/pem"
"github.com/cyberphone/json-canonicalization/go/src/webpki.org/jsoncanonicalizer"
ct "github.com/google/certificate-transparency-go"
"github.com/google/certificate-transparency-go/tls"
ctx509util "github.com/google/certificate-transparency-go/x509util"
"github.com/sigstore/sigstore-go/pkg/root"
)
type Authority struct {
root.BaseTrustedMaterial
parent *x509.Certificate
key, logKey, rekorKey *ecdsa.PrivateKey
logID, rekorID [32]byte
}
func New() (*Authority, error) {
key, e := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if e != nil {
return nil, e
}
tmpl := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "fixture root"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign}
raw, e := x509.CreateCertificate(rand.Reader, tmpl, tmpl, key.Public(), key)
if e != nil {
return nil, e
}
cert, e := x509.ParseCertificate(raw)
if e != nil {
return nil, e
}
rekorKey, e := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if e != nil {
return nil, e
}
rekorDER, e := x509.MarshalPKIXPublicKey(rekorKey.Public())
if e != nil {
return nil, e
}
logKey, e := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if e != nil {
return nil, e
}
der, e := x509.MarshalPKIXPublicKey(logKey.Public())
if e != nil {
return nil, e
}
return &Authority{parent: cert, key: key, logKey: logKey, rekorKey: rekorKey, logID: sha256.Sum256(der), rekorID: sha256.Sum256(rekorDER)}, nil
}
func (a *Authority) FulcioCertificateAuthorities() []root.CertificateAuthority {
return []root.CertificateAuthority{&root.FulcioCertificateAuthority{Root: a.parent}}
}
func (a *Authority) CTLogs() map[string]*root.TransparencyLog {
return map[string]*root.TransparencyLog{hex.EncodeToString(a.logID[:]): {PublicKey: a.logKey.Public()}}
}
// Bundle signs a manifest digest and embeds a real SCT and Rekor signed entry
// timestamp (the inclusion promise supported by Sigstore bundle v0.1).
func (a *Authority) Bundle(digest, identity, issuer string) ([]byte, error) {
key, e := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if e != nil {
return nil, e
}
now := time.Now().Add(-time.Second)
tmpl := &x509.Certificate{SerialNumber: big.NewInt(2), EmailAddresses: []string{identity}, NotBefore: now.Add(-time.Minute), NotAfter: now.Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, ExtraExtensions: []pkix.Extension{{Id: asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 1}, Value: []byte(issuer)}}}
der, e := x509.CreateCertificate(rand.Reader, tmpl, a.parent, key.Public(), a.key)
if e != nil {
return nil, e
}
pre, e := x509.ParseCertificate(der)
if e != nil {
return nil, e
}
sct := ct.SignedCertificateTimestamp{SCTVersion: ct.V1, LogID: ct.LogID{KeyID: a.logID}, Timestamp: uint64(now.UnixMilli())}
entry := ct.LogEntry{Leaf: ct.MerkleTreeLeaf{Version: ct.V1, LeafType: ct.TimestampedEntryLeafType, TimestampedEntry: &ct.TimestampedEntry{Timestamp: sct.Timestamp, EntryType: ct.PrecertLogEntryType, PrecertEntry: &ct.PreCert{IssuerKeyHash: sha256.Sum256(a.parent.RawSubjectPublicKeyInfo), TBSCertificate: pre.RawTBSCertificate}}}}
input, e := ct.SerializeSCTSignatureInput(sct, entry)
if e != nil {
return nil, e
}
hash := sha256.Sum256(input)
sig, e := a.logKey.Sign(rand.Reader, hash[:], crypto.SHA256)
if e != nil {
return nil, e
}
sct.Signature = ct.DigitallySigned{Algorithm: tls.SignatureAndHashAlgorithm{Hash: tls.SHA256, Signature: tls.ECDSA}, Signature: sig}
list, e := ctx509util.MarshalSCTsIntoSCTList([]*ct.SignedCertificateTimestamp{&sct})
if e != nil {
return nil, e
}
raw, e := tls.Marshal(*list)
if e != nil {
return nil, e
}
raw, e = asn1.Marshal(raw)
if e != nil {
return nil, e
}
tmpl.ExtraExtensions = append(tmpl.ExtraExtensions, pkix.Extension{Id: asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 11129, 2, 4, 2}, Value: raw})
der, e = x509.CreateCertificate(rand.Reader, tmpl, a.parent, key.Public(), a.key)
if e != nil {
return nil, e
}
cert, e := x509.ParseCertificate(der)
if e != nil {
return nil, e
}
digestBytes, e := hex.DecodeString(digest)
if e != nil {
return nil, e
}
sig, e = key.Sign(rand.Reader, digestBytes, crypto.SHA256)
if e != nil {
return nil, e
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})
body, e := json.Marshal(map[string]any{"apiVersion": "0.0.1", "kind": "hashedrekord", "spec": map[string]any{"data": map[string]any{"hash": map[string]string{"algorithm": "sha256", "value": digest}}, "signature": map[string]any{"content": sig, "publicKey": map[string]any{"content": certPEM}}}})
if e != nil {
return nil, e
}
body, e = jsoncanonicalizer.Transform(body)
if e != nil {
return nil, e
}
payload, e := json.Marshal(map[string]any{"body": body, "integratedTime": now.Unix(), "logIndex": 0, "logID": hex.EncodeToString(a.rekorID[:])})
if e != nil {
return nil, e
}
payload, e = jsoncanonicalizer.Transform(payload)
if e != nil {
return nil, e
}
hash = sha256.Sum256(payload)
set, e := a.rekorKey.Sign(rand.Reader, hash[:], crypto.SHA256)
if e != nil {
return nil, e
}
return json.Marshal(map[string]any{"mediaType": "application/vnd.dev.sigstore.bundle+json;version=0.1", "messageSignature": map[string]any{"messageDigest": map[string]any{"algorithm": "SHA2_256", "digest": digestBytes}, "signature": sig}, "verificationMaterial": map[string]any{"x509CertificateChain": map[string]any{"certificates": []any{map[string]any{"rawBytes": base64.StdEncoding.EncodeToString(der)}}}, "tlogEntries": []any{map[string]any{"logIndex": "0", "logId": map[string]any{"keyId": a.rekorID[:]}, "kindVersion": map[string]string{"kind": "hashedrekord", "version": "0.0.1"}, "integratedTime": now.Unix(), "inclusionPromise": map[string]any{"signedEntryTimestamp": set}, "canonicalizedBody": body}}}})
}
func (a *Authority) RekorLogs() map[string]*root.TransparencyLog {
return map[string]*root.TransparencyLog{hex.EncodeToString(a.rekorID[:]): {PublicKey: a.rekorKey.Public(), ValidityPeriodStart: time.Now().Add(-time.Hour)}}
}
+187
View File
@@ -0,0 +1,187 @@
// SPDX-License-Identifier: MIT
package cluster
import (
"archive/tar"
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"net"
"net/http"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"
"time"
"github.com/mudler/LocalAI/core/services/addons/catalog"
)
// Package describes one release of a fixture add-on.
type Package struct {
ID string
Version string
// Capabilities is the manifest "capabilities" object.
Capabilities map[string]any
// Fixture is written as fixture.json into the package (modes of the
// protocol fixture). Durable effect logs are enabled with Effects.
Fixture map[string]any
// Effects makes the fixture keep its commits in <effects dir>/<id>.log.
Effects string
// Files are extra package files, by package path.
Files map[string]string
// Executable is the package path of the executable. The default is
// bin/fixture, which holds the protocol fixture.
Executable string
// Binary replaces the library's executable for this package, for a real
// add-on built by the test.
Binary []byte
}
// Library serves releases as a catalog source on loopback.
type Library struct {
mu sync.Mutex
listener net.Listener
server *http.Server
exe []byte
releases []catalog.Release
archives map[string][]byte
}
// NewLibrary starts a catalog server. exe is the fixture executable that goes
// into every package built by Add. An empty path starts a library that only
// serves finished archives (AddArchive).
func NewLibrary(exePath string) (*Library, error) {
var exe []byte
if exePath != "" {
b, err := os.ReadFile(exePath) // #nosec G304 -- test harness reads files it created in its own work directory
if err != nil {
return nil, err
}
exe = b
}
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, err
}
lib := &Library{listener: l, exe: exe, archives: map[string][]byte{}}
mux := http.NewServeMux()
mux.HandleFunc("/library.yaml", func(w http.ResponseWriter, r *http.Request) {
lib.mu.Lock()
defer lib.mu.Unlock()
_ = catalog.WriteIndex(w, lib.releases)
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
lib.mu.Lock()
data, ok := lib.archives[strings.TrimPrefix(r.URL.Path, "/")]
lib.mu.Unlock()
if !ok {
http.NotFound(w, r)
return
}
_, _ = w.Write(data)
})
lib.server = &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
go func() { _ = lib.server.Serve(l) }()
return lib, nil
}
// URL is the library document address.
func (l *Library) URL() string { return "http://" + l.listener.Addr().String() + "/library.yaml" }
// Close stops the server.
func (l *Library) Close() { _ = l.server.Close() }
// Add builds a package archive and lists it. effectsDir receives the durable
// effect log of packages that ask for one.
func (l *Library) Add(p Package, effectsDir string) error {
executable, binary := p.Executable, p.Binary
if executable == "" {
executable = "bin/fixture"
}
if binary == nil {
binary = l.exe
}
manifest := map[string]any{
"schemaVersion": 1, "id": p.ID, "version": p.Version, "executable": executable,
"os": runtime.GOOS, "arch": runtime.GOARCH,
"host": map[string]int{"min": 1, "max": 2}, "api": map[string]int{"min": 1, "max": 1},
"protocol": map[string]int{"min": 1, "max": 1}, "dataFormat": map[string]int{"min": 1, "max": 2},
"launchMode": "always", "capabilities": p.Capabilities,
}
raw, err := json.Marshal(manifest)
if err != nil {
return err
}
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
add := func(name string, data []byte, mode int64) error {
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: mode, Size: int64(len(data))}); err != nil {
return err
}
_, err := tw.Write(data)
return err
}
if err := add("manifest.json", raw, 0o644); err != nil {
return err
}
if err := add(executable, binary, 0o755); err != nil {
return err
}
fixture := map[string]any{}
for k, v := range p.Fixture {
fixture[k] = v
}
if p.Effects != "" {
fixture["effects"] = filepath.Join(effectsDir, p.ID+".log")
}
if len(fixture) > 0 {
b, _ := json.Marshal(fixture)
if err := add("fixture.json", b, 0o644); err != nil {
return err
}
}
names := make([]string, 0, len(p.Files))
for n := range p.Files {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
if err := add(n, []byte(p.Files[n]), 0o644); err != nil {
return err
}
}
if err := tw.Close(); err != nil {
return err
}
name := fmt.Sprintf("%s-%s.tar", p.ID, p.Version)
l.mu.Lock()
defer l.mu.Unlock()
l.archives[name] = buf.Bytes()
l.releases = append(l.releases, catalog.Release{
ID: p.ID, Name: p.ID, Version: p.Version, OS: runtime.GOOS, Arch: runtime.GOARCH,
Digest: fmt.Sprintf("sha256:%x", sha256.Sum256(buf.Bytes())),
Artifact: "http://" + l.listener.Addr().String() + "/" + name,
})
return nil
}
// AddArchive lists a finished package archive (a plain tar with manifest.json
// at its root) as one release. The catalog digest is the digest of the archive
// bytes, which the installer verifies before it unpacks anything.
func (l *Library) AddArchive(id, version string, archive []byte) (string, error) {
name := fmt.Sprintf("%s-%s.tar", id, version)
digest := fmt.Sprintf("sha256:%x", sha256.Sum256(archive))
l.mu.Lock()
defer l.mu.Unlock()
l.archives[name] = archive
l.releases = append(l.releases, catalog.Release{
ID: id, Name: id, Version: version, OS: runtime.GOOS, Arch: runtime.GOARCH,
Digest: digest,
Artifact: "http://" + l.listener.Addr().String() + "/" + name,
})
return digest, nil
}
+107
View File
@@ -0,0 +1,107 @@
// SPDX-License-Identifier: MIT
package cluster
import (
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
// Cluster is a running fixture: the catalog, the configuration and the three
// server processes.
type Cluster struct {
Config *Config
Library *Library
Nodes map[string]*Node
Creds *Credentials
// Effects is where the fixture add-ons keep their durable commit logs.
Effects string
mu sync.Mutex
down bool
}
// Paths are the executables a cluster needs.
type Paths struct {
Server, Mock, Fixture string
}
// New generates a configuration under dir and starts the catalog. No process
// is started: call Up.
func New(dir string, p Paths) (*Cluster, error) {
lib, err := NewLibrary(p.Fixture)
if err != nil {
return nil, err
}
cfg, err := Generate(dir, lib.URL())
if err != nil {
lib.Close()
return nil, err
}
c := &Cluster{Config: cfg, Library: lib, Nodes: map[string]*Node{}, Effects: filepath.Join(cfg.Dir, "effects")}
if err := os.MkdirAll(c.Effects, 0o700); err != nil {
lib.Close()
return nil, err
}
for _, name := range Instances {
c.Nodes[name] = &Node{Name: name, Config: cfg, ServerPath: p.Server, MockPath: p.Mock}
}
return c, nil
}
// Up starts the owner, waits for it, and then starts the frontends.
func (c *Cluster) Up() error {
for _, name := range Instances {
c.mu.Lock()
if c.down {
c.mu.Unlock()
return fmt.Errorf("the cluster is down")
}
err := c.Nodes[name].Start()
c.mu.Unlock()
if err != nil {
return err
}
if err := c.Nodes[name].Ready(120 * time.Second); err != nil {
return err
}
}
creds, err := LoadCredentials(c.Config.Credentials)
if err != nil {
return fmt.Errorf("the owner wrote no credentials: %w", err)
}
c.Creds = creds
return nil
}
// Down stops every process and the catalog. It is idempotent, safe to call
// from a signal handler, and no node starts after it.
func (c *Cluster) Down() {
c.mu.Lock()
first := !c.down
c.down = true
c.mu.Unlock()
for _, name := range []string{FrontB, FrontA, Owner} {
c.Nodes[name].Stop()
}
if first {
c.Library.Close()
}
}
// Token returns a client of one node authenticated with the API key of a user.
func (c *Cluster) Token(node, user string) Client {
return Client{Base: c.Nodes[node].URL(), Bearer: c.Creds.Users[user].Token}
}
// Session returns a client of one node with the browser session of a user.
func (c *Cluster) Session(node, user string) Client {
return Client{Base: c.Nodes[node].URL(), Session: c.Creds.Users[user].Session}
}
// Control calls the owner's test control route.
func (c *Cluster) Control(path string, body any) (*Response, error) {
return Client{Base: "http://" + c.Config.ControlAddr}.Do("POST", "/"+path, body, map[string]string{"X-Foundation-Control": c.Config.Control})
}
+292
View File
@@ -0,0 +1,292 @@
// SPDX-License-Identifier: MIT
// Package cluster builds the foundation acceptance fixture: one add-on owner
// and two non-owner frontends, each a separate UI test server process with its
// own listener, sharing one account database and speaking the real mTLS
// carrier. The fixture add-on is the protocol fixture binary; nothing here
// imports a Tables package.
package cluster
import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"encoding/json"
"encoding/pem"
"fmt"
"math/big"
"net"
"os"
"path/filepath"
"sync"
"time"
"github.com/mudler/LocalAI/core/services/addons/gateway"
)
// Owner and the two frontends are the fixed instance names.
const (
Owner = "owner"
FrontA = "front-a"
FrontB = "front-b"
)
// Instances lists every instance name in start order.
var Instances = []string{Owner, FrontA, FrontB}
// Secret is the API key HMAC secret every instance shares.
const Secret = "foundation-fixture-secret"
// Instance is one process of the fixture.
type Instance struct {
Name string `json:"name"`
HTTPPort int `json:"httpPort"`
CarrierAddr string `json:"carrierAddr"`
// Dir holds the instance's models, data and logs. Root is the add-on root,
// which only the owner opens.
Dir string `json:"dir"`
Root string `json:"root"`
Cert string `json:"cert"`
Key string `json:"key"`
Sign string `json:"sign"`
}
// Config is the cluster.json every process reads.
type Config struct {
Dir string `json:"dir"`
AuthDB string `json:"authDb"`
Control string `json:"control"`
ControlAddr string `json:"controlAddr"`
CatalogURL string `json:"catalogUrl"`
Credentials string `json:"credentials"`
CA string `json:"ca"`
Pins map[string]string `json:"pins"`
Verify map[string]string `json:"verify"`
Nodes map[string]Instance `json:"nodes"`
// ArtifactExpiryMillis is the lifetime of a private file result in
// milliseconds; zero is the product default. A test that needs expiry
// sets it and saves the configuration before the cluster starts.
ArtifactExpiryMillis int64 `json:"artifactExpiryMillis,omitempty"`
// Alias names a derived configuration (see Derive); it only changes the
// file the configuration is saved to.
Alias string `json:"alias,omitempty"`
}
// Derive copies the configuration under another file name with the ports of
// the owner replaced, so a second owner can be started on the same storage.
func (c *Config) Derive(alias string) (*Config, error) {
d := *c
d.Alias = alias
d.Nodes = map[string]Instance{}
for k, v := range c.Nodes {
d.Nodes[k] = v
}
owner := d.Nodes[Owner]
var err error
if owner.HTTPPort, err = freePort(); err != nil {
return nil, err
}
// The carrier address stays: it is what the frontends dial, so the second
// owner would need the same listener and must fail before it gets it.
d.Nodes[Owner] = owner
if d.ControlAddr, err = freeAddr(); err != nil {
return nil, err
}
releasePorts()
return &d, d.Save()
}
func freeAddr() (string, error) {
p, err := freePort()
return fmt.Sprintf("127.0.0.1:%d", p), err
}
// Generate writes a fresh cluster configuration under dir. Ports are chosen
// by the operating system and released again; the processes bind them later.
func Generate(dir, catalogURL string) (*Config, error) {
dir, err := filepath.Abs(dir)
if err != nil {
return nil, err
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, err
}
pub, key, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, err
}
ca := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "foundation CA"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(48 * time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign}
der, err := x509.CreateCertificate(rand.Reader, ca, ca, pub, key)
if err != nil {
return nil, err
}
if ca, err = x509.ParseCertificate(der); err != nil {
return nil, err
}
control := make([]byte, 16)
_, _ = rand.Read(control)
controlPort, err := freePort()
if err != nil {
return nil, err
}
c := &Config{
ControlAddr: fmt.Sprintf("127.0.0.1:%d", controlPort),
Dir: dir, AuthDB: filepath.Join(dir, "auth.db"), Control: hex.EncodeToString(control), CatalogURL: catalogURL,
Credentials: filepath.Join(dir, "credentials.json"),
CA: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})),
Pins: map[string]string{}, Verify: map[string]string{}, Nodes: map[string]Instance{},
}
for i, name := range Instances {
leafPub, leafKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, err
}
leaf := &x509.Certificate{SerialNumber: big.NewInt(int64(i + 2)), Subject: pkix.Name{CommonName: name}, NotBefore: ca.NotBefore, NotAfter: ca.NotAfter, IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}}
leafDER, err := x509.CreateCertificate(rand.Reader, leaf, ca, leafPub, key)
if err != nil {
return nil, err
}
keyDER, err := x509.MarshalPKCS8PrivateKey(leafKey)
if err != nil {
return nil, err
}
signPub, signKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, err
}
pin := sha256.Sum256(leafDER)
c.Pins[name] = hex.EncodeToString(pin[:])
c.Verify[name] = hex.EncodeToString(signPub)
httpPort, err := freePort()
if err != nil {
return nil, err
}
carrier := ""
if name == Owner {
p, err := freePort()
if err != nil {
return nil, err
}
carrier = fmt.Sprintf("127.0.0.1:%d", p)
}
node := Instance{
Name: name, HTTPPort: httpPort, CarrierAddr: carrier, Dir: filepath.Join(dir, name), Root: filepath.Join(dir, name, "addons"),
Cert: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafDER})),
Key: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})),
Sign: hex.EncodeToString(signKey),
}
if err := os.MkdirAll(node.Dir, 0o700); err != nil {
return nil, err
}
c.Nodes[name] = node
}
releasePorts()
return c, c.Save()
}
// held keeps every chosen port bound until the configuration is complete, so
// that two choices can never return the same number.
var (
heldMu sync.Mutex
held []net.Listener
)
func freePort() (int, error) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, err
}
heldMu.Lock()
held = append(held, l)
heldMu.Unlock()
return l.Addr().(*net.TCPAddr).Port, nil
}
// releasePorts frees the chosen ports for the processes that will bind them.
func releasePorts() {
heldMu.Lock()
defer heldMu.Unlock()
for _, l := range held {
_ = l.Close()
}
held = nil
}
// Path is where cluster.json lives.
func (c *Config) Path() string {
if c.Alias != "" {
return filepath.Join(c.Dir, "cluster-"+c.Alias+".json")
}
return filepath.Join(c.Dir, "cluster.json")
}
// Save writes cluster.json.
func (c *Config) Save() error {
b, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
return os.WriteFile(c.Path(), b, 0o600)
}
// Load reads a cluster.json.
func Load(path string) (*Config, error) {
b, err := os.ReadFile(path) // #nosec G304 -- test harness reads files it created in its own work directory
if err != nil {
return nil, err
}
var c Config
if err := json.Unmarshal(b, &c); err != nil {
return nil, err
}
return &c, nil
}
// OwnerAddress is the address every instance dials for the owner.
func (c *Config) OwnerAddress() string { return "https://" + c.Nodes[Owner].CarrierAddr }
// Carrier builds the carrier identity of one instance.
func (c *Config) Carrier(name string) (*gateway.CarrierConfig, error) {
node, ok := c.Nodes[name]
if !ok {
return nil, fmt.Errorf("unknown instance %q", name)
}
pair, err := tls.X509KeyPair([]byte(node.Cert), []byte(node.Key))
if err != nil {
return nil, err
}
roots := x509.NewCertPool()
if !roots.AppendCertsFromPEM([]byte(c.CA)) {
return nil, fmt.Errorf("invalid CA")
}
signRaw, err := hex.DecodeString(node.Sign)
if err != nil {
return nil, err
}
pins := map[string][32]byte{}
for id, h := range c.Pins {
raw, err := hex.DecodeString(h)
if err != nil || len(raw) != 32 {
return nil, fmt.Errorf("invalid pin")
}
var pin [32]byte
copy(pin[:], raw)
pins[id] = pin
}
keys := map[string]ed25519.PublicKey{}
for id, h := range c.Verify {
raw, err := hex.DecodeString(h)
if err != nil {
return nil, err
}
keys[id] = ed25519.PublicKey(raw)
}
return &gateway.CarrierConfig{
InstanceID: name, OwnerID: Owner, OwnerAddress: c.OwnerAddress(),
Certificate: pair, Roots: roots, Pins: pins, SigningKey: ed25519.PrivateKey(signRaw), VerificationKeys: keys,
}, nil
}
+354
View File
@@ -0,0 +1,354 @@
// SPDX-License-Identifier: MIT
package cluster
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/mudler/LocalAI/pkg/httpclient"
)
// Node is one UI test server process of the cluster.
type Node struct {
Name string
Config *Config
ServerPath string
MockPath string
mu sync.Mutex
cmd *exec.Cmd
done chan struct{}
// groups are the process groups of every process this node ever started.
// A server leads a group of its own and its add-on and mock processes
// stay in it, even after the server is killed on purpose, so a sweep of
// the groups reaches an orphan that the scenario left behind.
groups []int
}
// URL is the HTTP address of the node.
func (n *Node) URL() string {
return fmt.Sprintf("http://127.0.0.1:%d", n.Config.Nodes[n.Name].HTTPPort)
}
// Start launches the process. It does not wait for readiness.
func (n *Node) Start() error {
n.mu.Lock()
defer n.mu.Unlock()
if n.cmd != nil && !n.exitedLocked() {
return fmt.Errorf("%s is already running", n.Name)
}
logFile, err := os.OpenFile(filepath.Join(n.Config.Dir, n.Name+".log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return err
}
node := n.Config.Nodes[n.Name]
cmd := exec.Command(n.ServerPath, // #nosec G204 -- test harness starts the binaries it built
"--mock-backend="+n.MockPath,
"--port="+strconv.Itoa(node.HTTPPort),
"--foundation="+n.Config.Path(),
"--foundation-role="+n.Name)
cmd.Stdout, cmd.Stderr = logFile, logFile
cmd.Env = append(cmd.Environ(), "TMPDIR="+node.Dir)
// Own process group, so teardown reaches every descendant. The server
// dies with the harness (Linux), so a killed test run leaves no server.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Pdeathsig: syscall.SIGKILL}
if err := cmd.Start(); err != nil {
_ = logFile.Close()
return err
}
done := make(chan struct{})
n.cmd, n.done = cmd, done
n.groups = append(n.groups, cmd.Process.Pid)
go func() { _ = cmd.Wait(); _ = logFile.Close(); close(done) }()
return nil
}
// PID is the process id, or zero when it was never started.
func (n *Node) PID() int {
n.mu.Lock()
defer n.mu.Unlock()
if n.cmd == nil || n.cmd.Process == nil {
return 0
}
return n.cmd.Process.Pid
}
// Exited reports whether the process has ended.
func (n *Node) Exited() bool {
n.mu.Lock()
defer n.mu.Unlock()
return n.exitedLocked()
}
func (n *Node) exitedLocked() bool {
if n.done == nil {
return true
}
select {
case <-n.done:
return true
default:
return false
}
}
// Wait blocks until the process ended or the timeout passed.
func (n *Node) Wait(timeout time.Duration) bool {
n.mu.Lock()
done := n.done
n.mu.Unlock()
if done == nil {
return true
}
select {
case <-done:
return true
case <-time.After(timeout):
return false
}
}
// Ready waits until the node answers its readiness probe.
func (n *Node) Ready(timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if n.Exited() {
return fmt.Errorf("%s exited before it was ready: %s", n.Name, n.Tail(15))
}
resp, err := httpclient.NewWithTimeout(5 * time.Second).Get(n.URL() + "/readyz")
if err == nil {
_ = resp.Body.Close()
if resp.StatusCode == 200 {
return nil
}
}
time.Sleep(100 * time.Millisecond)
}
return fmt.Errorf("%s was not ready in %s: %s", n.Name, timeout, n.Tail(15))
}
// Kill ends the server process alone, without any chance to clean up. Its
// descendants are left running on purpose: a scenario uses this to orphan an
// add-on. Stop sweeps them.
func (n *Node) Kill() {
n.mu.Lock()
cmd := n.cmd
n.mu.Unlock()
if cmd != nil && cmd.Process != nil && !n.Exited() {
_ = cmd.Process.Signal(syscall.SIGKILL)
n.Wait(15 * time.Second)
}
}
// Stop asks the server to end, waits a bounded time, and then kills every
// process of every group the node started. It is idempotent and also reaps
// what an earlier Kill left behind.
func (n *Node) Stop() {
n.mu.Lock()
cmd := n.cmd
n.mu.Unlock()
if cmd != nil && cmd.Process != nil && !n.Exited() {
_ = cmd.Process.Signal(syscall.SIGTERM)
if !n.Wait(20 * time.Second) {
n.Kill()
}
}
n.sweep(5 * time.Second)
}
// sweep stops what is left in the process groups of the node: SIGTERM, a
// bounded wait, then SIGKILL.
func (n *Node) sweep(grace time.Duration) {
n.mu.Lock()
groups := append([]int(nil), n.groups...)
n.mu.Unlock()
live := func() []int {
var out []int
for _, g := range groups {
if syscall.Kill(-g, 0) == nil {
out = append(out, g)
}
}
return out
}
for _, g := range live() {
_ = syscall.Kill(-g, syscall.SIGTERM)
}
deadline := time.Now().Add(grace)
for len(live()) > 0 && time.Now().Before(deadline) {
time.Sleep(50 * time.Millisecond)
}
for _, g := range live() {
_ = syscall.Kill(-g, syscall.SIGKILL)
}
for len(live()) > 0 && time.Now().Before(deadline.Add(5*time.Second)) {
time.Sleep(50 * time.Millisecond)
}
}
// Tail returns the last lines of the node log.
func (n *Node) Tail(lines int) string {
b, err := os.ReadFile(filepath.Join(n.Config.Dir, n.Name+".log"))
if err != nil {
return ""
}
parts := strings.Split(strings.TrimSpace(string(b)), "\n")
if len(parts) > lines {
parts = parts[len(parts)-lines:]
}
return strings.Join(parts, "\n")
}
// ChildrenOf lists the live direct children of a process, as pid and the
// command line, by reading /proc.
func ChildrenOf(pid int) map[int]string {
out := map[int]string{}
entries, _ := os.ReadDir("/proc")
for _, e := range entries {
child, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
stat, err := os.ReadFile("/proc/" + e.Name() + "/stat")
if err != nil {
continue
}
s := string(stat)
end := strings.LastIndex(s, ")")
if end < 0 {
continue
}
f := strings.Fields(s[end+1:])
// f[0] is the state and f[1] the parent.
if len(f) < 2 || f[0] == "Z" {
continue
}
if ppid, _ := strconv.Atoi(f[1]); ppid == pid {
cmdline, _ := os.ReadFile("/proc/" + e.Name() + "/cmdline")
out[child] = strings.ReplaceAll(strings.TrimRight(string(cmdline), "\x00"), "\x00", " ")
}
}
return out
}
// Alive reports whether a process id is a live, non-zombie process.
func Alive(pid int) bool {
b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
if err != nil {
return false
}
s := string(b)
end := strings.LastIndex(s, ")")
if end < 0 {
return false
}
f := strings.Fields(s[end+1:])
return len(f) > 0 && f[0] != "Z"
}
// Credentials are the accounts the owner seeded.
type Credentials struct {
Users map[string]User `json:"users"`
Packages []string `json:"packages"`
}
// User is one seeded account with its API key and browser session.
type User struct {
ID string `json:"id"`
Role string `json:"role"`
Token string `json:"token"`
Session string `json:"session"`
KeyID string `json:"keyId"`
}
// LoadCredentials reads the file the owner wrote.
func LoadCredentials(path string) (*Credentials, error) {
b, err := os.ReadFile(path) // #nosec G304 -- test harness reads files it created in its own work directory
if err != nil {
return nil, err
}
var c Credentials
return &c, json.Unmarshal(b, &c)
}
// Response is one HTTP answer.
type Response struct {
Status int
Header http.Header
Body []byte
}
// JSON decodes the body.
func (r *Response) JSON(v any) error { return json.Unmarshal(r.Body, v) }
// String is the body as text.
func (r *Response) String() string { return string(r.Body) }
// Client talks to one node as one account.
type Client struct {
Base string
Bearer string
Session string
// Headers are sent on every request, after the credential headers.
Headers map[string]string
}
// Do sends one request. A session client sends the browser fetch metadata the
// add-on routes require; a bearer client sends none.
func (c Client) Do(method, path string, body any, extra map[string]string) (*Response, error) {
var reader io.Reader
if body != nil {
if raw, ok := body.(string); ok {
reader = strings.NewReader(raw)
} else {
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
reader = bytes.NewReader(b)
}
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, method, c.Base+path, reader)
if err != nil {
return nil, err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
switch {
case c.Bearer != "":
req.Header.Set("Authorization", "Bearer "+c.Bearer)
case c.Session != "":
req.Header.Set("Cookie", "session="+c.Session)
req.Header.Set("Sec-Fetch-Site", "same-origin")
req.Header.Set("Sec-Fetch-Mode", "cors")
req.Header.Set("Sec-Fetch-Dest", "empty")
}
for k, v := range c.Headers {
req.Header.Set(k, v)
}
for k, v := range extra {
req.Header.Set(k, v)
}
resp, err := httpclient.New().Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
data, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
return &Response{Status: resp.StatusCode, Header: resp.Header, Body: data}, err
}
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: MIT
package cluster
// The fixture packages. All run the protocol fixture; they differ only in what
// the manifest declares, so the three shapes the host must support are covered:
// a UI-bearing package, a tool package without a UI and a service only package.
// UIModule is a minimal add-on interface in the compiled form of the SDK
// contract: it draws a title and a button that reads through the host.
const UIModule = `export function createAddon(sdk) {
const h = sdk.React.createElement
return {
Component: () => h('section', null,
h('h2', { 'data-testid': 'fnd-title' }, 'Foundation fixture'),
h('button', { 'data-testid': 'fnd-read', onClick: () => sdk.api.read({ arguments: { tool: 'echo' } }).then(
(out) => { window.__fnd = { ok: out } }, (e) => { window.__fnd = { error: e && e.status } }) }, 'Read')),
dispose() {},
}
}
`
// UIPackage is the UI-bearing add-on with tools.
func UIPackage(version string, fixture map[string]any) Package {
return Package{
ID: "found-ui", Version: version, Effects: "yes", Fixture: fixture,
Capabilities: map[string]any{
"service": true, "tools": []string{"echo", "export"},
"ui": map[string]any{"entry": "ui.js", "css": []string{"ui.css"}, "sdk": map[string]int{"min": 1, "max": 2}, "pages": []string{"home"}, "mainPage": "home", "title": "Foundation", "description": "Foundation fixture."},
},
Files: map[string]string{"ui.js": UIModule, "ui.css": "body{}\n", "private.txt": "not declared"},
}
}
// ToolPackage is a tool add-on without a UI.
func ToolPackage(version string, fixture map[string]any) Package {
return Package{ID: "found-tool", Version: version, Effects: "yes", Fixture: fixture, Capabilities: map[string]any{"service": true, "tools": []string{"echo", "export"}}}
}
// ServicePackage declares only a service: no tool, no page.
func ServicePackage(version string, fixture map[string]any) Package {
return Package{ID: "found-svc", Version: version, Effects: "yes", Fixture: fixture, Capabilities: map[string]any{"service": true}}
}
+182
View File
@@ -0,0 +1,182 @@
// SPDX-License-Identifier: MIT
package cluster
import (
"context"
"crypto/subtle"
"encoding/json"
"fmt"
"net"
"net/http"
"os"
"path/filepath"
"runtime"
"time"
"github.com/google/uuid"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/auth"
"github.com/mudler/LocalAI/core/services/addons/catalog"
"github.com/mudler/LocalAI/core/services/addons/policy"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// AppOptions are the application options of one role. The account database is
// the same file for every instance, so a session or key is valid on all.
func (c *Config) AppOptions(role string) ([]config.AppOption, error) {
node, ok := c.Nodes[role]
if !ok {
return nil, fmt.Errorf("unknown role %q", role)
}
transport, err := c.Carrier(role)
if err != nil {
return nil, err
}
addon := config.AddonConfig{
Enabled: true, InstanceID: role, OwnerID: Owner, OwnerAddress: c.OwnerAddress(), StorageID: "disk",
Root: node.Root, Transport: transport,
Host: contract.HostCompatibility{Host: 1, API: 1, Protocol: 1, SDK: 1, DataFormat: 1, OS: runtime.GOOS, Arch: runtime.GOARCH},
}
if role == Owner {
addon.ListenAddress = node.CarrierAddr
addon.ArtifactExpiry = time.Duration(c.ArtifactExpiryMillis) * time.Millisecond
}
return []config.AppOption{
config.WithAuthEnabled(true),
config.WithAuthDatabaseURL(c.AuthDB),
config.WithAuthAPIKeyHMACSecret(Secret),
config.WithAddons(addon),
}, nil
}
// Seed creates the accounts and the catalog source once, on the first start of
// the owner, and writes the credentials. A restart keeps them.
func (c *Config) Seed(ctx context.Context, app *application.Application) error {
if _, err := os.Stat(c.Credentials); err == nil {
return nil
}
db := app.AuthDB()
if db == nil {
return fmt.Errorf("authentication is not enabled")
}
creds := Credentials{Users: map[string]User{}}
var admin contract.Actor
for _, u := range []struct {
name, role string
feature bool
}{{"admin", auth.RoleAdmin, true}, {"member", auth.RoleUser, true}, {"friend", auth.RoleUser, true}, {"outsider", auth.RoleUser, false},
// Two more ordinary accounts for suites that need a viewer and an
// editor next to the owner of a resource.
{"viewer", auth.RoleUser, true}, {"editor", auth.RoleUser, true}} {
user := auth.User{ID: "fnd-" + u.name, Email: u.name + "@foundation.test", Name: u.name, Provider: auth.ProviderLocal, Role: u.role, Status: auth.StatusActive}
if err := db.Create(&user).Error; err != nil {
return err
}
if u.feature && u.role != auth.RoleAdmin {
if err := db.Create(&auth.UserPermission{ID: uuid.NewString(), UserID: user.ID, Permissions: auth.PermissionMap{auth.FeatureAddons: true}}).Error; err != nil {
return err
}
}
token, key, err := auth.CreateAPIKey(db, user.ID, "foundation", u.role, Secret, nil)
if err != nil {
return err
}
session, err := auth.CreateSession(db, user.ID, Secret)
if err != nil {
return err
}
creds.Users[u.name] = User{ID: user.ID, Role: u.role, Token: token, Session: session, KeyID: key.ID}
if u.name == "admin" {
admin = contract.Actor{UserID: user.ID, CredentialID: "key:" + key.ID, Mode: auth.AddonModeNamedUser}
}
}
sources := app.AddonSources()
if sources == nil {
return fmt.Errorf("the add-on owner did not start")
}
if err := sources.Save(ctx, admin, catalog.Source{ID: "fixture", Name: "Fixture", URL: c.CatalogURL, Enabled: true, AllowLoopbackHTTP: true}); err != nil {
return err
}
if _, err := sources.Refresh(ctx, "fixture"); err != nil {
return err
}
b, err := json.MarshalIndent(creds, "", " ")
if err != nil {
return err
}
tmp := c.Credentials + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return err
}
return os.Rename(tmp, c.Credentials)
}
// ControlServer serves the owner-only test control routes on loopback: the
// fixture has no product route that creates a resource, so a test registers
// one through the owner's own policy here. It never grants more than the
// policy itself would.
func (c *Config) ControlServer(app *application.Application) (*http.Server, error) {
mux := http.NewServeMux()
mux.HandleFunc("/resource", func(w http.ResponseWriter, r *http.Request) {
if subtle.ConstantTimeCompare([]byte(r.Header.Get("X-Foundation-Control")), []byte(c.Control)) != 1 || r.Method != http.MethodPost {
http.Error(w, "denied", http.StatusForbidden)
return
}
var req struct {
AddonID string `json:"addonId"`
Owner string `json:"owner"`
Grants map[string]string `json:"grants"`
}
if json.NewDecoder(r.Body).Decode(&req) != nil {
http.Error(w, "invalid", http.StatusBadRequest)
return
}
creds, err := LoadCredentials(c.Credentials)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
actorOf := func(name string) (contract.Actor, bool) {
u, ok := creds.Users[name]
return contract.Actor{UserID: u.ID, CredentialID: "key:" + u.KeyID, Mode: auth.AddonModeNamedUser}, ok
}
owner, ok := actorOf(req.Owner)
p := app.AddonPolicy()
if !ok || p == nil {
http.Error(w, "no owner policy", http.StatusConflict)
return
}
ctx := r.Context()
key, err := p.Register(ctx, owner, req.AddonID)
if err == nil {
err = p.Commit(ctx, owner, key)
}
for name, role := range req.Grants {
if err != nil {
break
}
u, ok := creds.Users[name]
if !ok {
err = fmt.Errorf("unknown user %q", name)
break
}
err = p.SetGrant(ctx, owner, key, u.ID, policy.Role(role))
}
if err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
_ = json.NewEncoder(w).Encode(map[string]string{"resourceId": key.ResourceID})
})
l, err := net.Listen("tcp", c.ControlAddr)
if err != nil {
return nil, err
}
s := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
go func() { _ = s.Serve(l) }()
return s, nil
}
// StateDir is where one instance keeps its models, data and generated files.
func (c *Config) StateDir(role string) string { return filepath.Join(c.Nodes[role].Dir, "state") }
@@ -0,0 +1,98 @@
// SPDX-License-Identifier: MIT
package cluster
import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
. "github.com/onsi/gomega"
)
// fakeServer is a server stand-in: it starts two helpers that ignore SIGTERM,
// as an add-on in orphan mode does, records their ids and then waits.
const fakeServer = `#!/bin/sh
dir=$(dirname "$0")
for n in 1 2; do
( trap '' TERM; exec sleep 300 ) &
echo $! >> "$dir/helpers"
done
echo ready > "$dir/ready"
while :; do sleep 1; done
`
func helperPIDs(t *testing.T, dir string) []int {
t.Helper()
var pids []int
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(filepath.Join(dir, "ready")); err == nil {
break
}
time.Sleep(20 * time.Millisecond)
}
b, err := os.ReadFile(filepath.Join(dir, "helpers"))
NewWithT(t).Expect(err).NotTo(HaveOccurred())
for _, f := range strings.Fields(string(b)) {
n, _ := strconv.Atoi(f)
pids = append(pids, n)
}
NewWithT(t).Expect(pids).To(HaveLen(2), "want two helpers")
return pids
}
func newFakeNode(t *testing.T) (*Node, string) {
t.Helper()
dir := t.TempDir()
script := filepath.Join(dir, "server.sh")
NewWithT(t).Expect(os.WriteFile(script, []byte(fakeServer), 0o700)).To(Succeed())
cfg := &Config{Dir: dir, Nodes: map[string]Instance{Owner: {}}}
return &Node{Name: Owner, Config: cfg, ServerPath: script}, dir
}
func assertGone(t *testing.T, pids ...int) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
alive := false
for _, p := range pids {
alive = alive || Alive(p)
}
if !alive {
return
}
time.Sleep(50 * time.Millisecond)
}
NewWithT(t).Expect(false).To(BeTrue(), "processes survived the teardown: %v", pids)
}
// A failing scenario ends in Stop: the helpers that ignore SIGTERM must go.
func TestStopReapsDescendantsThatIgnoreSIGTERM(t *testing.T) {
n, dir := newFakeNode(t)
NewWithT(t).Expect(n.Start()).To(Succeed())
helpers := helperPIDs(t, dir)
n.Stop()
assertGone(t, append(helpers, n.PID())...)
n.Stop() // idempotent
}
// A scenario that kills the server on purpose orphans its helpers; Stop must
// still reach them, across a restart of the same node.
func TestStopReapsOrphansLeftByKill(t *testing.T) {
n, dir := newFakeNode(t)
NewWithT(t).Expect(n.Start()).To(Succeed())
first := helperPIDs(t, dir)
n.Kill()
for _, p := range first {
NewWithT(t).Expect(Alive(p)).To(BeTrue(), "Kill must leave the descendants running, %d is gone", p)
}
_ = os.Remove(filepath.Join(dir, "ready"))
_ = os.Remove(filepath.Join(dir, "helpers"))
NewWithT(t).Expect(n.Start()).To(Succeed())
second := helperPIDs(t, dir)
n.Stop()
assertGone(t, append(first, second...)...)
}
@@ -0,0 +1,98 @@
// SPDX-License-Identifier: MIT
// Command foundation-cluster runs the foundation fixture for the browser
// checks: one owner and two frontends, the catalog on loopback, and the UI
// package already installed and enabled through a frontend. It writes a
// ready file with the addresses and accounts and runs until it is stopped.
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/mudler/LocalAI/tests/addons/foundation/cluster"
)
func main() {
dir := flag.String("dir", "", "directory of the cluster state (created)")
server := flag.String("server", "", "UI test server executable")
mock := flag.String("mock-backend", "", "mock backend executable")
fixture := flag.String("fixture", "", "protocol fixture executable")
ready := flag.String("ready", "", "file written when the cluster is serving")
flag.Parse()
if err := run(*dir, cluster.Paths{Server: *server, Mock: *mock, Fixture: *fixture}, *ready); err != nil {
fmt.Fprintln(os.Stderr, "foundation-cluster:", err)
os.Exit(1)
}
}
func run(dir string, p cluster.Paths, ready string) error {
if dir == "" || ready == "" || p.Server == "" || p.Mock == "" || p.Fixture == "" {
return fmt.Errorf("-dir, -server, -mock-backend, -fixture and -ready are required")
}
c, err := cluster.New(dir, p)
if err != nil {
return err
}
defer c.Down()
// A signal during start-up or serving tears everything down first.
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
interrupted := make(chan struct{})
go func() {
<-stop
c.Down()
close(interrupted)
os.Exit(1)
}()
if err := c.Library.Add(cluster.UIPackage("1.0.0", nil), c.Effects); err != nil {
return err
}
if err := c.Up(); err != nil {
return err
}
// Installed and enabled through a frontend, as an administrator would.
admin := c.Token(cluster.FrontA, "admin")
for i, action := range []string{"install", "enable"} {
args := map[string]any{}
if action == "install" {
args = map[string]any{"sourceId": "fixture", "version": "1.0.0"}
}
r, err := admin.Do("POST", "/api/addons/dispatch", map[string]any{"operationId": fmt.Sprintf("browser-%s-%d", action, i), "addonId": "found-ui", "generation": 1, "action": action, "arguments": args}, nil)
if err != nil || r.Status != 200 {
return fmt.Errorf("%s: %v %v", action, err, r)
}
}
deadline := time.Now().Add(30 * time.Second)
for {
r, err := admin.Do("GET", "/api/addons/apps", nil, nil)
if err == nil && r.Status == 200 && strings.Contains(string(r.Body), `"state":"running"`) {
break
}
if time.Now().After(deadline) {
return fmt.Errorf("the add-on did not start")
}
time.Sleep(200 * time.Millisecond)
}
out := map[string]any{"urls": map[string]string{}, "users": c.Creds.Users, "dir": c.Config.Dir}
for name, n := range c.Nodes {
out["urls"].(map[string]string)[name] = n.URL()
}
b, _ := json.MarshalIndent(out, "", " ")
tmp := filepath.Clean(ready) + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return err
}
if err := os.Rename(tmp, ready); err != nil {
return err
}
<-interrupted
return nil
}
@@ -0,0 +1,188 @@
// SPDX-License-Identifier: MIT
package foundation_test
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
"time"
"github.com/mudler/LocalAI/tests/addons/foundation/cluster"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestFoundation(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Add-ons foundation acceptance")
}
// paths are the executables every cluster of this suite runs.
var paths cluster.Paths
// repoRoot is the root of this repository.
var repoRoot string
var _ = BeforeSuite(func() {
_, file, _, _ := runtime.Caller(0)
repoRoot = filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", ".."))
scratch, err := os.MkdirTemp("", "foundation-bin-*")
Expect(err).NotTo(HaveOccurred())
DeferCleanup(func() { _ = os.RemoveAll(scratch) })
build := func(env, out, pkg string) string {
if p := os.Getenv(env); p != "" {
return p
}
target := filepath.Join(scratch, out)
cmd := exec.Command("go", "build", "-p", "2", "-tags", "auth", "-o", target, pkg)
cmd.Dir = repoRoot
b, err := cmd.CombinedOutput()
Expect(err).NotTo(HaveOccurred(), string(b))
return target
}
paths.Fixture = build("FOUNDATION_FIXTURE", "fixture-addon", "./tests/addons/fixture/cmd")
paths.Server = build("FOUNDATION_SERVER", "ui-test-server", "./tests/e2e-ui")
paths.Mock = os.Getenv("FOUNDATION_MOCK")
if paths.Mock == "" {
paths.Mock = filepath.Join(repoRoot, "tests/e2e/mock-backend/mock-backend")
}
Expect(paths.Mock).To(BeAnExistingFile(), "build tests/e2e/mock-backend first (scripts/addons/prepare.sh --integration)")
})
// start brings a cluster up in a fresh directory and takes it down at the end
// of the spec container. Package directories are read-only, so the cleanup
// restores permissions first.
func start() *cluster.Cluster {
dir, err := os.MkdirTemp("", "foundation-cluster-*")
Expect(err).NotTo(HaveOccurred())
c, err := cluster.New(dir, paths)
Expect(err).NotTo(HaveOccurred())
DeferCleanup(func() {
c.Down()
_ = filepath.WalkDir(dir, func(p string, d fs.DirEntry, e error) error {
if e == nil && d.IsDir() {
_ = os.Chmod(p, 0o700)
}
return nil
})
_ = os.RemoveAll(dir)
})
return c
}
// ---- small API helpers over the HTTP client ----
type apiError struct {
Error struct {
Type string `json:"type"`
Message string `json:"message"`
OperationID string `json:"operationId"`
} `json:"error"`
}
var counter int
func nextID(prefix string) string {
counter++
return fmt.Sprintf("%s-%d-%d", prefix, time.Now().UnixNano()%1_000_000, counter)
}
func dispatch(c cluster.Client, request map[string]any) *cluster.Response {
if _, ok := request["operationId"]; !ok {
request["operationId"] = nextID("op")
}
if _, ok := request["arguments"]; !ok {
request["arguments"] = map[string]any{}
}
r, err := c.Do("POST", "/api/addons/dispatch", request, nil)
Expect(err).NotTo(HaveOccurred())
return r
}
type app struct {
ID string `json:"id"`
Generation uint64 `json:"generation"`
Enabled bool `json:"enabled"`
State string `json:"state"`
Tools []string `json:"tools"`
HasUI bool `json:"hasUI"`
UI *struct {
Entry string `json:"entry"`
CSS []string `json:"css"`
Assets *struct {
Digest string `json:"digest"`
Base string `json:"base"`
} `json:"assets"`
} `json:"ui"`
Version string `json:"version"`
}
func apps(c cluster.Client) (int, []app) {
r, err := c.Do("GET", "/api/addons/apps", nil, nil)
Expect(err).NotTo(HaveOccurred())
var out struct {
Apps []app `json:"apps"`
}
if r.Status == 200 {
Expect(r.JSON(&out)).To(Succeed(), r.String())
}
return r.Status, out.Apps
}
func appOf(c cluster.Client, id string) *app {
status, list := apps(c)
Expect(status).To(Equal(200))
for i := range list {
if list[i].ID == id {
return &list[i]
}
}
return nil
}
// settle polls a condition with a deadline; the condition is a fact of the
// system under test, never a stand-in for synchronisation.
func settle(what string, timeout time.Duration, f func() bool) {
GinkgoHelper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if f() {
return
}
time.Sleep(150 * time.Millisecond)
}
Fail("timed out waiting for " + what)
}
func sha(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) }
// hashTree digests a file or directory so a spec can prove it did not change.
func hashTree(path string) string {
h := sha256.New()
info, err := os.Stat(path)
Expect(err).NotTo(HaveOccurred())
if !info.IsDir() {
b, err := os.ReadFile(path)
Expect(err).NotTo(HaveOccurred())
return sha(b)
}
Expect(filepath.WalkDir(path, func(p string, d fs.DirEntry, e error) error {
if e != nil || d.IsDir() {
return e
}
rel, _ := filepath.Rel(path, p)
b, err := os.ReadFile(p)
if err != nil {
return err
}
_, _ = fmt.Fprintf(h, "%s\x00%s\x00", rel, sha(b))
return nil
})).To(Succeed())
return hex.EncodeToString(h.Sum(nil))
}
+506
View File
@@ -0,0 +1,506 @@
// SPDX-License-Identifier: MIT
package foundation_test
import (
"time"
"os"
"path/filepath"
"strings"
"github.com/mudler/LocalAI/tests/addons/foundation/cluster"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// lifecycle submits one administrator lifecycle action.
func lifecycle(cl cluster.Client, id, action string, generation uint64, version string) *cluster.Response {
args := map[string]any{}
if version != "" {
args = map[string]any{"sourceId": "fixture", "version": version}
}
return dispatch(cl, map[string]any{"addonId": id, "action": action, "generation": generation, "arguments": args})
}
// resource registers one resource on the owner and returns its identifier.
func resource(c *cluster.Cluster, addon, owner string, grants map[string]string) string {
GinkgoHelper()
r, err := c.Control("resource", map[string]any{"addonId": addon, "owner": owner, "grants": grants})
Expect(err).NotTo(HaveOccurred())
Expect(r.Status).To(Equal(200), r.String())
var out struct {
ResourceID string `json:"resourceId"`
}
Expect(r.JSON(&out)).To(Succeed())
return out.ResourceID
}
type result struct {
Payload struct {
Actor string `json:"actor"`
Arguments map[string]any `json:"arguments"`
} `json:"payload"`
Outputs []struct {
Handle string `json:"handle"`
Name string `json:"name"`
MediaType string `json:"mediaType"`
Size int64 `json:"size"`
} `json:"outputs"`
}
// read is a typed read of a resource through one client.
func read(cl cluster.Client, addon, rid string, generation uint64, args map[string]any) *cluster.Response {
return dispatch(cl, map[string]any{"addonId": addon, "resourceId": rid, "generation": generation, "action": "read", "arguments": args})
}
func actorOf(r *cluster.Response) string {
GinkgoHelper()
Expect(r.Status).To(Equal(200), r.String())
var out result
Expect(r.JSON(&out)).To(Succeed())
return out.Payload.Actor
}
func errorType(r *cluster.Response) string {
var e apiError
_ = r.JSON(&e)
return e.Error.Type
}
// lines counts the non-empty lines of a durable fixture log.
func lines(path string) int {
b, err := os.ReadFile(path)
if err != nil {
return 0
}
n := 0
for _, l := range strings.Split(string(b), "\n") {
if strings.TrimSpace(l) != "" {
n++
}
}
return n
}
// fixtureChildren counts the live fixture processes that are children of pid.
func fixtureChildren(pid int) int {
n := 0
for _, cmdline := range cluster.ChildrenOf(pid) {
if strings.Contains(cmdline, "bin/fixture") {
n++
}
}
return n
}
func dataDir(c *cluster.Cluster, id string) string {
return filepath.Join(c.Config.Nodes[cluster.Owner].Root, "data", id)
}
// retainData writes a marker the add-on's retained data must keep.
func retainData(c *cluster.Cluster, id string) string {
GinkgoHelper()
dir := dataDir(c, id)
Expect(os.MkdirAll(dir, 0o700)).To(Succeed())
marker := filepath.Join(dir, "rows.db")
Expect(os.WriteFile(marker, []byte("retained rows"), 0o600)).To(Succeed())
return marker
}
func expectRetained(marker string) {
GinkgoHelper()
b, err := os.ReadFile(marker)
Expect(err).NotTo(HaveOccurred(), "retained data must survive")
Expect(string(b)).To(Equal("retained rows"))
}
var forged = map[string]string{
"X-Forwarded-Actor": "fnd-admin", "X-LocalAI-Actor": "fnd-admin", "X-LocalAI-User": "fnd-admin",
"X-Actor-Id": "fnd-admin", "X-Forwarded-User": "fnd-admin", "X-Remote-User": "fnd-admin",
"X-LocalAI-Credential": "key:forged", "X-LocalAI-Authority": `{"actor":{"userId":"fnd-admin"},"scope":"read"}`,
}
var _ = Describe("foundation fixture through the owner and two frontends", Ordered, func() {
var (
c *cluster.Cluster
admin cluster.Client
hostBin string
hostUI string
binSum string
uiSum string
toolRes string
uiRes string
svcRes string
exportID string
)
front := []string{cluster.FrontA, cluster.FrontB}
BeforeAll(func() {
hostBin = paths.Server
hostUI = filepath.Join(repoRoot, "core/http/react-ui/dist")
if _, err := os.Stat(hostUI); err != nil {
hostUI = ""
}
binSum = hashTree(hostBin)
if hostUI != "" {
uiSum = hashTree(hostUI)
}
c = start()
Expect(c.Library.Add(cluster.UIPackage("1.0.0", nil), c.Effects)).To(Succeed())
Expect(c.Library.Add(cluster.UIPackage("2.0.0", nil), c.Effects)).To(Succeed())
Expect(c.Library.Add(cluster.ToolPackage("1.0.0", nil), c.Effects)).To(Succeed())
Expect(c.Library.Add(cluster.ServicePackage("1.0.0", map[string]any{"mode": "lose-reply"}), c.Effects)).To(Succeed())
Expect(c.Up()).To(Succeed())
admin = c.Token(cluster.FrontA, "admin")
})
It("installs the UI, tool and service-only packages from the catalog through a frontend without starting them and leaves the host binary and UI build unchanged", func() {
for _, id := range []string{"found-ui", "found-tool", "found-svc"} {
r := lifecycle(admin, id, "install", 0, "1.0.0")
Expect(r.Status).To(Equal(200), r.String())
}
status, list := apps(admin)
Expect(status).To(Equal(200))
Expect(list).To(HaveLen(3))
for _, a := range list {
Expect(a.Enabled).To(BeFalse(), a.ID)
Expect(a.State).To(Equal("stopped"), a.ID)
}
Expect(fixtureChildren(c.Nodes[cluster.Owner].PID())).To(BeZero(), "installing must not start any process")
Expect(hashTree(hostBin)).To(Equal(binSum), "the host binary must not change")
if hostUI != "" {
Expect(hashTree(hostUI)).To(Equal(uiSum), "the shipped UI build must not change")
}
})
It("activates each package through the other frontend and runs one process per add-on under the owner only", func() {
viaB := c.Token(cluster.FrontB, "admin")
for _, id := range []string{"found-ui", "found-tool", "found-svc"} {
r := lifecycle(viaB, id, "enable", 1, "")
Expect(r.Status).To(Equal(200), r.String())
}
settle("three running add-ons", 30*time.Second, func() bool {
_, list := apps(admin)
n := 0
for _, a := range list {
if a.State == "running" {
n++
}
}
return n == 3
})
Expect(fixtureChildren(c.Nodes[cluster.Owner].PID())).To(Equal(3))
for _, f := range front {
Expect(fixtureChildren(c.Nodes[f].PID())).To(BeZero(), f+" must never start an add-on")
Expect(filepath.Join(c.Config.Nodes[f].Root)).NotTo(BeADirectory(), f+" must not open add-on storage")
}
toolRes = resource(c, "found-tool", "member", map[string]string{"friend": "viewer"})
uiRes = resource(c, "found-ui", "member", map[string]string{"friend": "viewer"})
svcRes = resource(c, "found-svc", "member", nil)
})
It("lists the same apps from the owner and both frontends and shows only what each declared", func() {
var seen [][]string
for _, n := range []string{cluster.Owner, cluster.FrontA, cluster.FrontB} {
status, list := apps(c.Token(n, "member"))
Expect(status).To(Equal(200), n)
byID := map[string]app{}
ids := []string{}
for _, a := range list {
byID[a.ID] = a
ids = append(ids, a.ID+":"+a.State)
}
seen = append(seen, ids)
Expect(byID["found-ui"].HasUI).To(BeTrue())
Expect(byID["found-tool"].HasUI).To(BeFalse(), "the tool package declares no UI")
Expect(byID["found-tool"].UI).To(BeNil())
Expect(byID["found-tool"].Tools).To(ConsistOf("echo", "export"))
Expect(byID["found-svc"].HasUI).To(BeFalse())
Expect(byID["found-svc"].Tools).To(BeEmpty(), "the service-only package declares no tool")
}
Expect(seen[1]).To(ConsistOf(seen[0]))
Expect(seen[2]).To(ConsistOf(seen[0]))
})
It("serves reads of the UI, tool and service-only packages alternately through the owner and both frontends", func() {
nodes := []string{cluster.FrontA, cluster.FrontB, cluster.Owner, cluster.FrontB, cluster.FrontA, cluster.Owner}
for i, n := range nodes {
Expect(actorOf(read(c.Token(n, "member"), "found-ui", uiRes, 1, map[string]any{"tool": "echo", "i": i}))).To(Equal("fnd-member"), n)
Expect(actorOf(read(c.Token(n, "friend"), "found-tool", toolRes, 1, map[string]any{"tool": "echo"}))).To(Equal("fnd-friend"), n)
Expect(actorOf(read(c.Token(n, "member"), "found-svc", svcRes, 1, map[string]any{"q": "plain"}))).To(Equal("fnd-member"), n)
}
})
It("denies an account without the feature, a stranger to the resource and an anonymous caller the same way on every node", func() {
for _, n := range []string{cluster.Owner, cluster.FrontA, cluster.FrontB} {
r := read(c.Token(n, "outsider"), "found-tool", toolRes, 1, map[string]any{"tool": "echo"})
Expect(r.Status).To(Equal(403), n+" outsider: "+r.String())
// The friend has a grant on the tool resource only.
r = read(c.Token(n, "friend"), "found-svc", svcRes, 1, map[string]any{})
Expect(r.Status).To(Equal(403), n+" friend on a foreign resource: "+r.String())
anon, err := cluster.Client{Base: c.Nodes[n].URL()}.Do("POST", "/api/addons/dispatch", `{"addonId":"found-tool","action":"read"}`, nil)
Expect(err).NotTo(HaveOccurred())
Expect(anon.Status).To(BeElementOf(401, 403), n)
}
})
It("takes the actor from the authenticated credential only and ignores forged actor and authority headers", func() {
for _, n := range []string{cluster.FrontA, cluster.FrontB, cluster.Owner} {
cl := c.Token(n, "member")
cl.Headers = forged
Expect(actorOf(read(cl, "found-tool", toolRes, 1, map[string]any{"tool": "echo"}))).To(Equal("fnd-member"), n)
// A forged administrator cannot reach lifecycle either.
r := lifecycle(cl, "found-tool", "disable", 1, "")
Expect(r.Status).To(Equal(403), n)
}
// A body that claims another actor is not part of the request type.
r := dispatch(c.Token(cluster.FrontA, "member"), map[string]any{"addonId": "found-tool", "resourceId": toolRes, "generation": 1, "action": "read", "actor": "fnd-admin", "arguments": map[string]any{"tool": "echo"}})
Expect(r.Status).To(Equal(400), r.String())
})
It("denies a tool the add-on did not declare and a generation that is not current on every node", func() {
for _, n := range []string{cluster.FrontA, cluster.FrontB, cluster.Owner} {
m := c.Token(n, "member")
r := read(m, "found-tool", toolRes, 1, map[string]any{"tool": "drop-everything"})
Expect(r.Status).To(Equal(403), n+": "+r.String())
r = read(m, "found-tool", toolRes, 7, map[string]any{"tool": "echo"})
Expect(r.Status).To(Equal(403), n+": "+r.String())
// Without a tool name only the generation of the running add-on
// can refuse the call.
r = read(m, "found-tool", toolRes, 7, map[string]any{"q": "plain"})
Expect(r.Status).To(Equal(403), n+" plain read of a stale generation: "+r.String())
}
})
It("relays JSON and a private file result and downloads the file through a different frontend than the one that produced it", func() {
m := c.Token(cluster.FrontA, "member")
r := read(m, "found-tool", toolRes, 1, map[string]any{"tool": "export"})
Expect(r.Status).To(Equal(200), r.String())
var out result
Expect(r.JSON(&out)).To(Succeed())
Expect(out.Outputs).To(HaveLen(1))
exportID = out.Outputs[0].Handle
Expect(exportID).NotTo(Equal("report"), "the host never hands out the add-on's own handle")
Expect(out.Outputs[0].Name).To(Equal("report.txt"))
viaB := c.Token(cluster.FrontB, "member")
// The file result is transferred in the background: the read returns
// the artifact as transferring, and a download answers 403 until it
// is complete. Wait on the documented status lookup, bounded, so a
// loaded machine cannot turn the transfer time into a failure.
Eventually(func(g Gomega) string {
st, err := viaB.Do("GET", "/api/addons/artifacts/"+exportID, nil, nil)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(st.Status).To(Equal(200), st.String())
var a struct {
State string `json:"state"`
}
g.Expect(st.JSON(&a)).To(Succeed())
return a.State
}, 30*time.Second, 100*time.Millisecond).Should(Equal("complete"))
dl, err := viaB.Do("GET", "/api/addons/artifacts/"+exportID+"/download", nil, nil)
Expect(err).NotTo(HaveOccurred())
Expect(dl.Status).To(Equal(200))
Expect(dl.String()).To(Equal("fixture\n"))
Expect(dl.Header.Get("Content-Disposition")).To(ContainSubstring("attachment"))
// Another account, even one with a grant on the resource, is denied
// the bytes of someone else's result.
other, err := c.Token(cluster.FrontB, "friend").Do("GET", "/api/addons/artifacts/"+exportID+"/download", nil, nil)
Expect(err).NotTo(HaveOccurred())
Expect(other.Status).To(Equal(403))
j, err := viaB.Do("POST", "/api/addons/relay", map[string]any{"kind": "json", "request": map[string]any{
"addonId": "found-tool", "resourceId": toolRes, "generation": 1, "operationId": nextID("json"), "action": "read", "arguments": map[string]any{"tool": "echo", "shape": "json"}}}, nil)
Expect(err).NotTo(HaveOccurred())
Expect(j.Status).To(Equal(200), j.String())
Expect(j.String()).To(ContainSubstring("fnd-member"))
})
It("relays lifecycle status and progress of an operation to the administrator who made it and to nobody else", func() {
r := lifecycle(admin, "found-tool", "enable", 1, "")
// The add-on is already enabled: an identical enable is an idempotent
// no-op or a conflict, but never a server error.
Expect(r.Status).To(BeNumerically("<", 500), r.String())
op := nextID("progress")
r = dispatch(admin, map[string]any{"operationId": op, "addonId": "found-svc", "action": "enable", "generation": 1})
Expect(r.Status).To(BeNumerically("<", 500), r.String())
for _, kind := range []string{"status", "progress"} {
rel, err := c.Token(cluster.FrontB, "admin").Do("POST", "/api/addons/relay", map[string]any{"kind": kind, "request": map[string]any{
"addonId": "found-svc", "generation": 1, "operationId": op, "action": "operation-status", "arguments": map[string]any{}}}, nil)
Expect(err).NotTo(HaveOccurred())
Expect(rel.Status).To(Equal(200), kind+": "+rel.String())
Expect(rel.String()).To(ContainSubstring("completed"), kind)
// Another account never sees the operation.
no, err := c.Token(cluster.FrontB, "member").Do("POST", "/api/addons/relay", map[string]any{"kind": kind, "request": map[string]any{
"addonId": "found-svc", "generation": 1, "operationId": op, "action": "operation-status", "arguments": map[string]any{}}}, nil)
Expect(err).NotTo(HaveOccurred())
Expect(no.Status).To(Equal(403), kind)
}
})
It("serves the immutable UI assets by package digest through a non-owner frontend and refuses everything else", func() {
a := appOf(c.Token(cluster.FrontA, "member"), "found-ui")
Expect(a).NotTo(BeNil())
Expect(a.UI).NotTo(BeNil())
for _, n := range front {
r, err := c.Token(n, "member").Do("GET", a.UI.Entry, nil, nil)
Expect(err).NotTo(HaveOccurred())
Expect(r.Status).To(Equal(200), n)
Expect(r.String()).To(ContainSubstring("createAddon"))
Expect(r.Header.Get("Content-Type")).To(ContainSubstring("javascript"))
Expect(r.Header.Get("Cache-Control")).To(ContainSubstring("immutable"))
// The same asset without a credential is refused.
anon, err := cluster.Client{Base: c.Nodes[n].URL()}.Do("GET", a.UI.Entry, nil, nil)
Expect(err).NotTo(HaveOccurred())
Expect(anon.Status).To(BeElementOf(401, 403))
out, err := c.Token(n, "outsider").Do("GET", a.UI.Entry, nil, nil)
Expect(err).NotTo(HaveOccurred())
Expect(out.Status).To(Equal(403))
}
base := strings.TrimSuffix(a.UI.Entry, "ui.js")
for _, bad := range []string{base + "private.txt", base + "../manifest.json", base + "bin/fixture", strings.Replace(base, a.UI.Entry[len("/api/addons/found-ui/assets/"):len("/api/addons/found-ui/assets/")+64], strings.Repeat("0", 64), 1) + "ui.js"} {
r, err := c.Token(cluster.FrontB, "member").Do("GET", bad, nil, nil)
Expect(err).NotTo(HaveOccurred())
Expect(r.Status).To(BeElementOf(400, 403, 404), bad)
Expect(r.String()).NotTo(ContainSubstring("not declared"))
}
// The tool package has no UI: there is nothing to serve.
r, err := c.Token(cluster.FrontB, "member").Do("GET", "/api/addons/found-tool/assets/"+strings.Repeat("a", 64)+"/ui.js", nil, nil)
Expect(err).NotTo(HaveOccurred())
Expect(r.Status).To(BeElementOf(403, 404))
})
It("applies one confirmed change exactly once through a different frontend than the one that proposed it", func() {
pid := nextID("write")
r, err := c.Token(cluster.FrontA, "member").Do("POST", "/api/addons/proposals", map[string]any{
"addonId": "found-tool", "resourceId": toolRes, "generation": 1, "operationId": pid, "action": "write", "arguments": map[string]any{"note": "one"}}, nil)
Expect(err).NotTo(HaveOccurred())
Expect(r.Status).To(Equal(200), r.String())
var p struct {
ID, Digest, State string
}
Expect(r.JSON(&p)).To(Succeed())
Expect(p.State).To(Equal("pending"))
// A bearer key can never confirm.
bad, err := c.Token(cluster.FrontB, "member").Do("POST", "/api/addons/proposals/"+pid+"/confirm", map[string]any{"digest": p.Digest}, map[string]string{"X-LocalAI-Confirm": pid})
Expect(err).NotTo(HaveOccurred())
Expect(bad.Status).To(Equal(403))
// Another account cannot confirm it either.
other, err := c.Session(cluster.FrontB, "friend").Do("POST", "/api/addons/proposals/"+pid+"/confirm", map[string]any{"digest": p.Digest}, map[string]string{"X-LocalAI-Confirm": pid})
Expect(err).NotTo(HaveOccurred())
Expect(other.Status).To(Equal(403))
Expect(lines(filepath.Join(c.Effects, "found-tool.log"))).To(BeZero())
confirm := func(node string) *cluster.Response {
resp, err := c.Session(node, "member").Do("POST", "/api/addons/proposals/"+pid+"/confirm", map[string]any{"digest": p.Digest}, map[string]string{"X-LocalAI-Confirm": pid})
Expect(err).NotTo(HaveOccurred())
return resp
}
first := confirm(cluster.FrontB)
Expect(first.Status).To(Equal(200), first.String())
Expect(first.String()).To(ContainSubstring(`"state":"applied"`))
again := confirm(cluster.FrontA)
Expect(again.Status).To(Equal(200), again.String())
Expect(again.String()).To(ContainSubstring(`"state":"applied"`))
Expect(lines(filepath.Join(c.Effects, "found-tool.log"))).To(Equal(1), "one committed effect")
Expect(lines(filepath.Join(c.Effects, "found-tool.log.applies"))).To(Equal(1), "one apply request reached the add-on")
})
It("reports a lost reply as outcome unknown with the original operation ID and settles it by receipt lookup without a second apply", func() {
pid := nextID("lost")
r, err := c.Token(cluster.FrontA, "member").Do("POST", "/api/addons/proposals", map[string]any{
"addonId": "found-svc", "resourceId": svcRes, "generation": 1, "operationId": pid, "action": "write", "arguments": map[string]any{"note": "lost"}}, nil)
Expect(err).NotTo(HaveOccurred())
Expect(r.Status).To(Equal(200), r.String())
var p struct{ Digest string }
Expect(r.JSON(&p)).To(Succeed())
confirm := func(node string) *cluster.Response {
resp, err := c.Session(node, "member").Do("POST", "/api/addons/proposals/"+pid+"/confirm", map[string]any{"digest": p.Digest}, map[string]string{"X-LocalAI-Confirm": pid})
Expect(err).NotTo(HaveOccurred())
return resp
}
// The owner and each frontend report the same original operation.
first := confirm(cluster.FrontB)
Expect(first.Status).To(Equal(503), first.String())
var e apiError
Expect(first.JSON(&e)).To(Succeed())
Expect(e.Error.Type).To(Equal("outcome_unknown"))
Expect(e.Error.OperationID).To(Equal(pid))
Expect(lines(filepath.Join(c.Effects, "found-svc.log"))).To(Equal(1), "the change did commit")
// Repeating the confirmation from every node never submits it again.
for _, n := range []string{cluster.FrontA, cluster.Owner, cluster.FrontB} {
rep := confirm(n)
if rep.Status == 503 {
Expect(errorType(rep)).To(Equal("outcome_unknown"), n)
var again apiError
Expect(rep.JSON(&again)).To(Succeed())
Expect(again.Error.OperationID).To(Equal(pid), n)
}
}
Expect(lines(filepath.Join(c.Effects, "found-svc.log.applies"))).To(Equal(1), "no second apply may reach the add-on")
// A status lookup is read-only and settles by receipt.
rec, err := c.Session(cluster.FrontA, "member").Do("POST", "/api/addons/proposals/"+pid+"/reconcile", nil, nil)
Expect(err).NotTo(HaveOccurred())
Expect(rec.Status).To(Equal(200), rec.String())
Expect(rec.String()).To(ContainSubstring(`"state":"applied"`))
Expect(lines(filepath.Join(c.Effects, "found-svc.log"))).To(Equal(1))
Expect(lines(filepath.Join(c.Effects, "found-svc.log.applies"))).To(Equal(1))
})
It("upgrades to a new generation, denies the previous generation and its assets, and keeps the retained data", func() {
marker := retainData(c, "found-ui")
before := appOf(admin, "found-ui")
Expect(before.Generation).To(Equal(uint64(1)))
r := lifecycle(admin, "found-ui", "upgrade", 0, "2.0.0")
Expect(r.Status).To(Equal(200), r.String())
settle("the upgraded add-on running", 30*time.Second, func() bool {
a := appOf(admin, "found-ui")
return a != nil && a.Generation == 2 && a.State == "running"
})
expectRetained(marker)
for _, n := range []string{cluster.FrontA, cluster.FrontB, cluster.Owner} {
stale := read(c.Token(n, "member"), "found-ui", uiRes, 1, map[string]any{"q": "plain"})
Expect(stale.Status).To(Equal(403), n+" stale generation: "+stale.String())
Expect(actorOf(read(c.Token(n, "member"), "found-ui", uiRes, 2, map[string]any{"tool": "echo"}))).To(Equal("fnd-member"), n)
}
old, err := c.Token(cluster.FrontB, "member").Do("GET", before.UI.Entry, nil, nil)
Expect(err).NotTo(HaveOccurred())
Expect(old.Status).To(BeElementOf(403, 404), "the previous package digest is no longer served")
now := appOf(c.Token(cluster.FrontB, "member"), "found-ui")
Expect(now.UI.Entry).NotTo(Equal(before.UI.Entry))
cur, err := c.Token(cluster.FrontB, "member").Do("GET", now.UI.Entry, nil, nil)
Expect(err).NotTo(HaveOccurred())
Expect(cur.Status).To(Equal(200))
})
It("disables, removes and reinstalls an add-on while its data and grants remain", func() {
marker := retainData(c, "found-tool")
Expect(lifecycle(admin, "found-tool", "disable", 1, "").Status).To(Equal(200))
for _, n := range []string{cluster.FrontA, cluster.FrontB, cluster.Owner} {
r := read(c.Token(n, "member"), "found-tool", toolRes, 1, map[string]any{"tool": "echo"})
Expect(r.Status).To(BeElementOf(403, 503), n+" disabled: "+r.String())
}
Expect(fixtureChildren(c.Nodes[cluster.Owner].PID())).To(Equal(2))
Expect(lifecycle(admin, "found-tool", "uninstall", 0, "").Status).To(Equal(200))
expectRetained(marker)
Expect(lifecycle(admin, "found-tool", "install", 0, "1.0.0").Status).To(Equal(200))
cur := appOf(admin, "found-tool")
Expect(cur).NotTo(BeNil())
Expect(lifecycle(admin, "found-tool", "enable", cur.Generation, "").Status).To(Equal(200))
settle("the reinstalled add-on running", 30*time.Second, func() bool {
a := appOf(admin, "found-tool")
return a != nil && a.State == "running"
})
expectRetained(marker)
g := appOf(admin, "found-tool").Generation
// The grants of the resource outlived the removal: the friend reads it.
Expect(actorOf(read(c.Token(cluster.FrontB, "friend"), "found-tool", toolRes, g, map[string]any{"tool": "echo"}))).To(Equal("fnd-friend"))
})
It("never changes the host binary or the shipped UI build over the whole lifecycle", func() {
Expect(hashTree(hostBin)).To(Equal(binSum))
if hostUI != "" {
Expect(hashTree(hostUI)).To(Equal(uiSum))
}
})
})
@@ -0,0 +1,129 @@
// SPDX-License-Identifier: MIT
package foundation_test
import (
"strings"
"time"
"github.com/mudler/LocalAI/tests/addons/foundation/cluster"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// A new generation or an enabled add-on whose process cannot start, and an
// owner that dies while one is starting, must leave the last good code, the
// retained data and a usable lifecycle.
var _ = Describe("failed starts and interrupted lifecycle", func() {
It("keeps the last good generation running and its data when an upgrade fails to start", func() {
c, res := serving([]cluster.Package{
cluster.ToolPackage("1.0.0", nil),
cluster.ToolPackage("2.0.0", map[string]any{"mode": "exit-start"}),
cluster.ToolPackage("3.0.0", nil),
}, map[string]string{"found-tool": "1.0.0"})
admin := c.Token(cluster.FrontB, "admin")
marker := retainData(c, "found-tool")
r := lifecycle(admin, "found-tool", "upgrade", 0, "2.0.0")
Expect(r.Status).NotTo(Equal(200), "an upgrade whose process cannot start must not report success: "+r.String())
a := appOf(admin, "found-tool")
Expect(a).NotTo(BeNil())
Expect(a.State).To(Equal("running"))
Expect(a.Generation).To(Equal(uint64(1)))
Expect(fixtureChildren(c.Nodes[cluster.Owner].PID())).To(Equal(1))
Expect(actorOf(read(c.Token(cluster.FrontA, "member"), "found-tool", res["found-tool"], 1, map[string]any{"tool": "echo"}))).To(Equal("fnd-member"))
expectRetained(marker)
// The failure left nothing behind that blocks a later good upgrade.
r = lifecycle(admin, "found-tool", "upgrade", 0, "3.0.0")
Expect(r.Status).To(Equal(200), r.String())
settle("the third release running", 30*time.Second, func() bool {
cur := appOf(admin, "found-tool")
return cur != nil && cur.State == "running" && cur.Generation > 1
})
expectRetained(marker)
})
It("keeps the last good code and data when the owner is killed in the middle of an upgrade and recovers on restart", func() {
c, res := serving([]cluster.Package{
cluster.ToolPackage("1.0.0", nil),
cluster.ToolPackage("2.0.0", map[string]any{"mode": "never-ready"}),
cluster.ToolPackage("3.0.0", nil),
}, map[string]string{"found-tool": "1.0.0"})
admin := c.Token(cluster.FrontA, "admin")
marker := retainData(c, "found-tool")
owner := c.Nodes[cluster.Owner]
ownerPID := owner.PID()
done := make(chan struct{})
go func() {
defer GinkgoRecover()
defer close(done)
// The connection dies with the owner; the result is not the point.
_, _ = admin.Do("POST", "/api/addons/dispatch", map[string]any{"operationId": nextID("mid"), "addonId": "found-tool", "action": "upgrade", "generation": 0, "arguments": map[string]any{"sourceId": "fixture", "version": "2.0.0"}}, nil)
}()
var lastGood int
for pid := range cluster.ChildrenOf(ownerPID) {
lastGood = pid
}
// The last good process is replaced by the candidate, which never
// becomes ready: the owner is killed while that start is in flight.
settle("the candidate generation being started", 30*time.Second, func() bool {
for pid, cmdline := range cluster.ChildrenOf(ownerPID) {
if pid != lastGood && strings.Contains(cmdline, "bin/fixture") {
return true
}
}
return false
})
owner.Kill()
Eventually(done, 30*time.Second).Should(BeClosed())
settle("every add-on process ending", 20*time.Second, func() bool { return fixtureChildren(ownerPID) == 0 })
restartOwner(c)
settle("the last good generation running", 60*time.Second, func() bool {
a := appOf(c.Token(cluster.FrontB, "admin"), "found-tool")
return a != nil && a.State == "running"
})
a := appOf(c.Token(cluster.FrontB, "admin"), "found-tool")
Expect(a.Generation).To(Equal(uint64(1)), "the interrupted upgrade must not have replaced the last good code")
Expect(a.Version).To(Equal("1.0.0"))
Expect(actorOf(read(c.Token(cluster.FrontB, "member"), "found-tool", res["found-tool"], 1, map[string]any{"tool": "echo"}))).To(Equal("fnd-member"))
expectRetained(marker)
Expect(fixtureChildren(c.Nodes[cluster.Owner].PID())).To(Equal(1))
// A later good upgrade is not blocked by the interrupted one.
r := lifecycle(c.Token(cluster.FrontB, "admin"), "found-tool", "upgrade", 0, "3.0.0")
Expect(r.Status).To(Equal(200), r.String())
expectRetained(marker)
})
It("keeps retained data and a consistent lifecycle when the owner is killed in the middle of an enable", func() {
c := start()
Expect(c.Library.Add(cluster.ServicePackage("1.0.0", map[string]any{"mode": "never-ready"}), c.Effects)).To(Succeed())
Expect(c.Up()).To(Succeed())
admin := c.Token(cluster.FrontA, "admin")
Expect(lifecycle(admin, "found-svc", "install", 0, "1.0.0").Status).To(Equal(200))
marker := retainData(c, "found-svc")
owner := c.Nodes[cluster.Owner]
ownerPID := owner.PID()
done := make(chan struct{})
go func() {
defer GinkgoRecover()
defer close(done)
_, _ = admin.Do("POST", "/api/addons/dispatch", map[string]any{"operationId": nextID("enable"), "addonId": "found-svc", "action": "enable", "generation": 1, "arguments": map[string]any{}}, nil)
}()
settle("the process being started", 30*time.Second, func() bool { return fixtureChildren(ownerPID) == 1 })
owner.Kill()
Eventually(done, 30*time.Second).Should(BeClosed())
settle("the process ending", 20*time.Second, func() bool { return fixtureChildren(ownerPID) == 0 })
restartOwner(c)
a := appOf(c.Token(cluster.FrontB, "admin"), "found-svc")
Expect(a).NotTo(BeNil(), "the installed add-on is not lost")
Expect(a.State).NotTo(Equal("running"), "a process that never became ready is never reported as running")
expectRetained(marker)
// The lifecycle is not wedged: the add-on can be disabled and removed.
for _, action := range []string{"disable", "uninstall"} {
r := lifecycle(c.Token(cluster.FrontB, "admin"), "found-svc", action, a.Generation, "")
Expect(r.Status).To(Equal(200), action+": "+r.String())
}
expectRetained(marker)
Expect(fixtureChildren(c.Nodes[cluster.Owner].PID())).To(BeZero())
})
})
+186
View File
@@ -0,0 +1,186 @@
// SPDX-License-Identifier: MIT
package foundation_test
import (
"os"
"path/filepath"
"time"
"github.com/mudler/LocalAI/tests/addons/foundation/cluster"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// serving brings up a cluster with the given packages published, the named
// add-ons installed and enabled, and one resource of each registered.
func serving(pkgs []cluster.Package, install map[string]string) (*cluster.Cluster, map[string]string) {
GinkgoHelper()
c := start()
for _, p := range pkgs {
Expect(c.Library.Add(p, c.Effects)).To(Succeed())
}
Expect(c.Up()).To(Succeed())
admin := c.Token(cluster.FrontA, "admin")
resources := map[string]string{}
for id, version := range install {
Expect(lifecycle(admin, id, "install", 0, version).Status).To(Equal(200))
Expect(lifecycle(admin, id, "enable", 1, "").Status).To(Equal(200))
resources[id] = resource(c, id, "member", map[string]string{"friend": "viewer"})
}
settle("every add-on running", 30*time.Second, func() bool {
_, list := apps(admin)
n := 0
for _, a := range list {
if a.State == "running" {
n++
}
}
return n == len(install)
})
return c, resources
}
func restartOwner(c *cluster.Cluster) {
GinkgoHelper()
Expect(c.Nodes[cluster.Owner].Start()).To(Succeed())
Expect(c.Nodes[cluster.Owner].Ready(120 * time.Second)).To(Succeed())
}
var _ = Describe("owner failure and recovery", func() {
It("fails closed on every frontend while the owner is down, executes nothing locally and recovers the last good package and data on restart", func() {
c, res := serving([]cluster.Package{cluster.ToolPackage("1.0.0", nil), cluster.UIPackage("1.0.0", nil)}, map[string]string{"found-tool": "1.0.0", "found-ui": "1.0.0"})
marker := retainData(c, "found-tool")
pkgDir := filepath.Join(c.Config.Nodes[cluster.Owner].Root, "packages")
packageSum := hashTree(pkgDir)
before := lines(filepath.Join(c.Effects, "found-tool.log"))
ui := appOf(c.Token(cluster.FrontA, "member"), "found-ui")
owner := c.Nodes[cluster.Owner]
ownerPID := owner.PID()
owner.Kill()
settle("the add-on processes ending with the owner", 20*time.Second, func() bool { return fixtureChildren(ownerPID) == 0 })
var bodies []string
for _, n := range []string{cluster.FrontA, cluster.FrontB} {
cl := c.Token(n, "member")
r := read(cl, "found-tool", res["found-tool"], 1, map[string]any{"tool": "echo"})
Expect(r.Status).To(Equal(503), n+": "+r.String())
Expect(errorType(r)).To(Equal("addon_error"))
bodies = append(bodies, r.String())
status, _ := apps(cl)
Expect(status).To(Equal(503), n+" apps")
asset, err := cl.Do("GET", ui.UI.Entry, nil, nil)
Expect(err).NotTo(HaveOccurred())
Expect(asset.Status).To(Equal(503), n+" asset")
life := lifecycle(c.Token(n, "admin"), "found-tool", "disable", 1, "")
Expect(life.Status).To(Equal(503), n+" lifecycle: "+life.String())
prop, err := cl.Do("POST", "/api/addons/proposals", map[string]any{"addonId": "found-tool", "resourceId": res["found-tool"], "generation": 1, "operationId": nextID("down"), "action": "write", "arguments": map[string]any{}}, nil)
Expect(err).NotTo(HaveOccurred())
Expect(prop.Status).To(Equal(503), n+" propose: "+prop.String())
Expect(fixtureChildren(c.Nodes[n].PID())).To(BeZero(), n+" must execute nothing locally")
Expect(c.Config.Nodes[n].Root).NotTo(BeADirectory(), n+" must not create add-on storage")
}
Expect(bodies[1]).To(Equal(bodies[0]), "the same typed error from every frontend")
Expect(fixtureChildren(ownerPID)).To(BeZero())
restartOwner(c)
settle("the add-ons running again", 30*time.Second, func() bool {
a := appOf(c.Token(cluster.FrontB, "admin"), "found-tool")
b := appOf(c.Token(cluster.FrontB, "admin"), "found-ui")
return a != nil && b != nil && a.State == "running" && b.State == "running"
})
a := appOf(c.Token(cluster.FrontA, "admin"), "found-tool")
Expect(a.Generation).To(Equal(uint64(1)), "the last good generation is the one that runs")
for _, n := range []string{cluster.FrontA, cluster.FrontB} {
Expect(actorOf(read(c.Token(n, "friend"), "found-tool", res["found-tool"], 1, map[string]any{"tool": "echo"}))).To(Equal("fnd-friend"))
}
Expect(fixtureChildren(c.Nodes[cluster.Owner].PID())).To(Equal(2), "exactly one process per add-on")
Expect(hashTree(pkgDir)).To(Equal(packageSum), "package code is unchanged")
expectRetained(marker)
Expect(lines(filepath.Join(c.Effects, "found-tool.log"))).To(Equal(before))
})
It("refuses a second owner on the same storage and keeps one process per add-on", func() {
c, res := serving([]cluster.Package{cluster.ToolPackage("1.0.0", nil)}, map[string]string{"found-tool": "1.0.0"})
owner := c.Nodes[cluster.Owner]
Expect(fixtureChildren(owner.PID())).To(Equal(1))
dup, err := c.Config.Derive("dup")
Expect(err).NotTo(HaveOccurred())
second := &cluster.Node{Name: cluster.Owner, Config: dup, ServerPath: paths.Server, MockPath: paths.Mock}
Expect(second.Start()).To(Succeed())
DeferCleanup(second.Stop)
Expect(second.Wait(60*time.Second)).To(BeTrue(), "a second owner on the same storage must not keep running: "+second.Tail(8))
Expect(fixtureChildren(second.PID())).To(BeZero())
// The first owner keeps its single process and keeps serving.
Expect(fixtureChildren(owner.PID())).To(Equal(1))
Expect(actorOf(read(c.Token(cluster.FrontB, "member"), "found-tool", res["found-tool"], 1, map[string]any{"tool": "echo"}))).To(Equal("fnd-member"))
})
It("stops an add-on process orphaned by an owner crash before it starts the next one", func() {
c, res := serving([]cluster.Package{cluster.ToolPackage("1.0.0", map[string]any{"mode": "orphan"})}, map[string]string{"found-tool": "1.0.0"})
owner := c.Nodes[cluster.Owner]
var orphan int
for pid := range cluster.ChildrenOf(owner.PID()) {
orphan = pid
}
Expect(orphan).NotTo(BeZero())
owner.Kill()
// An add-on that ignores the closed startup channel outlives its owner.
Consistently(func() bool { return cluster.Alive(orphan) }, 1500*time.Millisecond, 100*time.Millisecond).Should(BeTrue(), "the orphan mode must really orphan the process")
restartOwner(c)
settle("the orphan being stopped", 20*time.Second, func() bool { return !cluster.Alive(orphan) })
settle("the add-on serving again", 30*time.Second, func() bool {
a := appOf(c.Token(cluster.FrontA, "admin"), "found-tool")
return a != nil && a.State == "running"
})
children := cluster.ChildrenOf(c.Nodes[cluster.Owner].PID())
Expect(children).To(HaveLen(1))
for pid := range children {
Expect(pid).NotTo(Equal(orphan))
}
Expect(actorOf(read(c.Token(cluster.FrontA, "member"), "found-tool", res["found-tool"], 1, map[string]any{"tool": "echo"}))).To(Equal("fnd-member"))
})
It("leaves no process behind when a cluster with an orphaned add-on is taken down", func() {
c, _ := serving([]cluster.Package{cluster.ToolPackage("1.0.0", map[string]any{"mode": "orphan"})}, map[string]string{"found-tool": "1.0.0"})
owner := c.Nodes[cluster.Owner]
first := owner.PID()
var orphan int
for pid := range cluster.ChildrenOf(first) {
orphan = pid
}
Expect(orphan).NotTo(BeZero())
owner.Kill()
restartOwner(c)
var current int
for pid := range cluster.ChildrenOf(owner.PID()) {
current = pid
}
Expect(current).NotTo(BeZero())
// The teardown a failing spec reaches: every process must be gone.
c.Down()
for _, pid := range []int{first, owner.PID(), orphan, current, c.Nodes[cluster.FrontA].PID(), c.Nodes[cluster.FrontB].PID()} {
Expect(cluster.Alive(pid)).To(BeFalse(), "process %d survived Down", pid)
}
c.Down()
})
It("keeps the files of the package of an installed add-on read only", func() {
c, _ := serving([]cluster.Package{cluster.ToolPackage("1.0.0", nil)}, map[string]string{"found-tool": "1.0.0"})
root := filepath.Join(c.Config.Nodes[cluster.Owner].Root, "packages")
var files int
Expect(filepath.WalkDir(root, func(p string, d os.DirEntry, e error) error {
if e != nil || d.IsDir() {
return e
}
info, err := d.Info()
Expect(err).NotTo(HaveOccurred())
files++
Expect(info.Mode().Perm()&0o222).To(BeZero(), p+" must not be writable")
return nil
})).To(Succeed())
Expect(files).To(BeNumerically(">", 0))
})
})
+279
View File
@@ -0,0 +1,279 @@
// SPDX-License-Identifier: MIT
package foundation_test
import (
"context"
"errors"
"os"
"path/filepath"
goruntime "runtime"
"syscall"
"time"
"github.com/mudler/LocalAI/core/services/addons"
"github.com/mudler/LocalAI/core/services/addons/catalog"
"github.com/mudler/LocalAI/core/services/addons/packages"
addonruntime "github.com/mudler/LocalAI/core/services/addons/runtime"
"github.com/mudler/LocalAI/pkg/addons/contract"
"github.com/mudler/LocalAI/tests/addons/foundation/cluster"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// owner is the lifecycle service of an owner, built the way the application
// builds it (manager, installer, supervisor) over the real fixture process.
type owner struct {
m *addons.Manager
s *addonruntime.Supervisor
actor contract.Actor
root string
}
// openOwner opens or reopens the owner on root.
func openOwner(root string, lib *cluster.Library, ready time.Duration) (*owner, error) {
ctx := context.Background()
actor := contract.Actor{UserID: "admin", CredentialID: "key:admin", Mode: "named-user"}
admin := func(_ context.Context, a contract.Actor) error {
if a != actor {
return errors.New("denied")
}
return nil
}
sources, err := catalog.NewSources(filepath.Join(root, "sources.json"), admin, catalog.CredentialResolver{})
if err != nil {
return nil, err
}
if err := sources.Save(ctx, actor, catalog.Source{ID: "fixture", Name: "Fixture", URL: lib.URL(), Enabled: true, AllowLoopbackHTTP: true}); err != nil {
return nil, err
}
if _, err := sources.Refresh(ctx, "fixture"); err != nil {
return nil, err
}
cat := catalog.Catalog{Sources: sources, TargetOS: goruntime.GOOS, TargetArch: goruntime.GOARCH}
host := contract.HostCompatibility{Host: 1, API: 1, Protocol: 1, SDK: 1, DataFormat: 1, OS: goruntime.GOOS, Arch: goruntime.GOARCH}
installer := &packages.Installer{Root: filepath.Join(root, "packages"), Catalog: cat, Host: host}
m, err := addons.NewManager(ctx, addons.OwnerConfig{InstanceID: "owner", OwnerID: "owner", OwnerAddress: "https://owner.example", StorageID: "disk", Root: root}, cat, installer, admin)
if err != nil {
return nil, err
}
if err := m.Reconcile(ctx); err != nil {
_ = m.Close()
return nil, err
}
limits := contract.DefaultLimits()
s, err := addonruntime.NewSupervisor(addonruntime.Config{StateRoot: filepath.Join(root, "runtime"), Limits: limits, ReadyTimeout: ready, StableHealth: limits.BackoffMax})
if err != nil {
_ = m.Close()
return nil, err
}
if err := m.AttachRuntime(ctx, s); err != nil {
_ = s.Close(context.Background())
_ = m.Close()
return nil, err
}
return &owner{m: m, s: s, actor: actor, root: root}, nil
}
func (o *owner) submit(id, action string, generation uint64, version string) (addons.OperationStatus, error) {
return o.m.Submit(context.Background(), o.actor, addons.LifecycleCommand{OperationID: nextID("svc"), AddonID: id, Action: action, SourceID: "fixture", Version: version, Generation: generation})
}
func (o *owner) ready(id string, generation uint64) bool {
regs, err := o.s.Registry.Snapshot(context.Background())
Expect(err).NotTo(HaveOccurred())
for _, r := range regs {
if r.AddonID == id && r.Generation == generation {
return true
}
}
return false
}
func makeOwner(lib *cluster.Library, ready time.Duration) (*owner, string) {
GinkgoHelper()
root, err := os.MkdirTemp("", "foundation-owner-*")
Expect(err).NotTo(HaveOccurred())
DeferCleanup(func() {
_ = filepath.Walk(root, func(p string, i os.FileInfo, e error) error {
if e == nil && i.IsDir() {
_ = os.Chmod(p, 0o700)
}
return nil
})
_ = os.RemoveAll(root)
})
o, err := openOwner(root, lib, ready)
Expect(err).NotTo(HaveOccurred())
DeferCleanup(func() { _ = o.s.Close(context.Background()); _ = o.m.Close() })
return o, root
}
var _ = Describe("lifecycle service over the real fixture process", func() {
var lib *cluster.Library
var effects string
BeforeEach(func() {
var err error
lib, err = cluster.NewLibrary(paths.Fixture)
Expect(err).NotTo(HaveOccurred())
DeferCleanup(lib.Close)
effects = GinkgoT().TempDir()
})
It("keeps the last good generation running and the retained data when the new generation cannot start", func() {
Expect(lib.Add(cluster.ToolPackage("1.0.0", nil), effects)).To(Succeed())
Expect(lib.Add(cluster.ToolPackage("2.0.0", map[string]any{"mode": "exit-start"}), effects)).To(Succeed())
Expect(lib.Add(cluster.ToolPackage("3.0.0", nil), effects)).To(Succeed())
o, _ := makeOwner(lib, 5*time.Second)
ctx := context.Background()
_, err := o.submit("found-tool", "install", 0, "1.0.0")
Expect(err).NotTo(HaveOccurred())
_, err = o.submit("found-tool", "enable", 1, "")
Expect(err).NotTo(HaveOccurred())
Expect(o.ready("found-tool", 1)).To(BeTrue())
before, err := o.m.Status(ctx, "found-tool")
Expect(err).NotTo(HaveOccurred())
Expect(os.MkdirAll(before.DataDirectory, 0o700)).To(Succeed())
marker := filepath.Join(before.DataDirectory, "rows.db")
Expect(os.WriteFile(marker, []byte("retained rows"), 0o600)).To(Succeed())
firstPackage := hashTree(before.Generations[0].Receipt.Directory)
status, err := o.submit("found-tool", "upgrade", 0, "2.0.0")
Expect(err).To(HaveOccurred(), "an upgrade that cannot start is a failed operation: %+v", status)
after, err := o.m.Status(ctx, "found-tool")
Expect(err).NotTo(HaveOccurred())
Expect(after.Generation).To(Equal(uint64(1)), "the last good generation stays the installed one")
Expect(after.ObservedHealth).To(Equal("ready"), "and it stays running")
Expect(o.ready("found-tool", 1)).To(BeTrue())
Expect(hashTree(before.Generations[0].Receipt.Directory)).To(Equal(firstPackage), "its code is untouched")
b, err := os.ReadFile(marker)
Expect(err).NotTo(HaveOccurred())
Expect(string(b)).To(Equal("retained rows"))
// The failed attempt leaves nothing that blocks a good upgrade.
_, err = o.submit("found-tool", "upgrade", 0, "3.0.0")
Expect(err).NotTo(HaveOccurred())
Expect(o.ready("found-tool", 2)).To(BeTrue(), "the good release is the next generation")
})
It("restores the last good generation and resumes the same operation when the request is canceled during an upgrade start", func() {
Expect(lib.Add(cluster.ToolPackage("1.0.0", nil), effects)).To(Succeed())
Expect(lib.Add(cluster.ToolPackage("2.0.0", map[string]any{"mode": "never-ready"}), effects)).To(Succeed())
Expect(lib.Add(cluster.ToolPackage("3.0.0", nil), effects)).To(Succeed())
o, root := makeOwner(lib, 5*time.Second)
bg := context.Background()
_, err := o.submit("found-tool", "install", 0, "1.0.0")
Expect(err).NotTo(HaveOccurred())
_, err = o.submit("found-tool", "enable", 1, "")
Expect(err).NotTo(HaveOccurred())
first, err := o.m.Runtime(bg, "found-tool")
Expect(err).NotTo(HaveOccurred())
command := addons.LifecycleCommand{OperationID: nextID("cancel"), AddonID: "found-tool", Action: "upgrade", SourceID: "fixture", Version: "2.0.0"}
timeout, cancel := context.WithTimeout(bg, 800*time.Millisecond)
defer cancel()
started := time.Now()
status, err := o.m.Submit(timeout, o.actor, command)
Expect(err).To(HaveOccurred())
Expect(status.Phase).NotTo(Equal("failed"), "a canceled request is not a terminal failure of the operation")
Expect(time.Since(started)).To(BeNumerically("<", 10*time.Second), "the restore is bounded")
// The last good generation runs again, as a new process, and the
// canceled candidate and the first process do not overlap with it.
current, err := o.m.Runtime(bg, "found-tool")
Expect(err).NotTo(HaveOccurred(), "the last good generation is back without another command")
Expect(current.Generation).To(Equal(uint64(1)))
Expect(current.PID).NotTo(Equal(first.PID))
Expect(syscall.Kill(first.PID, 0)).NotTo(Succeed(), "the stopped process is gone")
Expect(o.ready("found-tool", 1)).To(BeTrue())
regs, err := o.s.Registry.Snapshot(bg)
Expect(err).NotTo(HaveOccurred())
Expect(regs).To(HaveLen(1))
// The same operation ID is retried and settles on the same outcome
// as the first attempt would have without the cancellation: the
// candidate never becomes ready, so it is a terminal failure with
// the last good generation kept.
_, err = o.m.Submit(bg, o.actor, command)
Expect(err).To(MatchError(addons.ErrOperationFailed))
_, err = o.m.Runtime(bg, "found-tool")
Expect(err).NotTo(HaveOccurred())
again, err := o.m.Submit(bg, o.actor, command)
Expect(err).To(MatchError(addons.ErrOperationFailed))
Expect(again.Phase).To(Equal("failed"))
Expect(o.ready("found-tool", 1)).To(BeTrue())
// The failure leaves nothing behind that blocks a good upgrade.
_, err = o.submit("found-tool", "upgrade", 0, "3.0.0")
Expect(err).NotTo(HaveOccurred())
Expect(o.ready("found-tool", 2)).To(BeTrue())
_ = root
})
It("converges on the last good generation after a crash that follows the rollback start and precedes any journal write", func() {
Expect(lib.Add(cluster.ToolPackage("1.0.0", nil), effects)).To(Succeed())
Expect(lib.Add(cluster.ToolPackage("2.0.0", map[string]any{"mode": "never-ready"}), effects)).To(Succeed())
o, root := makeOwner(lib, 5*time.Second)
bg := context.Background()
_, err := o.submit("found-tool", "install", 0, "1.0.0")
Expect(err).NotTo(HaveOccurred())
_, err = o.submit("found-tool", "enable", 1, "")
Expect(err).NotTo(HaveOccurred())
command := addons.LifecycleCommand{OperationID: nextID("crash"), AddonID: "found-tool", Action: "upgrade", SourceID: "fixture", Version: "2.0.0"}
timeout, cancel := context.WithTimeout(bg, 800*time.Millisecond)
defer cancel()
_, err = o.m.Submit(timeout, o.actor, command)
Expect(err).To(HaveOccurred())
Expect(o.ready("found-tool", 1)).To(BeTrue())
// The owner dies here: the journal still says generation 1 and the
// operation never reached prepared.
Expect(o.s.Close(bg)).To(Succeed())
Expect(o.m.Close()).To(Succeed())
again, err := openOwner(root, lib, 5*time.Second)
Expect(err).NotTo(HaveOccurred())
DeferCleanup(func() { _ = again.s.Close(bg); _ = again.m.Close() })
status, err := again.m.Status(bg, "found-tool")
Expect(err).NotTo(HaveOccurred())
Expect(status.Generation).To(Equal(uint64(1)))
Expect(again.ready("found-tool", 1)).To(BeTrue())
regs, err := again.s.Registry.Snapshot(bg)
Expect(err).NotTo(HaveOccurred())
Expect(regs).To(HaveLen(1))
})
It("starts the owner when an enabled add-on cannot become ready and fails only that add-on", func() {
Expect(lib.Add(cluster.ToolPackage("1.0.0", nil), effects)).To(Succeed())
Expect(lib.Add(cluster.ServicePackage("1.0.0", map[string]any{"mode": "exit-start"}), effects)).To(Succeed())
o, root := makeOwner(lib, 5*time.Second)
for _, id := range []string{"found-tool", "found-svc"} {
_, err := o.submit(id, "install", 0, "1.0.0")
Expect(err).NotTo(HaveOccurred())
}
_, err := o.submit("found-tool", "enable", 1, "")
Expect(err).NotTo(HaveOccurred())
// The broken add-on is enabled, and its own command says it did not start.
_, err = o.submit("found-svc", "enable", 1, "")
Expect(err).To(HaveOccurred())
status, err := o.m.Status(context.Background(), "found-svc")
Expect(err).NotTo(HaveOccurred())
marker := filepath.Join(status.DataDirectory, "rows.db")
Expect(os.MkdirAll(status.DataDirectory, 0o700)).To(Succeed())
Expect(os.WriteFile(marker, []byte("retained rows"), 0o600)).To(Succeed())
Expect(o.s.Close(context.Background())).To(Succeed())
Expect(o.m.Close()).To(Succeed())
// A restart must still bring the owner up, with the healthy add-on
// running and the broken one stopped.
again, err := openOwner(root, lib, 5*time.Second)
Expect(err).NotTo(HaveOccurred(), "one add-on that cannot start must not stop the owner")
DeferCleanup(func() { _ = again.s.Close(context.Background()); _ = again.m.Close() })
Expect(again.ready("found-tool", 1)).To(BeTrue())
Expect(again.ready("found-svc", 1)).To(BeFalse())
// A command on the healthy add-on is not failed by the broken one.
_, err = again.submit("found-tool", "disable", 1, "")
Expect(err).NotTo(HaveOccurred())
_, err = again.submit("found-svc", "disable", 1, "")
Expect(err).NotTo(HaveOccurred())
b, err := os.ReadFile(marker)
Expect(err).NotTo(HaveOccurred())
Expect(string(b)).To(Equal("retained rows"))
})
})
@@ -0,0 +1,102 @@
// SPDX-License-Identifier: MIT
package foundation_test
import (
"github.com/mudler/LocalAI/tests/addons/foundation/cluster"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// Two separate server processes, each with its own listener and carrier, share
// one account store and forward every add-on request to the one owner.
var _ = Describe("two non-owner frontends with real sessions", Ordered, func() {
var (
c *cluster.Cluster
res string
)
nodes := []string{cluster.Owner, cluster.FrontA, cluster.FrontB}
BeforeAll(func() {
c, _ = serving([]cluster.Package{cluster.ToolPackage("1.0.0", nil)}, map[string]string{"found-tool": "1.0.0"})
res = resource(c, "found-tool", "member", nil)
})
It("gives an ordinary account the apps list and no management on any node", func() {
for _, n := range nodes {
member := c.Session(n, "member")
status, list := apps(member)
Expect(status).To(Equal(200), n)
Expect(list).To(HaveLen(1))
raw, err := member.Do("GET", "/api/addons/apps", nil, nil)
Expect(err).NotTo(HaveOccurred())
Expect(raw.String()).NotTo(ContainSubstring(`"admin"`), n+": the apps list carries no controls for a member")
for _, action := range []string{"install", "upgrade", "enable", "disable", "uninstall", "catalog-sources", "addons-installed"} {
r := dispatch(member, map[string]any{"addonId": "found-tool", "action": action, "generation": 1, "arguments": map[string]any{}})
Expect(r.Status).To(Equal(403), n+" "+action+": "+r.String())
}
for _, verb := range []string{"start", "stop"} {
r, err := member.Do("POST", "/api/addons/apps/found-tool/"+verb, map[string]any{"operationId": nextID(verb), "generation": 1}, nil)
Expect(err).NotTo(HaveOccurred())
Expect(r.Status).To(Equal(403), n+" "+verb)
}
}
// The administrator sees the controls on every node.
for _, n := range nodes {
raw, err := c.Session(n, "admin").Do("GET", "/api/addons/apps", nil, nil)
Expect(err).NotTo(HaveOccurred())
Expect(raw.String()).To(ContainSubstring(`"admin"`), n)
}
})
It("returns the same typed denial from the owner and from each frontend", func() {
var bodies []string
for _, n := range nodes {
r := read(c.Token(n, "friend"), "found-tool", res, 1, map[string]any{"tool": "echo"})
Expect(r.Status).To(Equal(403))
bodies = append(bodies, r.String())
}
Expect(bodies[1]).To(Equal(bodies[0]))
Expect(bodies[2]).To(Equal(bodies[0]))
var unknown []string
for _, n := range nodes {
r := read(c.Token(n, "member"), "found-tool", "r-not-a-resource", 1, map[string]any{"tool": "echo"})
Expect(r.Status).To(Equal(403))
unknown = append(unknown, r.String())
}
Expect(unknown[1]).To(Equal(unknown[0]))
Expect(unknown[2]).To(Equal(unknown[0]))
Expect(unknown[0]).To(Equal(bodies[0]), "a missing resource and a forbidden one look the same")
})
It("refuses a session on every node once it was ended through one frontend", func() {
// A dedicated session: the shared fixture sessions stay valid.
victim := c.Session(cluster.FrontA, "friend")
for _, n := range nodes {
status, _ := apps(c.Session(n, "friend"))
Expect(status).To(Equal(200), n)
}
out, err := victim.Do("POST", "/api/auth/logout", nil, nil)
Expect(err).NotTo(HaveOccurred())
Expect(out.Status).To(Equal(200), out.String())
for _, n := range nodes {
status, _ := apps(c.Session(n, "friend"))
Expect(status).To(Equal(401), n+": the ended session is refused everywhere")
asset, err := c.Session(n, "friend").Do("GET", "/api/addons/found-tool/assets/"+"0000000000000000000000000000000000000000000000000000000000000000"+"/ui.js", nil, nil)
Expect(err).NotTo(HaveOccurred())
Expect(asset.Status).To(Equal(401), n)
}
})
It("refuses an API key on every node once it was revoked through one frontend", func() {
member := c.Creds.Users["member"]
bearer := c.Token(cluster.FrontB, "member")
Expect(actorOf(read(bearer, "found-tool", res, 1, map[string]any{"tool": "echo"}))).To(Equal("fnd-member"))
r, err := c.Session(cluster.FrontA, "member").Do("DELETE", "/api/auth/api-keys/"+member.KeyID, nil, nil)
Expect(err).NotTo(HaveOccurred())
Expect(r.Status).To(Equal(200), r.String())
for _, n := range nodes {
got := read(c.Token(n, "member"), "found-tool", res, 1, map[string]any{"tool": "echo"})
Expect(got.Status).To(Equal(401), n+": "+got.String())
}
})
})
@@ -0,0 +1,14 @@
// SPDX-License-Identifier: MIT
package hostslots_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestHostSlots(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Host UI slots contract")
}
+191
View File
@@ -0,0 +1,191 @@
// SPDX-License-Identifier: MIT
package hostslots_test
import (
"encoding/json"
"os"
"regexp"
"slices"
"strings"
"github.com/mudler/LocalAI/core/services/addons/apps"
"github.com/mudler/LocalAI/core/services/addons/packages"
"github.com/mudler/LocalAI/pkg/addons/contract"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func host() contract.HostCompatibility {
return contract.HostCompatibility{Host: 1, API: 1, Protocol: 1, SDK: 1, DataFormat: 1, OS: "linux", Arch: "amd64"}
}
// manifest builds a manifest with the given ui object. Tools are ping and list.
func manifest(ui string) []byte {
return []byte(`{"schemaVersion":1,"id":"notes","version":"1.0.0","executable":"bin/x","os":"linux","arch":"amd64",` +
`"host":{"min":1,"max":2},"api":{"min":1,"max":1},"protocol":{"min":1,"max":1},"dataFormat":{"min":1,"max":2},` +
`"capabilities":{"tools":["ping","list"],"ui":` + ui + `}}`)
}
const base = `"entry":"ui.js","sdk":{"min":1,"max":1},"icon":"ui/icon.png","pages":["home"]`
func validate(ui string) error {
m, e := contract.ParseManifest(manifest(ui))
if e != nil {
return e
}
return contract.ValidateManifest(m, host())
}
var _ = Describe("Host UI contributions in the manifest", func() {
It("accepts tool and component chat actions, slots and own-tool renderers", func() {
ui := `{` + base + `,"chatActions":[
{"id":"summarize","label":"Summarize","icon":"sparkles","placement":"composer","target":{"tool":"ping"}},
{"id":"inspect","label":{"en":"Inspect","it":"Ispeziona"},"icon":"ui/icon.png","placement":"message","target":{"component":"InspectPanel"}}],
"slots":[{"id":"nav","slot":"app.nav","component":"NavBit"},{"id":"set","slot":"settings.sections","component":"SettingsBit"},
{"id":"cx","slot":"chat.composer.actions","component":"ComposerBit"},{"id":"mx","slot":"chat.message.actions","component":"MessageBit"}],
"renderers":[{"kind":"approval-card","tool":"ping","component":"PingCard"},{"kind":"tool-result","tool":"list","component":"ListView"}]}`
Expect(validate(ui)).To(Succeed())
m, _ := contract.ParseManifest(manifest(ui))
Expect(m.Capabilities.UI.ChatActions).To(HaveLen(2))
Expect(m.Capabilities.UI.ChatActions[1].Label.ByLanguage).To(HaveKeyWithValue("it", "Ispeziona"))
// The canonical form is stable.
out, e := json.Marshal(m)
Expect(e).NotTo(HaveOccurred())
again, e := contract.ParseManifest(out)
Expect(e).NotTo(HaveOccurred())
Expect(again).To(Equal(m))
})
It("keeps bare chat action identifiers and every earlier fixture valid", func() {
Expect(validate(`{` + base + `,"chatActions":["open","summarize"]}`)).To(Succeed())
for _, name := range []string{"service", "headless", "page", "chat", "app"} {
b, e := os.ReadFile("../fixtures/contracts/" + name + ".json")
Expect(e).NotTo(HaveOccurred())
m, e := contract.ParseManifest(b)
Expect(e).NotTo(HaveOccurred())
Expect(contract.ValidateManifest(m, host())).To(Succeed(), name)
}
for _, dir := range []string{"bare", "cssb", "uifixture", "uifixture-2"} {
b, e := os.ReadFile("../fixture/ui/" + dir + "/manifest.json")
Expect(e).NotTo(HaveOccurred())
m, e := contract.ParseManifest(b)
Expect(e).NotTo(HaveOccurred())
Expect(contract.ValidateManifest(m, host())).To(Succeed(), dir)
}
})
It("rejects every undeclared, unsafe or inconsistent contribution at install", func() {
action := func(body string) string {
return `{` + base + `,"chatActions":[` + body + `]}`
}
bad := map[string]string{
"unknown slot": `{` + base + `,"slots":[{"id":"a","slot":"chat.footer","component":"Bit"}]}`,
"unknown placement": action(`{"id":"a","label":"A","icon":"star","placement":"sidebar","target":{"tool":"ping"}}`),
"missing placement": action(`{"id":"a","label":"A","icon":"star","target":{"tool":"ping"}}`),
"remote icon": action(`{"id":"a","label":"A","icon":"https://evil.example/i.png","placement":"composer","target":{"tool":"ping"}}`),
"absolute icon": action(`{"id":"a","label":"A","icon":"/etc/icon.png","placement":"composer","target":{"tool":"ping"}}`),
"unknown icon name": action(`{"id":"a","label":"A","icon":"skull","placement":"composer","target":{"tool":"ping"}}`),
"other packaged icon": action(`{"id":"a","label":"A","icon":"ui/other.png","placement":"composer","target":{"tool":"ping"}}`),
"undeclared tool": action(`{"id":"a","label":"A","icon":"star","placement":"composer","target":{"tool":"delete-everything"}}`),
"both targets": action(`{"id":"a","label":"A","icon":"star","placement":"composer","target":{"tool":"ping","component":"Panel"}}`),
"no target": action(`{"id":"a","label":"A","icon":"star","placement":"composer","target":{}}`),
"duplicate ids": action(`{"id":"a","label":"A","icon":"star","placement":"composer","target":{"tool":"ping"}},{"id":"a","label":"B","icon":"star","placement":"message","target":{"tool":"ping"}}`),
"markup label": action(`{"id":"a","label":"<b>A</b>","icon":"star","placement":"composer","target":{"tool":"ping"}}`),
"long label": action(`{"id":"a","label":"` + strings.Repeat("x", 61) + `","icon":"star","placement":"composer","target":{"tool":"ping"}}`),
"unsafe component": action(`{"id":"a","label":"A","icon":"star","placement":"message","target":{"component":"__proto__"}}`),
"dotted component": action(`{"id":"a","label":"A","icon":"star","placement":"message","target":{"component":"A.b"}}`),
"unknown field": action(`{"id":"a","label":"A","icon":"star","placement":"composer","target":{"tool":"ping"},"onClick":"x"}`),
"slot id clashes": `{` + base + `,"chatActions":["a"],"slots":[{"id":"a","slot":"app.nav","component":"Bit"}]}`,
"duplicate slot id": `{` + base + `,"slots":[{"id":"a","slot":"app.nav","component":"Bit"},{"id":"a","slot":"settings.sections","component":"Bit"}]}`,
"lowercase component": `{` + base + `,"slots":[{"id":"a","slot":"app.nav","component":"constructor"}]}`,
"renderer foreign": `{` + base + `,"renderers":[{"kind":"approval-card","tool":"other-addon-tool","component":"Card"}]}`,
"renderer kind": `{` + base + `,"renderers":[{"kind":"banner","tool":"ping","component":"Card"}]}`,
"renderer duplicate": `{` + base + `,"renderers":[{"kind":"tool-result","tool":"ping","component":"A"},{"kind":"tool-result","tool":"ping","component":"B"}]}`,
"too many actions": `{` + base + `,"chatActions":[` + manyActions(17) + `]}`,
"too many slots": `{` + base + `,"slots":[` + manySlots(17) + `]}`,
"slot without id": `{` + base + `,"slots":[{"slot":"app.nav","component":"Bit"}]}`,
"slot unknown field": `{` + base + `,"slots":[{"id":"a","slot":"app.nav","component":"Bit","props":{}}]}`,
"renderer unknown key": `{` + base + `,"renderers":[{"kind":"tool-result","tool":"ping","component":"A","style":"x"}]}`,
}
for name, ui := range bad {
Expect(validate(ui)).NotTo(Succeed(), name)
}
// A valid control for each family, so the table above cannot pass by
// failing for an unrelated reason.
Expect(validate(action(`{"id":"a","label":"A","icon":"star","placement":"composer","target":{"tool":"ping"}}`))).To(Succeed())
Expect(validate(`{` + base + `,"slots":[{"id":"a","slot":"app.nav","component":"Bit"}]}`)).To(Succeed())
Expect(validate(`{` + base + `,"renderers":[{"kind":"tool-result","tool":"ping","component":"A"}]}`)).To(Succeed())
})
It("keeps the closed slot list and the icon allowlist equal to the host", func() {
Expect(contract.HostSlots).To(Equal([]string{"chat.composer.actions", "chat.message.actions", "app.nav", "settings.sections"}))
src, e := os.ReadFile("../../../core/http/react-ui/src/addons/sdk.js")
Expect(e).NotTo(HaveOccurred())
block := regexp.MustCompile(`(?s)ICON_NAMES = Object\.freeze\(\[(.*?)\]\)`).FindSubmatch(src)
Expect(block).NotTo(BeNil())
var names []string
for _, m := range regexp.MustCompile(`'([a-z-]+)'`).FindAllSubmatch(block[1], -1) {
names = append(names, string(m[1]))
}
Expect(slices.Equal(names, contract.IconNames)).To(BeTrue(), "sdk.js ICON_NAMES and contract.IconNames differ")
slots, e := os.ReadFile("../../../core/http/react-ui/src/addons/slots.js")
Expect(e).NotTo(HaveOccurred())
for _, id := range contract.HostSlots {
Expect(string(slots)).To(ContainSubstring("'" + id + "'"))
}
})
})
func manyActions(n int) string {
var out []string
for i := 0; i < n; i++ {
out = append(out, `{"id":"a`+string(rune('a'+i))+`","label":"A","icon":"star","placement":"composer","target":{"tool":"ping"}}`)
}
return strings.Join(out, ",")
}
func manySlots(n int) string {
var out []string
for i := 0; i < n; i++ {
out = append(out, `{"id":"s`+string(rune('a'+i))+`","slot":"app.nav","component":"Bit"}`)
}
return strings.Join(out, ",")
}
var _ = Describe("The apps list carries the declared contributions", func() {
snapshot := func(enabled bool, ui string) apps.Snapshot {
m, e := contract.ParseManifest(manifest(ui))
Expect(e).NotTo(HaveOccurred())
return apps.Snapshot{AddonID: "notes", Installed: true, Enabled: enabled, Generation: 2, State: "running",
Receipt: packages.PackageReceipt{Digest: "sha256:" + strings.Repeat("ab", 32), Manifest: m,
Icon: &packages.PackageIcon{Path: "ui/icon.png"}}}
}
ui := `{` + base + `,"chatActions":["legacy",
{"id":"summarize","label":{"en":"Summarize","it":"Riassumi"},"icon":"sparkles","placement":"composer","target":{"tool":"ping"}},
{"id":"inspect","label":"Inspect","icon":"ui/icon.png","placement":"message","target":{"component":"InspectPanel"}}],
"slots":[{"id":"set","slot":"settings.sections","component":"SettingsBit"}],
"renderers":[{"kind":"approval-card","tool":"ping","component":"PingCard"}]}`
It("lists actions, slots and renderers for an enabled addon and keeps the identifiers", func() {
a := apps.Build([]apps.Snapshot{snapshot(true, ui)}, false)[0]
Expect(a.UI).NotTo(BeNil())
Expect(a.UI.ChatActions).To(Equal([]string{"legacy", "summarize", "inspect"}))
Expect(a.UI.Actions).To(Equal([]apps.ChatActionView{
{ID: "summarize", Label: "Summarize", Labels: map[string]string{"en": "Summarize", "it": "Riassumi"}, Icon: "sparkles", Placement: "composer", Tool: "ping"},
{ID: "inspect", Label: "Inspect", PackagedIcon: true, Placement: "message", Component: "InspectPanel"},
}))
Expect(a.UI.Slots).To(Equal([]apps.SlotView{{ID: "set", Slot: "settings.sections", Component: "SettingsBit"}}))
Expect(a.UI.Renderers).To(Equal([]contract.Renderer{{Kind: "approval-card", Tool: "ping", Component: "PingCard"}}))
b, e := json.Marshal(a)
Expect(e).NotTo(HaveOccurred())
Expect(string(b)).To(ContainSubstring(`"slot":"settings.sections"`))
})
It("lists no contribution for a disabled addon, whoever asks", func() {
for _, admin := range []bool{false, true} {
for _, a := range apps.Build([]apps.Snapshot{snapshot(false, ui)}, admin) {
Expect(a.UI).To(BeNil())
}
}
})
})
+300
View File
@@ -0,0 +1,300 @@
// SPDX-License-Identifier: MIT
// Package packaging_test installs the package that scripts/addons/package.sh
// builds, through the real installer and a real local OCI distribution, and
// starts the packaged Tables executable under the real supervisor. The
// registry is an in-process distribution on a loopback port; nothing is
// pushed anywhere else and nothing is signed with a real identity.
package packaging_test
import (
"context"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
goruntime "runtime"
"strings"
"testing"
"time"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/registry"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/empty"
"github.com/google/go-containerregistry/pkg/v1/mutate"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/google/go-containerregistry/pkg/v1/static"
"github.com/google/go-containerregistry/pkg/v1/types"
"github.com/mudler/LocalAI/core/services/addons/catalog"
"github.com/mudler/LocalAI/core/services/addons/packages"
addonruntime "github.com/mudler/LocalAI/core/services/addons/runtime"
"github.com/mudler/LocalAI/pkg/addons/contract"
"github.com/mudler/LocalAI/pkg/addons/packaging"
"github.com/mudler/LocalAI/pkg/credentials"
"github.com/mudler/LocalAI/tests/addons/fixtures/signing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"gopkg.in/yaml.v3"
)
func TestPackagingContract(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Add-on package installer contract")
}
const (
repoRoot = "../../.."
revision = "0123456789abcdef0123456789abcdef01234567"
bundle = "application/vnd.dev.sigstore.bundle.v0.3+json"
issuer = "https://token.actions.githubusercontent.com"
)
var (
binary string
hostPlat = goruntime.GOOS + "/" + goruntime.GOARCH
)
var _ = BeforeSuite(func() {
binary = filepath.Join(GinkgoT().TempDir(), "tables")
cmd := exec.Command("go", "build", "-trimpath", "-o", binary, filepath.Join(repoRoot, "addons/tables/cmd/tables"))
cmd.Env = append(os.Environ(), "CGO_ENABLED=1")
out, err := cmd.CombinedOutput()
Expect(err).NotTo(HaveOccurred(), string(out))
_, err = os.Stat(filepath.Join(repoRoot, "addons/tables/ui/dist/ui/tables.js"))
Expect(err).NotTo(HaveOccurred(), "build the UI first: node addons/tables/ui/build.mjs")
})
// matrix writes a platform matrix that enables the platform of this host.
func matrix() string {
GinkgoHelper()
p := filepath.Join(GinkgoT().TempDir(), "platforms.yaml")
body := "schema: 1\naddon: tables\nexecutable: tables\nmanifest: addons/tables/manifest.json\nui: addons/tables/ui/dist\nplatforms:\n" +
" - platform: " + hostPlat + "\n enabled: true\n runner: native\n native_check: executable-start\n"
Expect(os.WriteFile(p, []byte(body), 0o644)).To(Succeed())
return p
}
// packageSh runs the real entrypoint script.
func packageSh(args ...string) (string, error) {
cmd := exec.Command("sh", append([]string{filepath.Join(repoRoot, "scripts/addons/package.sh")}, args...)...)
out, err := cmd.CombinedOutput()
return string(out), err
}
type published struct {
repository string
digest string // platform image digest the catalog pins
index string
entry map[string]any
registry string
authority *signing.Authority
}
// publishPackage builds, merges, publishes (to the local distribution) and
// renders the catalog entry of the package for the host platform.
func publishPackage(version string, signAs string) published {
GinkgoHelper()
cfg := matrix()
work := GinkgoT().TempDir()
layout := filepath.Join(work, "leg")
out, err := packageSh("--config", cfg, "--layout", layout, "--version", version, "--platform", hostPlat, "--executable", binary, "--revision", revision)
Expect(err).NotTo(HaveOccurred(), out)
merged := filepath.Join(work, "merged")
Expect(packaging.Run([]string{"merge", "--config", cfg, "--out", merged, "--version", version, "--revision", revision, layout}, io.Discard)).To(Succeed())
handler := registry.New(registry.Logger(log.New(io.Discard, "", 0)))
server := httptest.NewServer(handler)
DeferCleanup(server.Close)
host := strings.TrimPrefix(server.URL, "http://")
repository := host + "/addons/tables"
var pub strings.Builder
Expect(packaging.Run([]string{"publish", "--config", cfg, "--layout", merged, "--repository", repository, "--tag", version}, &pub)).To(Succeed())
catalogFile := filepath.Join(work, "catalog.yaml")
Expect(packaging.Run([]string{"catalog", "--config", cfg, "--template", filepath.Join(repoRoot, "gallery/addons/tables.yaml.tmpl"),
"--merged", merged, "--repository", repository, "--tag", version, "--channel", "latest",
"--owner-repo", "example/localai", "--signer-ref", "refs/tags/v" + version, "--out", catalogFile}, io.Discard)).To(Succeed())
b, err := os.ReadFile(catalogFile)
Expect(err).NotTo(HaveOccurred())
// The host's own reader is the judge of the generated index.
hostEntries, err := catalog.DecodeIndex(b)
Expect(err).NotTo(HaveOccurred())
Expect(hostEntries).To(HaveLen(1))
var entries []map[string]any
Expect(yaml.Unmarshal(b, &entries)).To(Succeed())
Expect(entries).To(HaveLen(1))
p := published{repository: repository, entry: entries[0], registry: host, authority: nil}
p.digest = entries[0]["digest"].(string)
for _, l := range strings.Split(pub.String(), "\n") {
if v, ok := strings.CutPrefix(l, "digest="); ok {
p.index = v
}
}
// Sign the platform image the catalog pins, as the workflow's signing
// step does (the identity is what the workflow certificate carries).
authority, err := signing.New()
Expect(err).NotTo(HaveOccurred())
p.authority = authority
identity := entries[0]["policy"].(map[string]any)["identity"].(string)
if signAs != "" {
identity = signAs
}
ref, err := name.ParseReference(repository + "@" + p.digest)
Expect(err).NotTo(HaveOccurred())
blob, err := authority.Bundle(strings.TrimPrefix(p.digest, "sha256:"), identity, issuer)
Expect(err).NotTo(HaveOccurred())
img, err := mutate.AppendLayers(empty.Image, static.NewLayer(blob, types.MediaType(bundle)))
Expect(err).NotTo(HaveOccurred())
h, err := img.Digest()
Expect(err).NotTo(HaveOccurred())
size, err := img.Size()
Expect(err).NotTo(HaveOccurred())
index := mutate.AppendManifests(empty.Index, mutate.IndexAddendum{Add: img, Descriptor: v1.Descriptor{Digest: h, Size: size, MediaType: types.DockerManifestSchema2, ArtifactType: bundle}})
Expect(remote.WriteIndex(ref.Context().Tag(strings.Replace(p.digest, ":", "-", 1)), index)).To(Succeed())
Expect(remote.Write(ref.Context().Tag("signature"), img)).To(Succeed())
return p
}
// installer is an owner: a catalog source that serves the rendered entry
// with the signer policy the owner configures, and a private package root.
func installer(p published, policy catalog.VerificationPolicy) (*packages.Installer, catalog.Release) {
GinkgoHelper()
entry := p.entry
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer catalog-only" {
w.WriteHeader(403)
return
}
doc, err := yaml.Marshal([]map[string]any{entry})
Expect(err).NotTo(HaveOccurred())
_, _ = w.Write(doc)
}))
DeferCleanup(server.Close)
store, err := credentials.Parse([]byte("- match: "+server.URL+"\n allow_insecure: true\n bearer: catalog-only"), nil)
Expect(err).NotTo(HaveOccurred())
sources, err := catalog.NewSources(filepath.Join(GinkgoT().TempDir(), "sources"), func(context.Context, contract.Actor) error { return nil },
catalog.CredentialResolver{References: map[string]catalog.CredentialBinding{"catalog": {Origin: server.URL, Store: store}}})
Expect(err).NotTo(HaveOccurred())
src := catalog.Source{ID: "first-party", Name: "First party", Enabled: true, AllowLoopbackHTTP: true, URL: server.URL, AuthReference: "catalog", Policy: policy}
Expect(sources.Save(context.Background(), contract.Actor{}, src)).To(Succeed())
_, err = sources.Refresh(context.Background(), "first-party")
Expect(err).NotTo(HaveOccurred())
c := catalog.Catalog{Sources: sources, TargetOS: goruntime.GOOS, TargetArch: goruntime.GOARCH}
release, err := c.Resolve(context.Background(), "first-party", "tables", "latest")
Expect(err).NotTo(HaveOccurred())
root := GinkgoT().TempDir()
DeferCleanup(func() {
_ = filepath.Walk(root, func(p string, info os.FileInfo, e error) error {
if e == nil && info.IsDir() {
_ = os.Chmod(p, 0o700)
}
return nil
})
})
return &packages.Installer{
Root: root, Catalog: c,
Host: contract.HostCompatibility{Host: 1, API: 1, Protocol: 1, SDK: 1, DataFormat: 1, OS: goruntime.GOOS, Arch: goruntime.GOARCH},
}, release
}
var _ = Describe("first-party package", func() {
It("installs the package built by package.sh and starts its executable", func() {
p := publishPackage("1.2.3", "")
identity := p.entry["policy"].(map[string]any)["identity"].(string)
Expect(identity).To(Equal("https://github.com/example/localai/.github/workflows/addons-package.yml@refs/tags/v1.2.3"))
i, release := installer(p, catalog.VerificationPolicy{RequireSignature: true, Issuer: issuer, Identity: identity})
i.SignatureTrust = p.authority
Expect(release.Digest).To(Equal(p.digest))
staged, err := i.Stage(context.Background(), release, nil)
Expect(err).NotTo(HaveOccurred())
receipt, err := i.Commit(context.Background(), staged)
Expect(err).NotTo(HaveOccurred())
Expect(receipt.Verification).To(Equal("sha256+sigstore"))
Expect(receipt.Digest).To(Equal(p.digest))
Expect(receipt.Manifest.ID).To(Equal("tables"))
Expect(receipt.Manifest.Version).To(Equal("1.2.3"))
Expect(receipt.Manifest.OS).To(Equal(goruntime.GOOS))
Expect(receipt.Manifest.Arch).To(Equal(goruntime.GOARCH))
Expect(receipt.Icon).NotTo(BeNil())
info, err := os.Stat(filepath.Join(receipt.Directory, "tables"))
Expect(err).NotTo(HaveOccurred())
Expect(info.Mode().Perm() & 0o100).NotTo(BeZero())
Expect(filepath.Join(receipt.Directory, "ui", "tables.js")).To(BeAnExistingFile())
Expect(filepath.Join(receipt.Directory, "ui", "tables.css")).To(BeAnExistingFile())
limits := contract.DefaultLimits()
sup, err := addonruntime.NewSupervisor(addonruntime.Config{StateRoot: filepath.Join(GinkgoT().TempDir(), "runtime"), DataRoot: filepath.Join(GinkgoT().TempDir(), "data"), Limits: limits, ReadyTimeout: 20 * time.Second, StableHealth: limits.BackoffMax})
Expect(err).NotTo(HaveOccurred())
DeferCleanup(func() { Expect(sup.Close(context.Background())).To(Succeed()) })
inst, err := sup.Start(context.Background(), receipt, 1)
Expect(err).NotTo(HaveOccurred())
Expect(inst.Generation).To(Equal(uint64(1)))
})
It("is refused when the signature carries another workflow identity", func() {
p := publishPackage("1.2.4", "https://github.com/example/localai/.github/workflows/backend_merge.yml@refs/heads/master")
identity := p.entry["policy"].(map[string]any)["identity"].(string)
i, release := installer(p, catalog.VerificationPolicy{RequireSignature: true, Issuer: issuer, Identity: identity})
i.SignatureTrust = p.authority
_, err := i.Stage(context.Background(), release, nil)
Expect(err).To(MatchError(packages.ErrPackage))
entries, err := os.ReadDir(i.Root)
Expect(err).NotTo(HaveOccurred())
Expect(entries).To(BeEmpty())
})
It("is refused when the owner policy names the backend workflow", func() {
p := publishPackage("1.2.5", "")
i, release := installer(p, catalog.VerificationPolicy{RequireSignature: true, Issuer: issuer, Identity: "https://github.com/example/localai/.github/workflows/backend_merge.yml@refs/heads/master"})
i.SignatureTrust = p.authority
_, err := i.Stage(context.Background(), release, nil)
Expect(err).To(MatchError(packages.ErrPackage))
})
It("is refused when the pinned digest is not the published image", func() {
p := publishPackage("1.2.6", "")
identity := p.entry["policy"].(map[string]any)["identity"].(string)
i, release := installer(p, catalog.VerificationPolicy{RequireSignature: true, Issuer: issuer, Identity: identity})
i.SignatureTrust = p.authority
release.Digest = "sha256:" + strings.Repeat("0", 64)
_, err := i.Stage(context.Background(), release, nil)
Expect(err).To(HaveOccurred())
})
It("gives identical digests for two builds of the same inputs", func() {
cfg := matrix()
var digests []string
for n := 0; n < 2; n++ {
layout := filepath.Join(GinkgoT().TempDir(), "leg")
out, err := packageSh("--config", cfg, "--layout", layout, "--version", "2.0.0", "--platform", hostPlat, "--executable", binary, "--revision", revision)
Expect(err).NotTo(HaveOccurred(), out)
for _, l := range strings.Split(out, "\n") {
if v, ok := strings.CutPrefix(l, "digest="); ok {
digests = append(digests, v)
}
}
}
Expect(digests).To(HaveLen(2))
Expect(digests[0]).To(Equal(digests[1]))
})
It("refuses a platform the matrix does not enable and a missing executable of another platform", func() {
layout := filepath.Join(GinkgoT().TempDir(), "leg")
out, err := packageSh("--layout", layout, "--version", "1.0.0", "--platform", hostPlat, "--executable", binary)
Expect(err).To(HaveOccurred(), out)
Expect(out).To(ContainSubstring("not enabled"))
Expect(layout).NotTo(BeADirectory())
other := "linux/arm64"
if hostPlat == other {
other = "linux/amd64"
}
out, err = packageSh("--config", matrix(), "--layout", filepath.Join(GinkgoT().TempDir(), "x"), "--version", "1.0.0", "--platform", other)
Expect(err).To(HaveOccurred(), out)
Expect(out).To(ContainSubstring("not this host"))
})
})
+475
View File
@@ -0,0 +1,475 @@
// SPDX-License-Identifier: MIT
package recovery_test
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"fmt"
"io/fs"
"math/big"
"net"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
goruntime "runtime"
"testing"
"time"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/config"
httpapi "github.com/mudler/LocalAI/core/http"
"github.com/mudler/LocalAI/core/http/auth"
"github.com/mudler/LocalAI/core/http/routes"
addonsvc "github.com/mudler/LocalAI/core/services/addons"
"github.com/mudler/LocalAI/core/services/addons/catalog"
"github.com/mudler/LocalAI/core/services/addons/gateway"
"github.com/mudler/LocalAI/core/services/addons/policy"
"github.com/mudler/LocalAI/pkg/addons/contract"
"github.com/mudler/LocalAI/pkg/system"
"github.com/mudler/LocalAI/tests/addons/foundation/cluster"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestRecovery(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Add-on data recovery")
}
var binary string
var _ = BeforeSuite(func() {
binary = filepath.Join(GinkgoT().TempDir(), "tables")
cmd := exec.Command("go", "build", "-o", binary, "../../../addons/tables/cmd/tables")
cmd.Env = append(os.Environ(), "CGO_ENABLED=1")
out, e := cmd.CombinedOutput()
Expect(e).NotTo(HaveOccurred(), string(out))
})
const appSecret = "tables-admin-secret"
// transports builds the transport identities of one owner and one forwarding
// frontend: a CA, two pinned leaves and two signing keys.
func transports(port int) (owner, front *gateway.CarrierConfig) {
GinkgoHelper()
pub, key, e := ed25519.GenerateKey(rand.Reader)
Expect(e).NotTo(HaveOccurred())
ca := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test CA"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(24 * time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign}
der, e := x509.CreateCertificate(rand.Reader, ca, ca, pub, key)
Expect(e).NotTo(HaveOccurred())
ca, e = x509.ParseCertificate(der)
Expect(e).NotTo(HaveOccurred())
pool := x509.NewCertPool()
pool.AddCert(ca)
pins := map[string][32]byte{}
verify := map[string]ed25519.PublicKey{}
pairs := map[string]tls.Certificate{}
signing := map[string]ed25519.PrivateKey{}
for i, id := range []string{"owner", "front-a"} {
lp, lk, e := ed25519.GenerateKey(rand.Reader)
Expect(e).NotTo(HaveOccurred())
leaf := &x509.Certificate{SerialNumber: big.NewInt(int64(i + 2)), Subject: pkix.Name{CommonName: id}, NotBefore: ca.NotBefore, NotAfter: ca.NotAfter, IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}}
d, e := x509.CreateCertificate(rand.Reader, leaf, ca, lp, key)
Expect(e).NotTo(HaveOccurred())
kd, e := x509.MarshalPKCS8PrivateKey(lk)
Expect(e).NotTo(HaveOccurred())
pair, e := tls.X509KeyPair(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: d}), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: kd}))
Expect(e).NotTo(HaveOccurred())
sp, sk, e := ed25519.GenerateKey(rand.Reader)
Expect(e).NotTo(HaveOccurred())
pins[id], verify[id], pairs[id], signing[id] = sha256.Sum256(d), sp, pair, sk
}
address := fmt.Sprintf("https://127.0.0.1:%d", port)
mk := func(id string) *gateway.CarrierConfig {
return &gateway.CarrierConfig{InstanceID: id, OwnerID: "owner", OwnerAddress: address, Certificate: pairs[id], Roots: pool, Pins: pins, SigningKey: signing[id], VerificationKeys: verify}
}
return mk("owner"), mk("front-a")
}
func freePort() int {
GinkgoHelper()
l, e := net.Listen("tcp", "127.0.0.1:0")
Expect(e).NotTo(HaveOccurred())
defer func() { _ = l.Close() }()
return l.Addr().(*net.TCPAddr).Port
}
// person is an account of the fixture.
type person struct {
id, role string
feature bool
status string
}
var people = []person{
{"alice", auth.RoleAdmin, false, auth.StatusActive},
{"bob", auth.RoleUser, true, auth.StatusActive},
{"carol", auth.RoleUser, true, auth.StatusActive},
{"dave", auth.RoleUser, true, auth.StatusActive},
{"erin", auth.RoleUser, false, auth.StatusActive},
{"frank", auth.RoleUser, true, auth.StatusPending},
}
// host is one owner in process: the real application bootstrap over real
// account storage, the real supervisor and the real Tables executable.
type host struct {
dir string
libraryURL string
owner config.AddonConfig
front *gateway.CarrierConfig
app *application.Application
web *echo.Echo
cancel context.CancelFunc
local bool
tokens map[string]string
sessions map[string]string
keys map[string]string
}
func newHost(local bool) *host {
GinkgoHelper()
dir := GinkgoT().TempDir()
h := &host{dir: dir, local: local, tokens: map[string]string{}, sessions: map[string]string{}, keys: map[string]string{}}
DeferCleanup(func() {
_ = filepath.WalkDir(dir, func(p string, d fs.DirEntry, e error) error {
if e == nil && d.IsDir() {
_ = os.Chmod(p, 0o700)
}
return nil
})
})
port := freePort()
var owner *gateway.CarrierConfig
owner, h.front = transports(port)
h.owner = config.AddonConfig{
Enabled: true, InstanceID: "owner", OwnerID: "owner", OwnerAddress: owner.OwnerAddress, StorageID: "local",
Root: filepath.Join(dir, "addons"), ListenAddress: fmt.Sprintf("127.0.0.1:%d", port), Transport: owner,
Host: contract.HostCompatibility{Host: 1, API: 1, Protocol: 1, SDK: 1, DataFormat: 1, OS: goruntime.GOOS, Arch: goruntime.GOARCH},
}
if local {
h.owner.OwnershipMode = auth.AddonModeLocalOwner
}
return h
}
func (h *host) mode() string {
if h.local {
return auth.AddonModeLocalOwner
}
return auth.AddonModeNamedUser
}
func (h *host) start() {
GinkgoHelper()
models, backends := filepath.Join(h.dir, "models"), filepath.Join(h.dir, "backends")
Expect(os.MkdirAll(models, 0o750)).To(Succeed())
Expect(os.MkdirAll(backends, 0o750)).To(Succeed())
state, e := system.GetSystemState(system.WithBackendPath(backends), system.WithModelPath(models))
Expect(e).NotTo(HaveOccurred())
ctx, cancel := context.WithCancel(context.Background())
h.cancel = cancel
opts := []config.AppOption{config.WithContext(ctx), config.WithSystemState(state), config.WithDataPath(h.dir), config.WithAddons(h.owner)}
if !h.local {
opts = append(opts, config.WithAuthEnabled(true), config.WithAuthDatabaseURL(filepath.Join(h.dir, "auth.db")), config.WithAuthAPIKeyHMACSecret(appSecret))
}
app, e := application.New(opts...)
Expect(e).NotTo(HaveOccurred())
h.app = app
h.web, e = httpapi.API(app)
Expect(e).NotTo(HaveOccurred())
}
// stop ends the application as a restart does and waits until the owner
// released its durable state.
func (h *host) stop() {
GinkgoHelper()
if h.cancel == nil {
return
}
Expect(h.app.Shutdown()).To(Succeed())
h.cancel()
h.cancel = nil
mode := policy.ModeNamedUser
if h.local {
mode = policy.ModeLocalOwner
}
Eventually(func() error {
s, e := policy.OpenStore(filepath.Join(h.owner.Root, "policy"), mode)
if e == nil {
_ = s.Close()
}
return e
}, 10*time.Second, 50*time.Millisecond).Should(Succeed())
}
func (h *host) actor(user string) contract.Actor {
if h.local {
return contract.Actor{UserID: auth.AddonLocalOwnerID, CredentialID: auth.AddonLocalOwnerID, Mode: auth.AddonModeLocalOwner}
}
return contract.Actor{UserID: user, CredentialID: "key:" + h.keys[user], Mode: auth.AddonModeNamedUser}
}
// seed creates the accounts, installs the real Tables package from a local
// catalog and waits until it runs.
func (h *host) seed(lib *cluster.Library) {
GinkgoHelper()
ctx := context.Background()
if !h.local {
db := h.app.AuthDB()
Expect(db).NotTo(BeNil())
for _, u := range people {
Expect(db.Create(&auth.User{ID: u.id, Email: u.id + "@tables.test", Name: "Name of " + u.id, Provider: auth.ProviderLocal, Role: u.role, Status: u.status}).Error).To(Succeed())
if u.feature {
Expect(db.Create(&auth.UserPermission{ID: uuid.NewString(), UserID: u.id, Permissions: auth.PermissionMap{auth.FeatureAddons: true}}).Error).To(Succeed())
}
token, key, e := auth.CreateAPIKey(db, u.id, "agent", u.role, appSecret, nil)
Expect(e).NotTo(HaveOccurred())
session, e := auth.CreateSession(db, u.id, appSecret)
Expect(e).NotTo(HaveOccurred())
h.tokens[u.id], h.sessions[u.id], h.keys[u.id] = token, session, key.ID
}
}
admin := h.actor("alice")
Expect(h.app.AddonSources().Save(ctx, admin, catalog.Source{ID: "local", Name: "Local", URL: lib.URL(), Enabled: true, AllowLoopbackHTTP: true})).To(Succeed())
_, e := h.app.AddonSources().Refresh(ctx, "local")
Expect(e).NotTo(HaveOccurred())
m := h.app.AddonManager()
_, e = m.Submit(ctx, admin, addonsvc.LifecycleCommand{OperationID: "install-tables", AddonID: "tables", Action: "install", SourceID: "local", Version: "0.1.0"})
Expect(e).NotTo(HaveOccurred())
_, e = m.Submit(ctx, admin, addonsvc.LifecycleCommand{OperationID: "enable-tables", AddonID: "tables", Action: "enable", Generation: 1})
Expect(e).NotTo(HaveOccurred())
h.running()
}
func (h *host) running() {
GinkgoHelper()
Eventually(func() string {
list, e := h.app.AddonManager().Apps(context.Background(), h.actor("alice"))
if e != nil {
return e.Error()
}
for _, a := range list {
if a.ID == "tables" {
return a.State
}
}
return "absent"
}, 30*time.Second, 100*time.Millisecond).Should(Equal("running"))
}
func (h *host) library() *cluster.Library {
GinkgoHelper()
lib, e := cluster.NewLibrary(binary)
Expect(e).NotTo(HaveOccurred())
DeferCleanup(lib.Close)
h.libraryURL = lib.URL()
raw, e := os.ReadFile("../../../addons/tables/manifest.json")
Expect(e).NotTo(HaveOccurred())
var m struct {
Capabilities map[string]any `json:"capabilities"`
}
Expect(json.Unmarshal(raw, &m)).To(Succeed())
// These specs run the tools, not the page: the package carries no UI
// files, so the declaration of them is left out.
delete(m.Capabilities, "ui")
exe, e := os.ReadFile(binary)
Expect(e).NotTo(HaveOccurred())
for _, v := range []string{"0.1.0", "0.1.1"} {
Expect(lib.Add(cluster.Package{ID: "tables", Version: v, Capabilities: m.Capabilities, Executable: "bin/tables", Binary: exe}, GinkgoT().TempDir())).To(Succeed())
}
// A release whose process fails at start: its upgrade must be undone.
Expect(lib.Add(cluster.Package{ID: "tables", Version: "0.2.0", Capabilities: m.Capabilities, Executable: "bin/tables", Binary: []byte("#!/bin/sh\nexit 9\n")}, GinkgoT().TempDir())).To(Succeed())
return lib
}
// ready is the usual start of a spec: a running host with the accounts and
// the package.
func ready(local bool) *host {
GinkgoHelper()
h := newHost(local)
lib := h.library()
h.start()
DeferCleanup(h.stop)
h.seed(lib)
return h
}
// reply is one HTTP answer.
type reply struct {
code int
body map[string]any
raw string
}
func (r reply) errType() string {
e, _ := r.body["error"].(map[string]any)
s, _ := e["type"].(string)
return s
}
func serve(e *echo.Echo, method, path, body string, headers ...func(*http.Request)) reply {
GinkgoHelper()
r := httptest.NewRequest(method, path, bytes.NewBufferString(body))
r.Header.Set("Content-Type", "application/json")
for _, h := range headers {
h(r)
}
w := httptest.NewRecorder()
e.ServeHTTP(w, r)
out := reply{code: w.Code, body: map[string]any{}, raw: w.Body.String()}
_ = json.Unmarshal(w.Body.Bytes(), &out.body)
return out
}
func (h *host) send(method, path, body string, headers ...func(*http.Request)) reply {
GinkgoHelper()
return serve(h.web, method, path, body, headers...)
}
func (h *host) bearer(user string) func(*http.Request) {
return func(r *http.Request) { r.Header.Set("Authorization", "Bearer "+h.tokens[user]) }
}
// tab is what the host's own pages send: the user's browser session, a
// same-origin fetch and the identifier the action concerns in the header. In
// local-owner mode there is no session.
func (h *host) tab(user, id string) func(*http.Request) {
return func(r *http.Request) {
if !h.local {
r.AddCookie(&http.Cookie{Name: "session", Value: h.sessions[user]})
}
r.Header.Set("Sec-Fetch-Site", "same-origin")
r.Header.Set("Sec-Fetch-Mode", "cors")
r.Header.Set("Sec-Fetch-Dest", "empty")
r.Header.Set("X-LocalAI-Confirm", id)
}
}
// session is a plain same-origin session request with no confirmation header.
func (h *host) session(user string) func(*http.Request) {
return func(r *http.Request) {
r.AddCookie(&http.Cookie{Name: "session", Value: h.sessions[user]})
r.Header.Set("Sec-Fetch-Site", "same-origin")
r.Header.Set("Sec-Fetch-Mode", "cors")
r.Header.Set("Sec-Fetch-Dest", "empty")
}
}
// frontend is a second LocalAI instance that holds no addon state and
// forwards every resource call to the owner over the real carrier.
func (h *host) frontend() *echo.Echo {
GinkgoHelper()
carrier, e := gateway.NewCarrier(*h.front)
Expect(e).NotTo(HaveOccurred())
var db = h.app.AuthDB()
g := &gateway.Gateway{Forward: carrier, Revalidate: auth.AddonRevalidate(db, h.mode())}
web := echo.New()
web.Use(auth.Middleware(db, h.app.ApplicationConfig()))
routes.RegisterAddonRoutesFor(web, g, db, h.mode())
return web
}
// Typed calls of the management routes, as the person's own browser tab.
func (h *host) create(user, op, name string) reply {
GinkgoHelper()
body := fmt.Sprintf(`{"addonId":"tables","operationId":%q,"generation":1,"arguments":{"name":%q}}`, op, name)
return h.send("POST", "/api/addons/resources", body, h.tab(user, op))
}
func (h *host) list(user string) reply {
GinkgoHelper()
return h.send("GET", "/api/addons/resources?addonId=tables", "", h.bearer(user))
}
// resources returns the listed resources by ID.
func resourcesOf(r reply) map[string]map[string]any {
GinkgoHelper()
out := map[string]map[string]any{}
list, _ := r.body["resources"].([]any)
for _, x := range list {
m := x.(map[string]any)
out[m["id"].(string)] = m
}
return out
}
func (h *host) grant(user, res, target, role string) reply {
GinkgoHelper()
return h.send("PUT", "/api/addons/resources/"+res+"/grants/"+target, fmt.Sprintf(`{"role":%q}`, role), h.tab(user, res))
}
func (h *host) revoke(user, res, target string) reply {
GinkgoHelper()
return h.send("DELETE", "/api/addons/resources/"+res+"/grants/"+target, "", h.tab(user, res))
}
func (h *host) grants(user, res string) reply {
GinkgoHelper()
return h.send("GET", "/api/addons/resources/"+res+"/grants", "", h.tab(user, res))
}
func (h *host) rename(user, res, op, name string, rev map[string]any) reply {
GinkgoHelper()
b, _ := json.Marshal(map[string]any{"operationId": op, "generation": 1, "revision": rev, "name": name})
return h.send("POST", "/api/addons/resources/"+res+"/rename", string(b), h.tab(user, res))
}
// revision reads the revision the owner role is shown for a resource.
func (h *host) revision(user, res string) map[string]any {
GinkgoHelper()
got := resourcesOf(h.list(user))[res]
Expect(got).NotTo(BeNil())
rev, _ := got["revision"].(map[string]any)
Expect(rev).NotTo(BeNil())
return rev
}
// name reads the display name of a resource through the owner's gateway, as
// the person who may read it.
func (h *host) name(user, res string) (string, error) {
out, e := h.app.AddonGateway().Dispatch(context.Background(), h.actor(user), contract.Request{
AddonID: "tables", ResourceID: res, OperationID: "inspect-" + uuid.NewString()[:8], Generation: h.currentGeneration(), Action: "read",
Arguments: json.RawMessage(`{"tool":"databases-inspect"}`),
})
if e != nil {
return "", e
}
var v struct {
Name string `json:"name"`
}
Expect(json.Unmarshal(out.Payload, &v)).To(Succeed())
return v.Name, nil
}
// database creates a database for the user and returns its ID.
func (h *host) database(user, op, name string) string {
GinkgoHelper()
got := h.create(user, op, name)
Expect(got.code).To(Equal(200), got.raw)
id, _ := got.body["id"].(string)
Expect(id).NotTo(BeEmpty())
return id
}
func (h *host) dbFile(res string) string {
return filepath.Join(h.owner.Root, "data", "tables", "tables", "databases", res+".db")
}
func (h *host) currentGeneration() uint64 {
st, e := h.app.AddonManager().Status(context.Background(), "tables")
Expect(e).NotTo(HaveOccurred())
return st.Generation
}
+463
View File
@@ -0,0 +1,463 @@
// SPDX-License-Identifier: MIT
package recovery_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/http/auth"
addonsvc "github.com/mudler/LocalAI/core/services/addons"
"github.com/mudler/LocalAI/core/services/addons/catalog"
"github.com/mudler/LocalAI/core/services/addons/policy"
"github.com/mudler/LocalAI/core/services/addons/tools"
"github.com/mudler/LocalAI/pkg/addons/contract"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// admin sends one recovery mutation as the administrator's own browser tab.
func (h *host) admin(path, op string, body map[string]any) reply {
GinkgoHelper()
body["operationId"] = op
b, _ := json.Marshal(body)
return h.send("POST", path, string(b), h.tab("alice", op))
}
func (h *host) status(user string) reply {
GinkgoHelper()
return h.send("GET", "/api/addons/tables/recovery", "", h.session(user))
}
func (h *host) stopTables() {
GinkgoHelper()
st, e := h.app.AddonManager().Status(context.Background(), "tables")
Expect(e).NotTo(HaveOccurred())
_, e = h.app.AddonManager().Submit(context.Background(), h.actor("alice"), addonsvc.LifecycleCommand{OperationID: fmt.Sprintf("disable-%d", time.Now().UnixNano()), AddonID: "tables", Action: "disable", Generation: st.Generation})
Expect(e).NotTo(HaveOccurred())
Eventually(func() string {
list, _ := h.app.AddonManager().Apps(context.Background(), h.actor("alice"))
for _, a := range list {
if a.ID == "tables" {
return a.State
}
}
return "absent"
}, 20*time.Second, 100*time.Millisecond).Should(Equal("stopped"))
}
func (h *host) startTables() {
GinkgoHelper()
st, e := h.app.AddonManager().Status(context.Background(), "tables")
Expect(e).NotTo(HaveOccurred())
_, e = h.app.AddonManager().Submit(context.Background(), h.actor("alice"), addonsvc.LifecycleCommand{OperationID: fmt.Sprintf("enable-%d", time.Now().UnixNano()), AddonID: "tables", Action: "enable", Generation: st.Generation})
Expect(e).NotTo(HaveOccurred())
h.running()
}
func (h *host) generation() uint64 {
GinkgoHelper()
st, e := h.app.AddonManager().Status(context.Background(), "tables")
Expect(e).NotTo(HaveOccurred())
return st.Generation
}
func backups(r reply) []map[string]any {
GinkgoHelper()
var out []map[string]any
list, _ := r.body["backups"].([]any)
for _, x := range list {
out = append(out, x.(map[string]any))
}
return out
}
var _ = Describe("Add-on data recovery over the real owner", func() {
It("makes a backup only for an administrator's own browser session and lists it", func() {
h := ready(false)
id := h.database("bob", "db-1", "Sales")
Expect(id).NotTo(BeEmpty())
// A person with the feature is not an administrator.
Expect(h.send("POST", "/api/addons/tables/recovery/backup", `{"operationId":"b-1"}`, h.tab("bob", "b-1")).code).To(Equal(403))
Expect(h.status("bob").code).To(Equal(403))
// An administrator's API key is not a confirmation surface.
Expect(h.send("POST", "/api/addons/tables/recovery/backup", `{"operationId":"b-1"}`, h.bearer("alice"), func(r *http.Request) { r.Header.Set("X-LocalAI-Confirm", "b-1") }).code).To(Equal(403))
// The header must name the operation, and an operation is required.
Expect(h.send("POST", "/api/addons/tables/recovery/backup", `{"operationId":"b-1"}`, h.tab("alice", "other")).code).To(Equal(403))
Expect(h.send("POST", "/api/addons/tables/recovery/backup", `{}`, h.tab("alice", "")).code).To(Equal(400))
Expect(h.send("POST", "/api/addons/Tables/recovery/backup", `{"operationId":"b-1"}`, h.tab("alice", "b-1")).code).To(Equal(403))
got := h.admin("/api/addons/tables/recovery/backup", "b-1", map[string]any{})
Expect(got.code).To(Equal(200), got.raw)
backupID, _ := got.body["id"].(string)
Expect(backupID).To(HavePrefix("b"))
Expect(got.body["verified"]).To(BeTrue())
// The same operation returns the same backup and makes no second one.
again := h.admin("/api/addons/tables/recovery/backup", "b-1", map[string]any{})
Expect(again.code).To(Equal(200))
Expect(again.body["id"]).To(Equal(backupID))
list := backups(h.status("alice"))
Expect(list).To(HaveLen(1))
Expect(list[0]["verification"]).To(Equal("verified"))
// The add-on runs again and the database still answers.
h.running()
name, e := h.name("bob", id)
Expect(e).NotTo(HaveOccurred())
Expect(name).To(Equal("Sales"))
// The backup holds the database file and is private.
dir := filepath.Join(h.owner.Root, "backups", "tables", backupID)
Expect(filepath.Join(dir, "data", "tables", "databases", id+".db")).To(BeAnExistingFile())
info, e := os.Stat(dir)
Expect(e).NotTo(HaveOccurred())
Expect(info.Mode().Perm()).To(Equal(os.FileMode(0o700)))
})
It("restores with warnings and does not give back a share that was revoked after the backup", func() {
h := ready(false)
id := h.database("bob", "db-1", "Sales")
Expect(h.grant("bob", id, "carol", "editor").code).To(Equal(200))
Expect(h.grant("bob", id, "dave", "viewer").code).To(Equal(200))
got := h.admin("/api/addons/tables/recovery/backup", "b-1", map[string]any{})
Expect(got.code).To(Equal(200), got.raw)
backupID := got.body["id"].(string)
// After the backup: carol loses access and the database is renamed.
Expect(h.revoke("bob", id, "carol").code).To(Equal(200))
rc := h.rename("bob", id, "rename-1", "Renamed", h.revision("bob", id))
Expect(rc.code).To(Equal(200), rc.raw)
// A restore needs the add-on stopped, the typed ID and a verified backup.
running := h.admin("/api/addons/tables/recovery/restore", "r-1", map[string]any{"backupId": backupID, "confirm": "tables"})
Expect(running.code).To(Equal(409))
Expect(running.errType()).To(Equal("addon_running"))
h.stopTables()
wrong := h.admin("/api/addons/tables/recovery/restore", "r-2", map[string]any{"backupId": backupID, "confirm": "Tables"})
Expect(wrong.code).To(Equal(400))
Expect(wrong.errType()).To(Equal("confirmation_mismatch"))
missing := h.admin("/api/addons/tables/recovery/restore", "r-3", map[string]any{"backupId": "b-nope", "confirm": "tables"})
Expect(missing.code).To(Equal(404))
Expect(missing.errType()).To(Equal("backup_not_found"))
done := h.admin("/api/addons/tables/recovery/restore", "r-4", map[string]any{"backupId": backupID, "confirm": "tables"})
Expect(done.code).To(Equal(200), done.raw)
warnings, _ := done.body["warnings"].([]any)
Expect(warnings).NotTo(BeEmpty())
Expect(fmt.Sprint(warnings)).To(ContainSubstring("Changes made after that time are not in it"))
prev, _ := done.body["previousCopy"].(string)
Expect(prev).To(HavePrefix("previous-"))
Expect(filepath.Join(h.owner.Root, "recovery", "tables", prev)).To(BeADirectory())
// The same operation does not restore again.
repeat := h.admin("/api/addons/tables/recovery/restore", "r-4", map[string]any{"backupId": backupID, "confirm": "tables"})
Expect(repeat.code).To(Equal(200))
Expect(repeat.body["at"]).To(Equal(done.body["at"]))
h.startTables()
// The data is as it was at the backup: the old name is back. Access
// is as it is now: carol stays out, dave is a viewer.
name, e := h.name("bob", id)
Expect(e).NotTo(HaveOccurred())
Expect(name).To(Equal("Sales"))
_, e = h.name("carol", id)
Expect(e).To(HaveOccurred())
name, e = h.name("dave", id)
Expect(e).NotTo(HaveOccurred())
Expect(name).To(Equal("Sales"))
Expect(h.grants("bob", id).code).To(Equal(200))
g := h.grants("bob", id)
Expect(g.raw).NotTo(ContainSubstring("carol"))
Expect(g.raw).To(ContainSubstring("dave"))
pol := done.body["policy"].(map[string]any)
Expect(pol["grantsDropped"]).To(BeNumerically("==", 1))
Expect(pol["revoked"]).To(BeNumerically("==", 1))
st := h.status("alice")
Expect(st.body["state"]).To(Equal("ready"))
Expect(st.body["lastRestore"]).NotTo(BeNil())
})
It("restores only a backup of the add-on in the route and touches nothing otherwise", func() {
h := ready(false)
id := h.database("bob", "db-1", "Sales")
got := h.admin("/api/addons/tables/recovery/backup", "b-1", map[string]any{})
Expect(got.code).To(Equal(200), got.raw)
backupID := got.body["id"].(string)
rc := h.rename("bob", id, "rename-1", "Renamed", h.revision("bob", id))
Expect(rc.code).To(Equal(200), rc.raw)
h.stopTables()
// The typed ID and the route name another add-on than the backup's.
for _, path := range []string{"verify", "delete-backup"} {
r := h.admin("/api/addons/notes/recovery/"+path, "x-"+path, map[string]any{"backupId": backupID})
Expect(r.code).To(Equal(404), path+": "+r.raw)
}
bad := h.admin("/api/addons/notes/recovery/restore", "r-x", map[string]any{"backupId": backupID, "confirm": "notes"})
Expect(bad.code).To(Equal(400), bad.raw)
Expect(bad.errType()).To(Equal("invalid_request"))
bad = h.admin("/api/addons/notes/recovery/restore", "r-y", map[string]any{"backupId": backupID, "confirm": "tables"})
Expect(bad.code).To(Equal(400), bad.raw)
Expect(filepath.Join(h.owner.Root, "backups", "tables", backupID)).To(BeADirectory())
entries, _ := os.ReadDir(filepath.Join(h.owner.Root, "recovery", "tables"))
for _, en := range entries {
Expect(en.Name()).NotTo(HavePrefix("previous-"))
}
h.startTables()
// Nothing was swapped in: the rename made after the backup stands.
name, e := h.name("bob", id)
Expect(e).NotTo(HaveOccurred())
Expect(name).To(Equal("Renamed"))
})
It("gives a forwarding frontend the same typed answers as the owner", func() {
h := ready(false)
h.database("bob", "db-1", "Sales")
front := h.frontend()
backup := func(web *echo.Echo, op string) reply {
GinkgoHelper()
b, _ := json.Marshal(map[string]any{"operationId": op})
return serve(web, "POST", "/api/addons/tables/recovery/backup", string(b), h.tab("alice", op))
}
ownerGot := backup(h.web, "p-1")
frontGot := backup(front, "p-2")
Expect(ownerGot.code).To(Equal(200), ownerGot.raw)
Expect(frontGot.code).To(Equal(200), frontGot.raw)
// A restore of a running add-on is refused the same way from both.
ask := func(web *echo.Echo, op string) reply {
b, _ := json.Marshal(map[string]any{"operationId": op, "backupId": ownerGot.body["id"], "confirm": "tables"})
return serve(web, "POST", "/api/addons/tables/recovery/restore", string(b), h.tab("alice", op))
}
a, b := ask(h.web, "q-1"), ask(front, "q-2")
Expect(a.code).To(Equal(409))
Expect(b.code).To(Equal(a.code))
Expect(b.errType()).To(Equal(a.errType()))
// A person who is not an administrator is denied the same way.
bob := serve(front, "GET", "/api/addons/tables/recovery", "", h.session("bob"))
Expect(bob.code).To(Equal(403))
list := serve(front, "GET", "/api/addons/tables/recovery", "", h.session("alice"))
Expect(list.code).To(Equal(200), list.raw)
Expect(backups(list)).To(HaveLen(2))
})
It("purges only with the typed ID, a stopped add-on and an explicit scope, and touches nothing else", func() {
h := ready(false)
id := h.database("bob", "db-1", "Sales")
backupID := h.admin("/api/addons/tables/recovery/backup", "b-1", map[string]any{}).body["id"].(string)
// Running: refused.
got := h.admin("/api/addons/tables/purge", "x-1", map[string]any{"scope": map[string]any{"data": true}, "confirm": "tables"})
Expect(got.code).To(Equal(409))
Expect(got.errType()).To(Equal("addon_running"))
h.stopTables()
Expect(h.admin("/api/addons/tables/purge", "x-2", map[string]any{"scope": map[string]any{"data": true}, "confirm": "nope"}).errType()).To(Equal("confirmation_mismatch"))
Expect(h.admin("/api/addons/tables/purge", "x-3", map[string]any{"scope": map[string]any{}, "confirm": "tables"}).errType()).To(Equal("invalid_request"))
// A person with the feature cannot, and neither can a database
// deletion or an uninstall do it: the data is still there.
Expect(h.send("POST", "/api/addons/tables/purge", `{"operationId":"x-4","scope":{"data":true},"confirm":"tables"}`, h.tab("bob", "x-4")).code).To(Equal(403))
Expect(h.dbFile(id)).To(BeAnExistingFile())
done := h.admin("/api/addons/tables/purge", "x-5", map[string]any{"scope": map[string]any{"data": true}, "confirm": "tables"})
Expect(done.code).To(Equal(200), done.raw)
Expect(h.dbFile(id)).NotTo(BeAnExistingFile())
// The backups were not in the scope.
Expect(backups(h.status("alice"))).To(HaveLen(1))
Expect(filepath.Join(h.owner.Root, "backups", "tables", backupID)).To(BeADirectory())
// The owner no longer lists the database for its person.
Expect(resourcesOf(h.list("bob"))).To(BeEmpty())
bk := h.admin("/api/addons/tables/purge", "x-6", map[string]any{"scope": map[string]any{"backups": true}, "confirm": "tables"})
Expect(bk.code).To(Equal(200), bk.raw)
Expect(backups(h.status("alice"))).To(BeEmpty())
})
It("is never reachable by a model: no tool, no declared name and no route for an API key", func() {
h := ready(false)
h.database("bob", "db-1", "Sales")
adapter := tools.NewToolAdapter(h.app.AddonGateway())
defs, e := adapter.Discover(context.Background(), h.actor("alice"))
Expect(e).NotTo(HaveOccurred())
for _, d := range defs {
Expect(d.Name).NotTo(ContainSubstring("recovery"))
Expect(d.Name).NotTo(ContainSubstring("purge"))
Expect(d.Name).NotTo(ContainSubstring("backup"))
Expect(d.Name).NotTo(ContainSubstring("restore"))
}
for _, name := range []string{"addon__tables__recovery-backup", "addon__tables__purge", "addon__tables__recovery-restore", "addon__tables__recovery-status"} {
_, e := adapter.Execute(context.Background(), h.actor("alice"), name, json.RawMessage(`{}`))
Expect(e).To(HaveOccurred(), name)
}
// The typed dispatch of a key names the action as an administrator
// action, but a key never reaches the human-surface mutations.
for _, path := range []string{"/api/addons/tables/recovery/backup", "/api/addons/tables/recovery/restore", "/api/addons/tables/purge", "/api/addons/tables/recovery/prune"} {
got := h.send("POST", path, `{"operationId":"k-1"}`, h.bearer("alice"))
Expect(got.code).To(Equal(403), path)
}
// Nothing was made by those calls.
Expect(backups(h.status("alice"))).To(BeEmpty())
var manifest struct {
Capabilities struct {
Tools []string `json:"tools"`
AdminTools []string `json:"adminTools"`
} `json:"capabilities"`
}
raw, e := os.ReadFile("../../../addons/tables/manifest.json")
Expect(e).NotTo(HaveOccurred())
Expect(json.Unmarshal(raw, &manifest)).To(Succeed())
for _, t := range append(manifest.Capabilities.Tools, manifest.Capabilities.AdminTools...) {
Expect(t).NotTo(MatchRegexp(`recovery|purge|backup|restore`))
}
})
It("upgrades an add-on with data through a backup and a migration and keeps its databases", func() {
h := ready(false)
id := h.database("bob", "db-1", "Sales")
Expect(h.grant("bob", id, "carol", "viewer").code).To(Equal(200))
before := h.generation()
_, e := h.app.AddonManager().Submit(context.Background(), h.actor("alice"), addonsvc.LifecycleCommand{OperationID: "upgrade-1", AddonID: "tables", Action: "upgrade", SourceID: "local", Version: "0.1.1"})
Expect(e).NotTo(HaveOccurred())
Expect(h.generation()).To(Equal(before + 1))
h.running()
// The data is intact and the shares are as they were.
name, e := h.name("carol", id)
Expect(e).NotTo(HaveOccurred())
Expect(name).To(Equal("Sales"))
// The add-on migrated its own data at start and said so.
raw, e := os.ReadFile(filepath.Join(h.owner.Root, "data", "tables", "migration.json"))
Expect(e).NotTo(HaveOccurred())
Expect(string(raw)).To(ContainSubstring(`"complete"`))
// The backup taken before is the recovery copy and is kept.
list := backups(h.status("alice"))
Expect(list).To(HaveLen(1))
Expect(list[0]["reason"]).To(Equal("upgrade"))
Expect(list[0]["pinned"]).To(BeTrue())
Expect(list[0]["version"]).To(Equal("0.1.0"))
st := h.status("alice")
Expect(st.body["state"]).To(Equal("ready"))
// The sole recovery copy cannot be deleted.
del := h.admin("/api/addons/tables/recovery/delete-backup", "d-1", map[string]any{"backupId": list[0]["id"]})
Expect(del.code).To(Equal(409))
Expect(del.errType()).To(Equal("sole_recovery_copy"))
})
It("undoes an upgrade whose package does not start and runs the old generation on its own data", func() {
h := ready(false)
id := h.database("bob", "db-1", "Sales")
before := h.generation()
_, e := h.app.AddonManager().Submit(context.Background(), h.actor("alice"), addonsvc.LifecycleCommand{OperationID: "upgrade-bad", AddonID: "tables", Action: "upgrade", SourceID: "local", Version: "0.2.0"})
Expect(e).To(HaveOccurred())
Expect(h.generation()).To(Equal(before))
h.running()
name, e := h.name("bob", id)
Expect(e).NotTo(HaveOccurred())
Expect(name).To(Equal("Sales"))
st := h.status("alice")
Expect(st.body["state"]).To(Equal("ready"))
// The backup that made it safe is still there.
Expect(backups(st)).To(HaveLen(1))
// A new database can be made: nothing is blocked.
Expect(h.create("bob", "db-2", "More").code).To(Equal(200))
})
It("hands retained data only to the same trusted source on a reinstall", func() {
h := ready(false)
id := h.database("bob", "db-1", "Sales")
ctx := context.Background()
st, e := h.app.AddonManager().Status(ctx, "tables")
Expect(e).NotTo(HaveOccurred())
_, e = h.app.AddonManager().Submit(ctx, h.actor("alice"), addonsvc.LifecycleCommand{OperationID: "uninstall-1", AddonID: "tables", Action: "uninstall"})
Expect(e).NotTo(HaveOccurred())
_ = st
// The same library under another source ID is another trusted
// identity: the data stays where it is.
lib := h.libraryURL
Expect(h.app.AddonSources().Save(ctx, h.actor("alice"), catalog.Source{ID: "mirror", Name: "Mirror", URL: lib, Enabled: true, AllowLoopbackHTTP: true})).To(Succeed())
_, e = h.app.AddonSources().Refresh(ctx, "mirror")
Expect(e).NotTo(HaveOccurred())
_, e = h.app.AddonManager().Submit(ctx, h.actor("alice"), addonsvc.LifecycleCommand{OperationID: "install-other", AddonID: "tables", Action: "install", SourceID: "mirror", Version: "0.1.0"})
Expect(e).To(HaveOccurred())
Expect(h.dbFile(id)).To(BeAnExistingFile())
// The original source is accepted and the data is served again.
_, e = h.app.AddonManager().Submit(ctx, h.actor("alice"), addonsvc.LifecycleCommand{OperationID: "install-same", AddonID: "tables", Action: "install", SourceID: "local", Version: "0.1.0"})
Expect(e).NotTo(HaveOccurred())
sts, e := h.app.AddonManager().Status(ctx, "tables")
Expect(e).NotTo(HaveOccurred())
_, e = h.app.AddonManager().Submit(ctx, h.actor("alice"), addonsvc.LifecycleCommand{OperationID: "enable-again", AddonID: "tables", Action: "enable", Generation: sts.Generation})
Expect(e).NotTo(HaveOccurred())
h.running()
name, e := h.name("bob", id)
Expect(e).NotTo(HaveOccurred())
Expect(name).To(Equal("Sales"))
})
It("documents every route and every emitted field in the Swagger spec, the instructions and the feature registry", func() {
spec, err := os.ReadFile("../../../swagger/swagger.json")
Expect(err).NotTo(HaveOccurred())
var doc struct {
Paths map[string]map[string]json.RawMessage `json:"paths"`
Definitions map[string]struct {
Properties map[string]any `json:"properties"`
} `json:"definitions"`
}
Expect(json.Unmarshal(spec, &doc)).To(Succeed())
for path, method := range map[string]string{
"/api/addons/{id}/recovery": "get",
"/api/addons/{id}/recovery/backup": "post",
"/api/addons/{id}/recovery/verify": "post",
"/api/addons/{id}/recovery/restore": "post",
"/api/addons/{id}/recovery/delete-backup": "post",
"/api/addons/{id}/recovery/prune": "post",
"/api/addons/{id}/purge": "post",
} {
Expect(doc.Paths).To(HaveKey(path))
text := string(doc.Paths[path][method])
Expect(text).To(ContainSubstring("MCP"), path)
Expect(text).To(ContainSubstring(`"Addons"`), path)
}
h := ready(false)
h.database("bob", "db-1", "Sales")
backup := h.admin("/api/addons/tables/recovery/backup", "b-1", map[string]any{})
Expect(backup.code).To(Equal(200))
// Emitted bodies and documented definitions list the same fields.
check := func(name string, got map[string]any) {
GinkgoHelper()
def, ok := doc.Definitions[name]
Expect(ok).To(BeTrue(), name)
for field := range got {
Expect(def.Properties).To(HaveKey(field), name+"."+field)
}
}
check("recovery.BackupReceipt", backup.body)
st := h.status("alice")
check("recovery.Status", st.body)
for _, b := range backups(st) {
check("recovery.BackupInfo", b)
}
h.stopTables()
rec := h.admin("/api/addons/tables/recovery/restore", "r-1", map[string]any{"backupId": backup.body["id"], "confirm": "tables"})
Expect(rec.code).To(Equal(200), rec.raw)
check("recovery.RestoreReceipt", rec.body)
check("recovery.RestoreReport", rec.body["policy"].(map[string]any))
purge := h.admin("/api/addons/tables/purge", "p-1", map[string]any{"scope": map[string]any{"recoveryCopies": true}, "confirm": "tables"})
Expect(purge.code).To(Equal(200), purge.raw)
check("recovery.PurgeReceipt", purge.body)
check("recovery.PurgeScope", purge.body["scope"].(map[string]any))
registered := map[string]bool{}
for _, entry := range auth.RouteFeatureRegistry {
if entry.Feature == auth.FeatureAddons {
registered[entry.Method+" "+entry.Pattern] = true
}
}
for _, key := range []string{"GET /api/addons/:id/recovery", "POST /api/addons/:id/recovery/backup", "POST /api/addons/:id/recovery/verify", "POST /api/addons/:id/recovery/restore", "POST /api/addons/:id/recovery/delete-backup", "POST /api/addons/:id/recovery/prune", "POST /api/addons/:id/purge"} {
Expect(registered).To(HaveKey(key))
}
ins := h.send("GET", "/api/instructions/addons", "", h.bearer("alice"))
Expect(ins.code).To(Equal(200))
for _, want := range []string{"/api/addons/{id}/recovery", "needs-recovery", "purge", "VACUUM"} {
if want == "VACUUM" {
continue
}
Expect(ins.raw).To(ContainSubstring(want), want)
}
docs, err := os.ReadFile("../../../docs/content/features/addons.md")
Expect(err).NotTo(HaveOccurred())
for _, want := range []string{"needs-recovery", "sole recovery copy", "retained data", "ownership mapping", "migration.json"} {
Expect(strings.ToLower(string(docs))).To(ContainSubstring(strings.ToLower(want)), want)
}
_ = policy.ModeNamedUser
_ = contract.Actor{}
})
})
+393
View File
@@ -0,0 +1,393 @@
// SPDX-License-Identifier: MIT
package tables_test
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"fmt"
"io/fs"
"math/big"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
goruntime "runtime"
"time"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/config"
httpapi "github.com/mudler/LocalAI/core/http"
"github.com/mudler/LocalAI/core/http/auth"
addonsvc "github.com/mudler/LocalAI/core/services/addons"
"github.com/mudler/LocalAI/core/services/addons/catalog"
"github.com/mudler/LocalAI/core/services/addons/gateway"
"github.com/mudler/LocalAI/core/services/addons/policy"
"github.com/mudler/LocalAI/pkg/addons/contract"
"github.com/mudler/LocalAI/pkg/system"
"github.com/mudler/LocalAI/tests/addons/foundation/cluster"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
const appSecret = "tables-application-secret"
// ownerCarrier builds the smallest real transport identity of an owner: one
// pinned certificate, one signing key and a loopback listen address.
func ownerCarrier() *gateway.CarrierConfig {
GinkgoHelper()
pub, key, e := ed25519.GenerateKey(rand.Reader)
Expect(e).NotTo(HaveOccurred())
ca := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test CA"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(24 * time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign}
der, e := x509.CreateCertificate(rand.Reader, ca, ca, pub, key)
Expect(e).NotTo(HaveOccurred())
ca, e = x509.ParseCertificate(der)
Expect(e).NotTo(HaveOccurred())
pool := x509.NewCertPool()
pool.AddCert(ca)
lp, lk, e := ed25519.GenerateKey(rand.Reader)
Expect(e).NotTo(HaveOccurred())
leaf := &x509.Certificate{SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: "owner"}, NotBefore: ca.NotBefore, NotAfter: ca.NotAfter, IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}}
d, e := x509.CreateCertificate(rand.Reader, leaf, ca, lp, key)
Expect(e).NotTo(HaveOccurred())
kd, e := x509.MarshalPKCS8PrivateKey(lk)
Expect(e).NotTo(HaveOccurred())
pair, e := tls.X509KeyPair(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: d}), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: kd}))
Expect(e).NotTo(HaveOccurred())
sp, sk, e := ed25519.GenerateKey(rand.Reader)
Expect(e).NotTo(HaveOccurred())
return &gateway.CarrierConfig{InstanceID: "owner", OwnerID: "owner", OwnerAddress: "https://owner.example", Certificate: pair, Roots: pool,
Pins: map[string][32]byte{"owner": sha256.Sum256(d)}, SigningKey: sk, VerificationKeys: map[string]ed25519.PublicKey{"owner": sp}}
}
// host is one owner process-in-process: the real application bootstrap over
// real account storage, the real supervisor and the real Tables executable.
type host struct {
dir string
owner config.AddonConfig
app *application.Application
web *echo.Echo
cancel context.CancelFunc
// Credentials of the accounts, created once and kept across restarts.
tokens map[string]string
sessions map[string]string
keys map[string]string
}
func newHost() *host {
GinkgoHelper()
dir := GinkgoT().TempDir()
h := &host{dir: dir, tokens: map[string]string{}, sessions: map[string]string{}, keys: map[string]string{}}
root := filepath.Join(dir, "addons")
// Package directories are read-only; restore permissions before removal.
DeferCleanup(func() {
_ = filepath.WalkDir(dir, func(p string, d fs.DirEntry, e error) error {
if e == nil && d.IsDir() {
_ = os.Chmod(p, 0o700)
}
return nil
})
})
h.owner = config.AddonConfig{
Enabled: true, InstanceID: "owner", OwnerID: "owner", OwnerAddress: "https://owner.example", StorageID: "local",
Root: root, ListenAddress: "127.0.0.1:0", Transport: ownerCarrier(),
Host: contract.HostCompatibility{Host: 1, API: 1, Protocol: 1, SDK: 1, DataFormat: 1, OS: goruntime.GOOS, Arch: goruntime.GOARCH},
}
return h
}
// start runs the application bootstrap, which reconciles reservations and
// recovers proposals before it serves.
func (h *host) start() {
GinkgoHelper()
models, backends := filepath.Join(h.dir, "models"), filepath.Join(h.dir, "backends")
Expect(os.MkdirAll(models, 0o750)).To(Succeed())
Expect(os.MkdirAll(backends, 0o750)).To(Succeed())
state, e := system.GetSystemState(system.WithBackendPath(backends), system.WithModelPath(models))
Expect(e).NotTo(HaveOccurred())
ctx, cancel := context.WithCancel(context.Background())
h.cancel = cancel
app, e := application.New(
config.WithContext(ctx), config.WithSystemState(state), config.WithDataPath(h.dir),
config.WithAuthEnabled(true), config.WithAuthDatabaseURL(filepath.Join(h.dir, "auth.db")), config.WithAuthAPIKeyHMACSecret(appSecret),
config.WithAddons(h.owner),
)
Expect(e).NotTo(HaveOccurred())
h.app = app
h.web, e = httpapi.API(app)
Expect(e).NotTo(HaveOccurred())
}
// stop ends the application as a restart does and waits until the owner
// released its durable state.
func (h *host) stop() {
GinkgoHelper()
if h.cancel == nil {
return
}
Expect(h.app.Shutdown()).To(Succeed())
h.cancel()
h.cancel = nil
Eventually(func() error {
s, e := policy.OpenStore(filepath.Join(h.owner.Root, "policy"), policy.ModeNamedUser)
if e == nil {
_ = s.Close()
}
return e
}, 10*time.Second, 50*time.Millisecond).Should(Succeed())
}
func (h *host) actor(user string) contract.Actor {
return contract.Actor{UserID: user, CredentialID: "key:" + h.keys[user], Mode: auth.AddonModeNamedUser}
}
// seed creates the accounts, installs the real Tables package from a local
// catalog and waits until it runs.
func (h *host) seed(lib *cluster.Library) {
GinkgoHelper()
ctx := context.Background()
db := h.app.AuthDB()
Expect(db).NotTo(BeNil())
for _, u := range []struct {
id, role string
feature bool
}{{"alice", auth.RoleAdmin, false}, {"bob", auth.RoleUser, true}} {
Expect(db.Create(&auth.User{ID: u.id, Email: u.id + "@tables.test", Name: u.id, Provider: auth.ProviderLocal, Role: u.role, Status: auth.StatusActive}).Error).To(Succeed())
if u.feature {
Expect(db.Create(&auth.UserPermission{ID: uuid.NewString(), UserID: u.id, Permissions: auth.PermissionMap{auth.FeatureAddons: true}}).Error).To(Succeed())
}
token, key, e := auth.CreateAPIKey(db, u.id, "agent", u.role, appSecret, nil)
Expect(e).NotTo(HaveOccurred())
session, e := auth.CreateSession(db, u.id, appSecret)
Expect(e).NotTo(HaveOccurred())
h.tokens[u.id], h.sessions[u.id], h.keys[u.id] = token, session, key.ID
}
admin := h.actor("alice")
Expect(h.app.AddonSources().Save(ctx, admin, catalog.Source{ID: "local", Name: "Local", URL: lib.URL(), Enabled: true, AllowLoopbackHTTP: true})).To(Succeed())
_, e := h.app.AddonSources().Refresh(ctx, "local")
Expect(e).NotTo(HaveOccurred())
m := h.app.AddonManager()
_, e = m.Submit(ctx, admin, addonsvc.LifecycleCommand{OperationID: "install-tables", AddonID: "tables", Action: "install", SourceID: "local", Version: "0.1.0"})
Expect(e).NotTo(HaveOccurred())
_, e = m.Submit(ctx, admin, addonsvc.LifecycleCommand{OperationID: "enable-tables", AddonID: "tables", Action: "enable", Generation: 1})
Expect(e).NotTo(HaveOccurred())
h.running()
}
// running waits until the Tables process is observed running.
func (h *host) running() {
GinkgoHelper()
Eventually(func() string {
list, e := h.app.AddonManager().Apps(context.Background(), h.actor("alice"))
if e != nil {
return e.Error()
}
for _, a := range list {
if a.ID == "tables" {
return a.State
}
}
return "absent"
}, 30*time.Second, 100*time.Millisecond).Should(Equal("running"))
}
type webReply struct {
code int
body map[string]any
}
func (h *host) send(method, path, body string, headers func(*http.Request)) webReply {
GinkgoHelper()
r := httptest.NewRequest(method, path, bytes.NewBufferString(body))
r.Header.Set("Content-Type", "application/json")
if headers != nil {
headers(r)
}
w := httptest.NewRecorder()
h.web.ServeHTTP(w, r)
out := webReply{code: w.Code, body: map[string]any{}}
_ = json.Unmarshal(w.Body.Bytes(), &out.body)
return out
}
func (h *host) bearer(user string) func(*http.Request) {
return func(r *http.Request) { r.Header.Set("Authorization", "Bearer "+h.tokens[user]) }
}
// humanTab is what the host confirmation surface sends: the user's own browser
// session, a same-origin fetch and the proposal named in the header.
func (h *host) humanTab(user, id string) func(*http.Request) {
return func(r *http.Request) {
r.AddCookie(&http.Cookie{Name: "session", Value: h.sessions[user]})
r.Header.Set("Sec-Fetch-Site", "same-origin")
r.Header.Set("Sec-Fetch-Mode", "cors")
r.Header.Set("Sec-Fetch-Dest", "empty")
r.Header.Set("X-LocalAI-Confirm", id)
}
}
func (h *host) chatCreate(user, op, name string) webReply {
GinkgoHelper()
body := fmt.Sprintf(`{"addonId":"tables","operationId":%q,"generation":1,"action":"create","arguments":{"tool":"databases-create","name":%q}}`, op, name)
return h.send("POST", "/api/addons/proposals", body, h.bearer(user))
}
func (h *host) confirm(user, id, digest string) webReply {
GinkgoHelper()
return h.send("POST", "/api/addons/proposals/"+id+"/confirm", fmt.Sprintf(`{"digest":%q}`, digest), h.humanTab(user, id))
}
// databaseName reads the name of a database through the owner's gateway, as
// the person who may read it.
func (h *host) databaseName(user, resource string) (string, error) {
res, e := h.app.AddonGateway().Dispatch(context.Background(), h.actor(user), contract.Request{
AddonID: "tables", ResourceID: resource, OperationID: "inspect-" + uuid.NewString()[:8], Generation: 1, Action: "read",
Arguments: json.RawMessage(`{"tool":"databases-inspect"}`),
})
if e != nil {
return "", e
}
var out struct {
Name string `json:"name"`
}
Expect(json.Unmarshal(res.Payload, &out)).To(Succeed())
return out.Name, nil
}
func (h *host) library() *cluster.Library {
GinkgoHelper()
lib, e := cluster.NewLibrary(binary)
Expect(e).NotTo(HaveOccurred())
DeferCleanup(lib.Close)
raw, e := os.ReadFile("../../../addons/tables/manifest.json")
Expect(e).NotTo(HaveOccurred())
var m struct {
Capabilities map[string]any `json:"capabilities"`
}
Expect(json.Unmarshal(raw, &m)).To(Succeed())
// These specs run the tools, not the page: the package carries no UI
// files, so the declaration of them is left out. The UI suite installs
// the whole package.
delete(m.Capabilities, "ui")
exe, e := os.ReadFile(binary)
Expect(e).NotTo(HaveOccurred())
Expect(lib.Add(cluster.Package{ID: "tables", Version: "0.1.0", Capabilities: m.Capabilities, Executable: "bin/tables", Binary: exe}, GinkgoT().TempDir())).To(Succeed())
return lib
}
func accessibleTo(h *host, user string) []string {
GinkgoHelper()
keys, e := h.app.AddonPolicy().Accessible(context.Background(), h.actor(user), "tables")
Expect(e).NotTo(HaveOccurred())
var ids []string
for _, k := range keys {
ids = append(ids, k.ResourceID)
}
return ids
}
var _ = Describe("Tables in the application bootstrap", Label("application"), func() {
It("creates a database from a chat proposal that only the proposer's session confirms", func() {
h := newHost()
lib := h.library()
h.start()
DeferCleanup(h.stop)
h.seed(lib)
p := h.chatCreate("alice", "chat-create-1", "Sales")
Expect(p.code).To(Equal(200), "%v", p.body)
Expect(p.body["state"]).To(Equal("pending"))
Expect(p.body["action"]).To(Equal("create"))
id, digest, resource := p.body["id"].(string), p.body["digest"].(string), p.body["resourceId"].(string)
Expect(resource).NotTo(BeEmpty())
// The reservation is reachable by nobody, not even by its proposer.
Expect(accessibleTo(h, "alice")).To(BeEmpty())
_, e := h.databaseName("alice", resource)
Expect(e).To(HaveOccurred())
// An API key and another user's session cannot approve it.
Expect(h.send("POST", "/api/addons/proposals/"+id+"/confirm", fmt.Sprintf(`{"digest":%q}`, digest), h.bearer("alice")).code).To(Equal(403))
Expect(h.confirm("bob", id, digest).code).To(Equal(403))
Expect(accessibleTo(h, "alice")).To(BeEmpty())
done := h.confirm("alice", id, digest)
Expect(done.code).To(Equal(200), "%v", done.body)
Expect(done.body["state"]).To(Equal("applied"))
Expect(accessibleTo(h, "alice")).To(Equal([]string{resource}))
Expect(accessibleTo(h, "bob")).To(BeEmpty())
name, e := h.databaseName("alice", resource)
Expect(e).NotTo(HaveOccurred())
Expect(name).To(Equal("Sales"))
_, e = h.databaseName("bob", resource)
Expect(e).To(HaveOccurred())
// A withdrawn proposal leaves nothing behind.
q := h.chatCreate("alice", "chat-create-2", "Scratch")
Expect(q.code).To(Equal(200))
Expect(h.send("POST", "/api/addons/proposals/chat-create-2/cancel", "", h.bearer("alice")).code).To(Equal(200))
pending, e := h.app.AddonPolicy().Pending()
Expect(e).NotTo(HaveOccurred())
Expect(pending).To(BeEmpty())
})
It("settles reservations at bootstrap by asking the real addon and keeps those of pending proposals", func() {
h := newHost()
lib := h.library()
h.start()
DeferCleanup(h.stop)
h.seed(lib)
ctx := context.Background()
alice := h.actor("alice")
p := h.app.AddonPolicy()
m := h.app.AddonManager()
// A crash after the addon created the database and before the commit.
made, e := p.Register(ctx, alice, "tables")
Expect(e).NotTo(HaveOccurred())
create := policy.RuntimeCreate(m, m, contract.DefaultLimits(), alice, json.RawMessage(`{"tool":"databases-create","name":"Recovered"}`))
Expect(create(ctx, made)).To(Succeed())
// A crash after the reservation and before any database.
lost, e := p.Register(ctx, alice, "tables")
Expect(e).NotTo(HaveOccurred())
// A chat proposal that nobody confirmed yet.
live := h.chatCreate("alice", "chat-create-live", "Later")
Expect(live.code).To(Equal(200), "%v", live.body)
held := live.body["resourceId"].(string)
before, e := p.Pending()
Expect(e).NotTo(HaveOccurred())
Expect(before).To(ConsistOf(made.ResourceID, lost.ResourceID, held))
h.stop()
h.start()
h.running()
p = h.app.AddonPolicy()
// The real addon said the database exists: committed for its owner.
Expect(accessibleTo(h, "alice")).To(Equal([]string{made.ResourceID}))
name, e := h.databaseName("alice", made.ResourceID)
Expect(e).NotTo(HaveOccurred())
Expect(name).To(Equal("Recovered"))
// It said nothing exists: dropped. The live proposal keeps its own.
pending, e := p.Pending()
Expect(e).NotTo(HaveOccurred())
Expect(pending).To(Equal([]string{held}))
// The proposal is still confirmable after the restart.
got := h.send("GET", "/api/addons/proposals/chat-create-live", "", h.bearer("alice"))
Expect(got.code).To(Equal(200), "%v", got.body)
Expect(got.body["state"]).To(Equal("pending"))
done := h.confirm("alice", "chat-create-live", got.body["digest"].(string))
Expect(done.code).To(Equal(200), "%v", done.body)
Expect(accessibleTo(h, "alice")).To(ConsistOf(made.ResourceID, held))
name, e = h.databaseName("alice", held)
Expect(e).NotTo(HaveOccurred())
Expect(name).To(Equal("Later"))
})
})
+270
View File
@@ -0,0 +1,270 @@
// SPDX-License-Identifier: MIT
package tables_test
import (
"bytes"
"context"
"io"
"net/http"
"os"
"path/filepath"
"github.com/mudler/LocalAI/core/services/addons/policy"
"github.com/mudler/LocalAI/pkg/addons/contract"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Tables databases under the real owner", func() {
ctx := context.Background()
alice, bob := user("alice"), user("bob")
It("starts the real executable through the authenticated handshake and refuses unauthenticated callers", func() {
r := newRig()
inst := r.start(1)
regs, e := r.sup.Registry.Snapshot(ctx)
Expect(e).NotTo(HaveOccurred())
Expect(regs).To(HaveLen(1))
Expect(regs[0].AddonID).To(Equal("tables"))
Expect(regs[0].Capabilities.Tools).To(ConsistOf("resource-exists", "databases-create", "databases-rename", "databases-inspect", "tables-create", "tables-rename", "tables-drop", "columns-add", "columns-rename", "columns-drop", "rows-query", "rows-aggregate", "rows-export", "rows-insert", "rows-update", "rows-delete"))
// The package declares the native pages: the entry, the style sheet, the
// one page and the chat action that selects a database.
ui := regs[0].Capabilities.UI
Expect(ui).NotTo(BeNil())
Expect(ui.Entry).To(Equal("ui/tables.js"))
Expect(ui.CSS).To(Equal([]string{"ui/tables.css"}))
Expect(ui.Pages).To(Equal([]string{"databases"}))
Expect(ui.ChatActions).To(HaveLen(1))
Expect(ui.ChatActions[0].ID).To(Equal("select-database"))
// The data directory the owner created is private and holds the addon's data.
fi, e := os.Stat(filepath.Join(r.dataRoot, "tables"))
Expect(e).NotTo(HaveOccurred())
Expect(fi.Mode().Perm()).To(Equal(os.FileMode(0o700)))
// Anyone who reaches the loopback port without the credential is denied.
for _, h := range []map[string]string{
{},
{"X-LocalAI-Incarnation": regs[0].Incarnation},
{"X-LocalAI-Incarnation": "stale", "Authorization": "Bearer guess"},
} {
for _, path := range []string{"/health", "/read", "/apply", "/receipt"} {
req, _ := http.NewRequest("POST", regs[0].Endpoint+path, bytes.NewReader([]byte(`{}`)))
req.Header.Set("Content-Type", "application/json")
for k, v := range h {
req.Header.Set(k, v)
}
resp, e := http.DefaultClient.Do(req)
Expect(e).NotTo(HaveOccurred())
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized), path)
}
}
_ = inst
})
It("creates a database under the host-assigned ID and lists it only for people who may read it", func() {
r := newRig()
inst := r.start(1)
key, e := r.create(inst, alice, "Sales")
Expect(e).NotTo(HaveOccurred())
Expect(key.AddonID).To(Equal("tables"))
fi, e := os.Stat(r.dbFile(key.ResourceID))
Expect(e).NotTo(HaveOccurred())
Expect(fi.Mode().Perm()).To(Equal(os.FileMode(0o600)))
Expect(r.policy.Authorize(ctx, alice, key, policy.ActionSchema)).To(Succeed())
Expect(r.policy.Authorize(ctx, bob, key, policy.ActionRead)).To(MatchError(policy.ErrForbidden))
mine, e := r.policy.Accessible(ctx, alice, "tables")
Expect(e).NotTo(HaveOccurred())
Expect(mine).To(Equal([]policy.ResourceKey{key}))
theirs, e := r.policy.Accessible(ctx, bob, "tables")
Expect(e).NotTo(HaveOccurred())
Expect(theirs).To(BeEmpty())
res, e := r.read(inst, alice, key.ResourceID, "databases-inspect")
Expect(e).NotTo(HaveOccurred())
Expect(schemaName(res)).To(Equal("Sales"))
// Sharing is the host's decision and widens the listing.
Expect(r.policy.SetGrant(ctx, alice, key, "bob", policy.RoleViewer)).To(Succeed())
theirs, _ = r.policy.Accessible(ctx, bob, "tables")
Expect(theirs).To(Equal([]policy.ResourceKey{key}))
})
It("drops the reservation when the addon refuses the name, and leaves no database", func() {
r := newRig()
inst := r.start(1)
_, e := r.create(inst, alice, " ")
Expect(e).To(HaveOccurred())
Expect(e).To(MatchError(policy.ErrNotCreated))
got, _ := r.policy.Accessible(ctx, alice, "tables")
Expect(got).To(BeEmpty())
Expect(r.reservations()).To(BeEmpty())
Expect(filesIn(filepath.Join(r.dataRoot, "tables", "tables", "databases"))).To(BeEmpty())
})
It("drops a reservation at reconcile when the crash came before the database was created", func() {
r := newRig()
r.start(1)
key, e := r.policy.Register(ctx, alice, "tables")
Expect(e).NotTo(HaveOccurred())
// Pending is invisible to its own owner.
Expect(r.policy.Authorize(ctx, alice, key, policy.ActionRead)).To(MatchError(policy.ErrForbidden))
r.closePolicy()
r.openPolicy()
Expect(r.policy.Reconcile(ctx, r.backing())).To(Succeed())
Expect(r.policy.Authorize(ctx, alice, key, policy.ActionRead)).To(MatchError(policy.ErrForbidden))
Expect(r.reservations()).To(BeEmpty())
got, _ := r.policy.Accessible(ctx, alice, "tables")
Expect(got).To(BeEmpty())
})
It("commits a reservation at reconcile when the crash came after the database was created", func() {
r := newRig()
inst := r.start(1)
actor := alice
key, e := r.policy.Register(ctx, actor, "tables")
Expect(e).NotTo(HaveOccurred())
// The database is created; the owner dies before it commits.
Expect(policy.RuntimeCreate(r, r, contract.DefaultLimits(), actor, args(map[string]any{"tool": "databases-create", "name": "Recovered"}))(ctx, key)).To(Succeed())
Expect(r.policy.Authorize(ctx, actor, key, policy.ActionRead)).To(MatchError(policy.ErrForbidden))
r.closePolicy()
r.openPolicy()
Expect(r.policy.Reconcile(ctx, r.backing())).To(Succeed())
Expect(r.policy.Authorize(ctx, actor, key, policy.ActionSchema)).To(Succeed())
Expect(r.policy.Authorize(ctx, bob, key, policy.ActionRead)).To(MatchError(policy.ErrForbidden))
res, e := r.read(inst, actor, key.ResourceID, "databases-inspect")
Expect(e).NotTo(HaveOccurred())
Expect(schemaName(res)).To(Equal("Recovered"))
})
It("never exposes a database that has no host record", func() {
r := newRig()
inst := r.start(1)
// The addon holds a database for an ID the host never recorded, as after
// a lost policy store: nobody can resolve or list it.
const orphan = "rffffffffffffffffffffffffffffffff"
a := args(map[string]any{"tool": "databases-create", "name": "Orphan"})
p, e := inst.Client.Preview(ctx, r.envelope(inst, alice, "preview", "create-"+orphan, orphan, a))
Expect(e).NotTo(HaveOccurred())
_, e = inst.Client.Apply(ctx, r.envelope(inst, alice, "apply", "create-"+orphan, orphan, a), p.Revision)
Expect(e).NotTo(HaveOccurred())
Expect(r.dbFile(orphan)).To(BeAnExistingFile())
for _, who := range []contract.Actor{alice, bob, user("admin")} {
_, e = r.policy.Resolve(ctx, who, contract.Request{AddonID: "tables", ResourceID: orphan})
Expect(e).To(MatchError(policy.ErrForbidden))
got, _ := r.policy.Accessible(ctx, who, "tables")
Expect(got).To(BeEmpty())
}
Expect(r.policy.Reconcile(ctx, r.backing())).To(Succeed())
_, e = r.policy.Resolve(ctx, alice, contract.Request{AddonID: "tables", ResourceID: orphan})
Expect(e).To(MatchError(policy.ErrForbidden))
})
It("renames one database by operation, replays safely, and leaves every other database alone", func() {
r := newRig()
inst := r.start(1)
a, e := r.create(inst, alice, "Sales")
Expect(e).NotTo(HaveOccurred())
b, e := r.create(inst, bob, "Costs")
Expect(e).NotTo(HaveOccurred())
before, _ := os.ReadFile(r.dbFile(b.ResourceID))
rec, e := r.rename(inst, alice, a.ResourceID, "Revenue", "rename-1")
Expect(e).NotTo(HaveOccurred())
Expect(rec.State).To(Equal(contract.ReceiptCommitted))
again, e := r.rename(inst, alice, a.ResourceID, "Revenue", "rename-1")
Expect(e).NotTo(HaveOccurred())
Expect(again.State).To(Equal(contract.ReceiptCommitted))
Expect(again.OperationID).To(Equal("rename-1"))
got, e := inst.Client.Receipt(ctx, "rename-1")
Expect(e).NotTo(HaveOccurred())
Expect(got.State).To(Equal(contract.ReceiptCommitted))
res, _ := r.read(inst, alice, a.ResourceID, "databases-inspect")
Expect(schemaName(res)).To(Equal("Revenue"))
res, _ = r.read(inst, bob, b.ResourceID, "databases-inspect")
Expect(schemaName(res)).To(Equal("Costs"))
after, _ := os.ReadFile(r.dbFile(b.ResourceID))
Expect(after).To(Equal(before))
// The rename is not portable: the same operation on another database is rejected.
other, e := r.rename(inst, alice, b.ResourceID, "Revenue", "rename-1")
if e == nil {
Expect(other.State).To(Equal(contract.ReceiptRejected))
}
res, _ = r.read(inst, bob, b.ResourceID, "databases-inspect")
Expect(schemaName(res)).To(Equal("Costs"))
// Host policy: an editor may not change the schema, so no rename is sent.
Expect(r.policy.SetGrant(ctx, bob, b, "alice", policy.RoleEditor)).To(Succeed())
Expect(r.policy.Authorize(ctx, alice, b, policy.ActionSchema)).To(MatchError(policy.ErrForbidden))
Expect(r.policy.Authorize(ctx, alice, b, policy.ActionWrite)).To(Succeed())
})
It("rejects an undeclared tool, a forged addon binding and an unknown resource", func() {
r := newRig()
inst := r.start(1)
key, e := r.create(inst, alice, "Sales")
Expect(e).NotTo(HaveOccurred())
_, e = r.read(inst, alice, key.ResourceID, "databases-drop")
Expect(e).To(HaveOccurred())
forged := r.envelope(inst, alice, "read", "forged-1", key.ResourceID, args(map[string]any{"tool": "databases-inspect"}))
forged.Request.AddonID = "other"
_, e = inst.Client.Read(ctx, forged)
Expect(e).To(HaveOccurred())
forged = r.envelope(inst, alice, "read", "forged-2", key.ResourceID, args(map[string]any{"tool": "databases-inspect"}))
forged.Request.Generation = 99
_, e = inst.Client.Read(ctx, forged)
Expect(e).To(HaveOccurred())
for _, bad := range []string{"../etc/passwd", "r00000000000000000000000000000000"} {
_, e = r.read(inst, alice, bad, "databases-inspect")
Expect(e).To(HaveOccurred(), bad)
}
Expect(filesIn(filepath.Join(r.dataRoot, "tables", "tables", "databases"))).To(Equal([]string{key.ResourceID + ".db"}))
})
It("keeps databases and operation receipts across an addon restart", func() {
r := newRig()
inst := r.start(1)
key, e := r.create(inst, alice, "Sales")
Expect(e).NotTo(HaveOccurred())
_, e = r.rename(inst, alice, key.ResourceID, "Renamed", "rename-keep")
Expect(e).NotTo(HaveOccurred())
Expect(r.sup.Stop(ctx, inst)).To(Succeed())
inst = r.start(2)
res, e := r.read(inst, alice, key.ResourceID, "databases-inspect")
Expect(e).NotTo(HaveOccurred())
Expect(schemaName(res)).To(Equal("Renamed"))
rec, e := inst.Client.Receipt(ctx, "rename-keep")
Expect(e).NotTo(HaveOccurred())
Expect(rec.State).To(Equal(contract.ReceiptCommitted))
replay, e := r.rename(inst, alice, key.ResourceID, "Renamed", "rename-keep")
Expect(e).NotTo(HaveOccurred())
Expect(replay.State).To(Equal(contract.ReceiptCommitted))
Expect(filesIn(filepath.Join(r.dataRoot, "tables", "tables", "databases"))).To(HaveLen(1))
})
It("fails closed when the addon cannot answer, and drops only for an addon that is not installed", func() {
r := newRig()
inst := r.start(1)
key, e := r.policy.Register(ctx, alice, "tables")
Expect(e).NotTo(HaveOccurred())
Expect(policy.RuntimeCreate(r, r, contract.DefaultLimits(), alice, args(map[string]any{"tool": "databases-create", "name": "Kept"}))(ctx, key)).To(Succeed())
Expect(r.sup.Stop(ctx, inst)).To(Succeed())
// Installed but down: the record is kept pending, not dropped, and
// the pass does not fail.
rep, e := r.policy.ReconcileReport(ctx, r.backing())
Expect(e).NotTo(HaveOccurred())
Expect(rep).To(Equal(policy.ReconcileReport{Kept: 1}))
Expect(r.reservations()).To(ConsistOf(key.ResourceID))
// Gone for good: nothing can back it, so the record is dropped.
r.installed = false
Expect(r.policy.Reconcile(ctx, r.backing())).To(Succeed())
Expect(r.reservations()).To(BeEmpty())
})
})
func filesIn(dir string) []string {
es, _ := os.ReadDir(dir)
out := []string{}
for _, e := range es {
out = append(out, e.Name())
}
return out
}
+284
View File
@@ -0,0 +1,284 @@
// SPDX-License-Identifier: MIT
package tables_test
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/mudler/LocalAI/core/services/addons/policy"
addonruntime "github.com/mudler/LocalAI/core/services/addons/runtime"
"github.com/mudler/LocalAI/pkg/addons/contract"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// rowEnv is an envelope under the host's read or write action.
func (r *rig) rowEnv(inst addonruntime.Instance, actor contract.Actor, scope, action, op, resource string, a map[string]any) contract.Envelope {
e := r.envelope(inst, actor, scope, op, resource, args(a))
e.Request.Action = action
return e
}
func (r *rig) rowsRead(inst addonruntime.Instance, actor contract.Actor, resource, op string, a map[string]any) (contract.Result, error) {
return inst.Client.Read(context.Background(), r.rowEnv(inst, actor, "read", "read", op, resource, a))
}
// rowsWrite previews and applies one row mutation as the host's broker does.
func (r *rig) rowsWrite(inst addonruntime.Instance, actor contract.Actor, resource, op string, a map[string]any) (contract.Preview, contract.Receipt, error) {
ctx := context.Background()
p, e := inst.Client.Preview(ctx, r.rowEnv(inst, actor, "preview", "write", op, resource, a))
if e != nil {
return p, contract.Receipt{}, e
}
rc, e := inst.Client.Apply(ctx, r.rowEnv(inst, actor, "apply", "write", op, resource, a), p.Revision)
return p, rc, e
}
type page struct {
Columns []string `json:"columns"`
Rows []struct {
ID int64 `json:"id"`
Values []any `json:"values"`
} `json:"rows"`
HasMore bool `json:"hasMore"`
Cursor string `json:"cursor"`
Revision contract.Revision `json:"revision"`
}
func asPage(res contract.Result) page {
GinkgoHelper()
var p page
Expect(json.Unmarshal(res.Payload, &p)).To(Succeed())
return p
}
var _ = Describe("Tables rows under the real owner", func() {
ctx := context.Background()
alice, bob := user("alice"), user("bob")
setup := func() (*rig, addonruntime.Instance, string, policy.ResourceKey) {
GinkgoHelper()
r := newRig()
inst := r.start(1)
key, e := r.create(inst, alice, "Sales")
Expect(e).NotTo(HaveOccurred())
_, _, e = r.schemaCall(inst, alice, key.ResourceID, "s-t", map[string]any{"tool": "tables-create", "table": "Orders"})
Expect(e).NotTo(HaveOccurred())
for i, c := range [][2]string{{"Item", "text"}, {"Qty", "integer"}} {
_, rc, e := r.schemaCall(inst, alice, key.ResourceID, fmt.Sprint("s-c", i), map[string]any{"tool": "columns-add", "table": "Orders", "column": c[0], "type": c[1]})
Expect(e).NotTo(HaveOccurred())
Expect(rc.State).To(Equal(contract.ReceiptCommitted))
}
return r, inst, key.ResourceID, key
}
It("reads and mutates rows with the exact preview, refuses wrong actions and keeps receipts across a restart", func() {
r, inst, id, key := setup()
p, rc, e := r.rowsWrite(inst, alice, id, "ins-1", map[string]any{"tool": "rows-insert", "table": "Orders",
"rows": []map[string]any{{"Item": "pen", "Qty": 3}, {"Item": "ink", "Qty": 9}, {"Item": "pad"}}})
Expect(e).NotTo(HaveOccurred())
Expect(rc.State).To(Equal(contract.ReceiptCommitted))
Expect(rc.Revision.Data).To(Equal(p.Revision.Data + 1))
Expect(rc.Revision.Schema).To(Equal(p.Revision.Schema))
var shape struct {
Count int `json:"count"`
Sample []map[string]any `json:"sample"`
}
Expect(json.Unmarshal(p.Summary, &shape)).To(Succeed())
Expect(shape.Count).To(Equal(3))
res, e := r.rowsRead(inst, alice, id, "q-1", map[string]any{"tool": "rows-query", "table": "orders", "columns": []string{"Item"},
"filter": map[string]any{"column": "Qty", "op": "gt", "value": 2}, "sort": []map[string]any{{"column": "Qty", "desc": true}}})
Expect(e).NotTo(HaveOccurred())
got := asPage(res)
Expect(got.Columns).To(Equal([]string{"Item"}))
Expect(got.Rows).To(HaveLen(2))
Expect(got.Rows[0].Values).To(Equal([]any{"ink"}))
agg, e := r.rowsRead(inst, alice, id, "q-2", map[string]any{"tool": "rows-aggregate", "table": "Orders",
"aggregates": []map[string]any{{"fn": "sum", "column": "Qty"}, {"fn": "count"}}})
Expect(e).NotTo(HaveOccurred())
Expect(string(agg.Payload)).To(ContainSubstring(`"values":[12,3]`))
// A filter update shows its exact set; a delete by id removes one row.
pv, rc, e := r.rowsWrite(inst, alice, id, "upd-1", map[string]any{"tool": "rows-update", "table": "Orders",
"filter": map[string]any{"column": "Qty", "op": "is-null"}, "set": map[string]any{"Qty": 1}})
Expect(e).NotTo(HaveOccurred())
Expect(rc.State).To(Equal(contract.ReceiptCommitted))
Expect(json.Unmarshal(pv.Summary, &shape)).To(Succeed())
Expect(shape.Count).To(Equal(1))
_, rc, e = r.rowsWrite(inst, alice, id, "del-1", map[string]any{"tool": "rows-delete", "table": "Orders", "ids": []int64{1}})
Expect(e).NotTo(HaveOccurred())
Expect(rc.State).To(Equal(contract.ReceiptCommitted))
// Roles: a viewer reads, an editor writes, neither changes the schema.
Expect(r.policy.SetGrant(ctx, alice, key, "bob", policy.RoleViewer)).To(Succeed())
Expect(r.policy.Authorize(ctx, bob, key, policy.ActionRead)).To(Succeed())
Expect(r.policy.Authorize(ctx, bob, key, policy.ActionWrite)).To(MatchError(policy.ErrForbidden))
Expect(r.policy.SetGrant(ctx, alice, key, "bob", policy.RoleEditor)).To(Succeed())
Expect(r.policy.Authorize(ctx, bob, key, policy.ActionWrite)).To(Succeed())
Expect(r.policy.Authorize(ctx, bob, key, policy.ActionSchema)).To(MatchError(policy.ErrForbidden))
// The addon refuses wrong actions on its own, whatever the host did.
del := map[string]any{"tool": "rows-delete", "table": "Orders", "ids": []int64{2}}
before, _ := r.rowsRead(inst, alice, id, "q-3", map[string]any{"tool": "rows-query", "table": "Orders"})
for _, act := range []string{"schema", "read", "share", "delete", "unknown", ""} {
env := r.rowEnv(inst, bob, "preview", act, "wrong-"+act, id, del)
_, e = inst.Client.Preview(ctx, env)
Expect(e).To(HaveOccurred(), act)
env = r.rowEnv(inst, bob, "apply", act, "wrong-"+act, id, del)
_, e = inst.Client.Apply(ctx, env, p.Revision)
Expect(e).To(HaveOccurred(), act)
}
_, e = inst.Client.Apply(ctx, r.rowEnv(inst, contract.Actor{}, "apply", "write", "wrong-noactor", id, del), p.Revision)
Expect(e).To(HaveOccurred())
// A row read under the write or schema action, and a mutation tool as a read.
for _, act := range []string{"write", "schema", "share"} {
_, e = inst.Client.Read(ctx, r.rowEnv(inst, bob, "read", act, "rd-"+act, id, map[string]any{"tool": "rows-query", "table": "Orders"}))
Expect(e).To(HaveOccurred(), act)
}
_, e = r.rowsRead(inst, bob, id, "rd-mut", del)
Expect(e).To(HaveOccurred())
// A schema tool under the write action is refused too.
env := r.rowEnv(inst, bob, "preview", "write", "sch-as-write", id, map[string]any{"tool": "tables-drop", "table": "Orders"})
_, e = inst.Client.Preview(ctx, env)
Expect(e).To(HaveOccurred())
// Unknown argument fields are refused, so a typo cannot widen a mutation.
_, e = inst.Client.Preview(ctx, r.rowEnv(inst, bob, "preview", "write", "typo", id, map[string]any{"tool": "rows-delete", "table": "Orders", "filters": map[string]any{"column": "Qty", "op": "eq", "value": 1}}))
Expect(e).To(HaveOccurred())
after, _ := r.rowsRead(inst, alice, id, "q-4", map[string]any{"tool": "rows-query", "table": "Orders"})
b, a := asPage(before), asPage(after)
Expect(a.Rows).To(Equal(b.Rows))
rec, e := inst.Client.Receipt(ctx, "wrong-write")
Expect(e).NotTo(HaveOccurred())
Expect(rec.State).To(Equal(contract.ReceiptUnknown))
// Stale: two previews of one revision, the second apply is rejected as stale.
first := map[string]any{"tool": "rows-update", "table": "Orders", "filter": map[string]any{"column": "Qty", "op": "gte", "value": 0}, "set": map[string]any{"Item": "A"}}
second := map[string]any{"tool": "rows-update", "table": "Orders", "filter": map[string]any{"column": "Qty", "op": "gte", "value": 0}, "set": map[string]any{"Item": "B"}}
p1, e := inst.Client.Preview(ctx, r.rowEnv(inst, bob, "preview", "write", "race-1", id, first))
Expect(e).NotTo(HaveOccurred())
p2, e := inst.Client.Preview(ctx, r.rowEnv(inst, bob, "preview", "write", "race-2", id, second))
Expect(e).NotTo(HaveOccurred())
rc1, e := inst.Client.Apply(ctx, r.rowEnv(inst, bob, "apply", "write", "race-1", id, first), p1.Revision)
Expect(e).NotTo(HaveOccurred())
Expect(rc1.State).To(Equal(contract.ReceiptCommitted))
rc2, e := inst.Client.Apply(ctx, r.rowEnv(inst, bob, "apply", "write", "race-2", id, second), p2.Revision)
Expect(e).NotTo(HaveOccurred())
Expect(rc2.State).To(Equal(contract.ReceiptRejected))
Expect(string(rc2.Result.Payload)).To(ContainSubstring(`"error":"stale"`))
// A rejected operation has no receipt to replay and its ID is free.
rec, e = inst.Client.Receipt(ctx, "race-2")
Expect(e).NotTo(HaveOccurred())
Expect(rec.State).To(Equal(contract.ReceiptUnknown))
// A restart keeps the data and the receipt, and a replay is the same receipt.
Expect(r.sup.Stop(ctx, inst)).To(Succeed())
inst = r.start(2)
got2, e := inst.Client.Receipt(ctx, "race-1")
Expect(e).NotTo(HaveOccurred())
Expect(got2.State).To(Equal(contract.ReceiptCommitted))
Expect(got2.Revision).To(Equal(rc1.Revision))
res, e = r.rowsRead(inst, alice, id, "q-5", map[string]any{"tool": "rows-query", "table": "Orders", "columns": []string{"Item"}})
Expect(e).NotTo(HaveOccurred())
for _, row := range asPage(res).Rows {
Expect(row.Values).To(Equal([]any{"A"}))
}
})
It("declares the row tools and refuses a row tool the manifest does not name", func() {
r, inst, id, _ := setup()
regs, e := r.sup.Registry.Snapshot(ctx)
Expect(e).NotTo(HaveOccurred())
Expect(regs[0].Capabilities.Tools).To(ContainElements("rows-query", "rows-aggregate", "rows-insert", "rows-update", "rows-delete"))
_, _, e = r.rowsWrite(inst, alice, id, "tr-1", map[string]any{"tool": "rows-truncate", "table": "Orders"})
Expect(e).To(HaveOccurred())
_, e = r.rowsRead(inst, alice, id, "tr-2", map[string]any{"tool": "rows-sql", "table": "Orders"})
Expect(e).To(HaveOccurred())
_, _, e = r.rowsWrite(inst, alice, id, "tr-3", map[string]any{"tool": "rows-delete", "table": "Orders"})
Expect(e).To(HaveOccurred())
})
})
var _ = Describe("Tables row proposals in the application bootstrap", Label("application"), func() {
It("applies row changes only on the proposer's confirmation and rejects a proposal whose database moved", func() {
h := newHost()
lib := h.library()
h.start()
DeferCleanup(h.stop)
h.seed(lib)
ctx := context.Background()
made := h.chatCreate("alice", "rows-create", "Ledger")
Expect(made.code).To(Equal(200), "%v", made.body)
Expect(h.confirm("alice", "rows-create", made.body["digest"].(string)).code).To(Equal(200))
res := made.body["resourceId"].(string)
propose := func(user, op, action string, a map[string]any) webReply {
body, _ := json.Marshal(map[string]any{"addonId": "tables", "resourceId": res, "operationId": op, "generation": 1, "action": action, "arguments": a})
return h.send("POST", "/api/addons/proposals", string(body), h.bearer(user))
}
run := func(user, op, action string, a map[string]any) webReply {
GinkgoHelper()
p := propose(user, op, action, a)
Expect(p.code).To(Equal(200), "%v", p.body)
done := h.confirm(user, op, p.body["digest"].(string))
Expect(done.code).To(Equal(200), "%v", done.body)
Expect(done.body["state"]).To(Equal("applied"), "%v", done.body)
return done
}
run("alice", "ap-t", "schema", map[string]any{"tool": "tables-create", "table": "Entries"})
run("alice", "ap-c", "schema", map[string]any{"tool": "columns-add", "table": "Entries", "column": "Memo", "type": "text"})
run("alice", "ap-n", "schema", map[string]any{"tool": "columns-add", "table": "Entries", "column": "Amount", "type": "integer"})
// An editor is needed for rows; a stranger and a viewer cannot propose.
Expect(propose("bob", "bob-denied", "write", map[string]any{"tool": "rows-insert", "table": "Entries", "rows": []map[string]any{{"Memo": "x"}}}).code).To(Equal(403))
key := policy.ResourceKey{AddonID: "tables", ResourceID: res}
alice := h.actor("alice")
Expect(h.app.AddonPolicy().SetGrant(ctx, alice, key, "bob", policy.RoleViewer)).To(Succeed())
Expect(propose("bob", "bob-viewer", "write", map[string]any{"tool": "rows-insert", "table": "Entries", "rows": []map[string]any{{"Memo": "x"}}}).code).To(Equal(403))
read := func(user string, a map[string]any) (page, error) {
out, e := h.app.AddonGateway().Dispatch(ctx, h.actor(user), contract.Request{AddonID: "tables", ResourceID: res,
OperationID: "rd-" + uuid.NewString()[:8], Generation: 1, Action: "read", Arguments: args(a)})
if e != nil {
return page{}, e
}
return asPage(out), nil
}
_, e := read("bob", map[string]any{"tool": "rows-query", "table": "Entries"})
Expect(e).NotTo(HaveOccurred())
Expect(h.app.AddonPolicy().SetGrant(ctx, alice, key, "bob", policy.RoleEditor)).To(Succeed())
// The proposal is a preview until the proposer's own session confirms.
p := propose("bob", "ins-1", "write", map[string]any{"tool": "rows-insert", "table": "Entries",
"rows": []map[string]any{{"Memo": "rent", "Amount": 100}, {"Memo": "food", "Amount": 40}}})
Expect(p.code).To(Equal(200), "%v", p.body)
Expect(p.body["state"]).To(Equal("pending"))
pg, e := read("alice", map[string]any{"tool": "rows-query", "table": "Entries"})
Expect(e).NotTo(HaveOccurred())
Expect(pg.Rows).To(BeEmpty())
Expect(h.send("POST", "/api/addons/proposals/ins-1/confirm", fmt.Sprintf(`{"digest":%q}`, p.body["digest"]), h.bearer("bob")).code).To(Equal(403))
Expect(h.confirm("alice", "ins-1", p.body["digest"].(string)).code).To(Equal(403))
Expect(h.confirm("bob", "ins-1", p.body["digest"].(string)).code).To(Equal(200))
pg, e = read("alice", map[string]any{"tool": "rows-query", "table": "Entries", "sort": []map[string]any{{"column": "Amount", "desc": true}}})
Expect(e).NotTo(HaveOccurred())
Expect(pg.Rows).To(HaveLen(2))
Expect(pg.Rows[0].Values).To(Equal([]any{"rent", float64(100)}))
// Two proposals on one revision: the second no longer applies, and the
// filter is never re-evaluated against the rows the first one made.
del := propose("alice", "del-all", "write", map[string]any{"tool": "rows-delete", "table": "Entries",
"filter": map[string]any{"column": "Amount", "op": "gt", "value": 0}})
Expect(del.code).To(Equal(200), "%v", del.body)
ins := propose("bob", "ins-2", "write", map[string]any{"tool": "rows-insert", "table": "Entries", "rows": []map[string]any{{"Memo": "late", "Amount": 5}}})
Expect(ins.code).To(Equal(200), "%v", ins.body)
Expect(h.confirm("bob", "ins-2", ins.body["digest"].(string)).code).To(Equal(200))
stale := h.confirm("alice", "del-all", del.body["digest"].(string))
Expect(stale.body["state"]).NotTo(Equal("applied"), "%v", stale.body)
pg, e = read("alice", map[string]any{"tool": "rows-query", "table": "Entries"})
Expect(e).NotTo(HaveOccurred())
Expect(pg.Rows).To(HaveLen(3))
_ = time.Second
})
})
+182
View File
@@ -0,0 +1,182 @@
// SPDX-License-Identifier: MIT
package tables_test
import (
"context"
"database/sql"
"encoding/json"
"errors"
_ "github.com/mattn/go-sqlite3"
"github.com/mudler/LocalAI/core/services/addons/policy"
addonruntime "github.com/mudler/LocalAI/core/services/addons/runtime"
"github.com/mudler/LocalAI/pkg/addons/contract"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// schemaCall sends one schema preview and apply through the real runtime
// transport, as the owner's broker does after it admitted an owner.
func (r *rig) schemaCall(inst addonruntime.Instance, actor contract.Actor, resource, op string, a map[string]any) (contract.Preview, contract.Receipt, error) {
body := args(a)
p, e := inst.Client.Preview(context.Background(), r.envelope(inst, actor, "preview", op, resource, body))
if e != nil {
return p, contract.Receipt{}, e
}
rc, e := inst.Client.Apply(context.Background(), r.envelope(inst, actor, "apply", op, resource, body), p.Revision)
return p, rc, e
}
var _ = Describe("Tables schema under the real owner", func() {
ctx := context.Background()
alice, bob := user("alice"), user("bob")
It("changes the schema as the owner, shows data loss in the preview and refuses an editor at the host and the addon", func() {
r := newRig()
inst := r.start(1)
key, e := r.create(inst, alice, "Sales")
Expect(e).NotTo(HaveOccurred())
id := key.ResourceID
p, rc, e := r.schemaCall(inst, alice, id, "tbl-create-1", map[string]any{"tool": "tables-create", "table": "Orders"})
Expect(e).NotTo(HaveOccurred())
Expect(rc.State).To(Equal(contract.ReceiptCommitted))
Expect(rc.Revision.Schema).To(Equal(p.Revision.Schema + 1))
var shape struct {
Count int `json:"count"`
Sample []map[string]any `json:"sample"`
}
Expect(json.Unmarshal(p.Summary, &shape)).To(Succeed())
Expect(shape.Count).To(Equal(1))
Expect(shape.Sample[0]["action"]).To(Equal("create table"))
_, rc, e = r.schemaCall(inst, alice, id, "col-add-1", map[string]any{"tool": "columns-add", "table": "orders", "column": "Total", "type": "real", "required": true, "default": 0})
Expect(e).NotTo(HaveOccurred())
Expect(rc.State).To(Equal(contract.ReceiptCommitted))
res, e := r.read(inst, alice, id, "databases-inspect")
Expect(e).NotTo(HaveOccurred())
var s struct {
Tables []string `json:"tables"`
Detail []struct {
Name string `json:"name"`
Columns []struct {
Name, Type string
Required bool
} `json:"columns"`
} `json:"detail"`
}
Expect(json.Unmarshal(res.Payload, &s)).To(Succeed())
Expect(s.Tables).To(Equal([]string{"Orders"}))
Expect(s.Detail[0].Columns).To(HaveLen(1))
Expect(s.Detail[0].Columns[0].Name).To(Equal("Total"))
Expect(s.Detail[0].Columns[0].Type).To(Equal("real"))
Expect(s.Detail[0].Columns[0].Required).To(BeTrue())
// Rows exist: dropping the column reports the data it would destroy.
raw, e := sql.Open("sqlite3", r.dbFile(id))
Expect(e).NotTo(HaveOccurred())
defer func() { _ = raw.Close() }()
var tableID, colID string
Expect(raw.QueryRow("SELECT table_id FROM tables_catalog_tables").Scan(&tableID)).To(Succeed())
Expect(raw.QueryRow("SELECT column_id FROM tables_catalog_columns").Scan(&colID)).To(Succeed())
_, e = raw.Exec("INSERT INTO " + tableID + " DEFAULT VALUES")
Expect(e).NotTo(HaveOccurred())
for i := 0; i < 3; i++ {
_, e = raw.Exec("INSERT INTO "+tableID+"("+colID+") VALUES(?)", float64(i))
Expect(e).NotTo(HaveOccurred())
}
dropArgs := map[string]any{"tool": "columns-drop", "table": "Orders", "column": "Total"}
p, e = inst.Client.Preview(ctx, r.envelope(inst, alice, "preview", "col-drop-1", id, args(dropArgs)))
Expect(e).NotTo(HaveOccurred())
var loss struct {
DataLoss map[string]float64 `json:"dataLoss"`
}
Expect(json.Unmarshal(p.Summary, &loss)).To(Succeed())
// The row made by DEFAULT VALUES holds the default 0, so four rows hold a value.
Expect(loss.DataLoss["rows"]).To(BeEquivalentTo(4))
// An editor: the host refuses the action before any call is sent.
Expect(r.policy.SetGrant(ctx, alice, key, "bob", policy.RoleEditor)).To(Succeed())
Expect(r.policy.Authorize(ctx, bob, key, policy.ActionSchema)).To(MatchError(policy.ErrForbidden))
Expect(r.policy.Authorize(ctx, bob, key, policy.ActionWrite)).To(Succeed())
// Direct runtime transport: a schema tool under a row action, or
// without an actor, is refused by the addon itself and changes nothing.
before, _ := r.read(inst, alice, id, "databases-inspect")
for _, act := range []string{"write", "read", "share", "delete", "unknown", ""} {
env := r.envelope(inst, bob, "preview", "direct-"+act, id, args(dropArgs))
env.Request.Action = act
_, e = inst.Client.Preview(ctx, env)
Expect(e).To(HaveOccurred(), act)
env = r.envelope(inst, bob, "apply", "direct-"+act, id, args(dropArgs))
env.Request.Action = act
_, e = inst.Client.Apply(ctx, env, p.Revision)
if e == nil {
Fail("apply under action " + act + " must fail")
}
}
noActor := r.envelope(inst, contract.Actor{}, "apply", "direct-noactor", id, args(dropArgs))
_, e = inst.Client.Apply(ctx, noActor, p.Revision)
Expect(e).To(HaveOccurred())
after, _ := r.read(inst, alice, id, "databases-inspect")
Expect(after.Payload).To(MatchJSON(before.Payload))
rec, e := inst.Client.Receipt(ctx, "direct-write")
Expect(e).NotTo(HaveOccurred())
Expect(rec.State).To(Equal(contract.ReceiptUnknown))
})
It("rejects a stale schema revision, replays an operation and keeps receipts across a restart", func() {
r := newRig()
inst := r.start(1)
key, e := r.create(inst, alice, "Sales")
Expect(e).NotTo(HaveOccurred())
id := key.ResourceID
a := args(map[string]any{"tool": "tables-create", "table": "A"})
stale, e := inst.Client.Preview(ctx, r.envelope(inst, alice, "preview", "sch-a", id, a))
Expect(e).NotTo(HaveOccurred())
_, rc, e := r.schemaCall(inst, alice, id, "sch-b", map[string]any{"tool": "tables-create", "table": "B"})
Expect(e).NotTo(HaveOccurred())
Expect(rc.State).To(Equal(contract.ReceiptCommitted))
// The preview of "A" was made before "B" changed the schema.
rj, e := inst.Client.Apply(ctx, r.envelope(inst, alice, "apply", "sch-a", id, a), stale.Revision)
Expect(e).NotTo(HaveOccurred())
Expect(rj.State).To(Equal(contract.ReceiptRejected))
var body struct {
Error string `json:"error"`
Revision contract.Revision `json:"revision"`
}
Expect(json.Unmarshal(rj.Result.Payload, &body)).To(Succeed())
Expect(body.Error).To(Equal("stale"))
Expect(body.Revision).To(Equal(rc.Revision))
res, _ := r.read(inst, alice, id, "databases-inspect")
Expect(string(res.Payload)).NotTo(ContainSubstring(`"A"`))
// The operation replays to one receipt and the same ID with another
// request is a conflict at the transport.
_, again, e := r.schemaCall(inst, alice, id, "sch-b", map[string]any{"tool": "tables-create", "table": "B"})
Expect(e).NotTo(HaveOccurred())
Expect(again).To(Equal(rc))
_, _, e = r.schemaCall(inst, alice, id, "sch-b", map[string]any{"tool": "tables-create", "table": "C"})
Expect(e).To(HaveOccurred())
// A restart keeps the receipt.
Expect(r.sup.Stop(ctx, inst)).To(Succeed())
inst = r.start(2)
got, e := inst.Client.Receipt(ctx, "sch-b")
Expect(e).NotTo(HaveOccurred())
Expect(got.State).To(Equal(contract.ReceiptCommitted))
Expect(got.Revision).To(Equal(rc.Revision))
Expect(errors.Is(e, context.Canceled)).To(BeFalse())
})
It("declares the schema tools and refuses any schema tool the manifest does not name", func() {
r := newRig()
inst := r.start(1)
key, e := r.create(inst, alice, "Sales")
Expect(e).NotTo(HaveOccurred())
regs, e := r.sup.Registry.Snapshot(ctx)
Expect(e).NotTo(HaveOccurred())
Expect(regs[0].Capabilities.Tools).To(ContainElements("tables-create", "tables-rename", "tables-drop", "columns-add", "columns-rename", "columns-drop"))
_, _, e = r.schemaCall(inst, alice, key.ResourceID, "sch-x", map[string]any{"tool": "columns-retype", "table": "A", "column": "b", "type": "integer"})
Expect(e).To(HaveOccurred())
_, _, e = r.schemaCall(inst, alice, key.ResourceID, "sch-y", map[string]any{"tool": "tables-create", "table": "bad‮name"})
Expect(e).To(HaveOccurred())
})
})
+159
View File
@@ -0,0 +1,159 @@
// SPDX-License-Identifier: MIT
package tables_test
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
goruntime "runtime"
"testing"
"time"
"github.com/mudler/LocalAI/core/services/addons/packages"
"github.com/mudler/LocalAI/core/services/addons/policy"
addonruntime "github.com/mudler/LocalAI/core/services/addons/runtime"
"github.com/mudler/LocalAI/pkg/addons/contract"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestTablesDatabases(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Tables databases addon")
}
var binary string
var _ = BeforeSuite(func() {
binary = filepath.Join(GinkgoT().TempDir(), "tables")
cmd := exec.Command("go", "build", "-o", binary, "../../../addons/tables/cmd/tables")
cmd.Env = append(os.Environ(), "CGO_ENABLED=1")
out, e := cmd.CombinedOutput()
Expect(e).NotTo(HaveOccurred(), string(out))
})
// rig is one owner: a supervisor over the real Tables executable, a policy
// store, and the host's bridge to the current runtime.
type rig struct {
root string
dataRoot string
sup *addonruntime.Supervisor
pkg packages.PackageReceipt
gen uint64
policy *policy.Policy
store *policy.Store
policyDir string
installed bool
}
func (r *rig) Runtime(ctx context.Context, id string) (addonruntime.Instance, error) {
return r.sup.Current(ctx, id, r.gen)
}
func (r *rig) DeclaredTools(_ context.Context, id string, _ uint64) ([]string, error) {
return r.pkg.Manifest.Capabilities.Tools, nil
}
func (r *rig) Installed(context.Context, string) (bool, error) { return r.installed, nil }
func (r *rig) backing() func(context.Context, policy.ResourceKey) (bool, error) {
return policy.BackingExists(policy.BackingOptions{Runtime: r, Tools: r, Installed: r.Installed})
}
func user(id string) contract.Actor {
return contract.Actor{UserID: id, CredentialID: "session:" + id, Mode: "named-user"}
}
func newRig() *rig {
GinkgoHelper()
root := GinkgoT().TempDir()
r := &rig{root: root, dataRoot: filepath.Join(root, "data"), installed: true, gen: 1}
data, e := os.ReadFile("../../../addons/tables/manifest.json")
Expect(e).NotTo(HaveOccurred())
m, e := contract.ParseManifest(data)
Expect(e).NotTo(HaveOccurred())
m.OS, m.Arch = goruntime.GOOS, goruntime.GOARCH
dir := filepath.Join(root, "package")
Expect(os.MkdirAll(dir, 0o700)).To(Succeed())
exe, e := os.ReadFile(binary)
Expect(e).NotTo(HaveOccurred())
Expect(os.WriteFile(filepath.Join(dir, m.Executable), exe, 0o500)).To(Succeed())
r.pkg = packages.PackageReceipt{Manifest: m, Directory: dir, Digest: "sha256:tables"}
b, e := json.Marshal(r.pkg)
Expect(e).NotTo(HaveOccurred())
Expect(os.WriteFile(filepath.Join(dir, "receipt.json"), b, 0o400)).To(Succeed())
limits := contract.DefaultLimits()
r.sup, e = addonruntime.NewSupervisor(addonruntime.Config{StateRoot: filepath.Join(root, "runtime"), DataRoot: r.dataRoot, Limits: limits, ReadyTimeout: 10 * time.Second, StableHealth: limits.BackoffMax})
Expect(e).NotTo(HaveOccurred())
DeferCleanup(func() { Expect(r.sup.Close(context.Background())).To(Succeed()) })
r.openPolicy()
return r
}
func (r *rig) openPolicy() {
GinkgoHelper()
r.policyDir = filepath.Join(r.root, "policy")
st, e := policy.OpenStore(r.policyDir, policy.ModeNamedUser)
Expect(e).NotTo(HaveOccurred())
r.store = st
r.policy = policy.New(st, policy.Options{
Revalidate: func(context.Context, contract.Actor) error { return nil },
CreateAuthority: func(context.Context, contract.Actor, string) error { return nil },
})
}
func (r *rig) closePolicy() { Expect(r.store.Close()).To(Succeed()) }
func (r *rig) start(gen uint64) addonruntime.Instance {
GinkgoHelper()
r.gen = gen
i, e := r.sup.Start(context.Background(), r.pkg, gen)
Expect(e).NotTo(HaveOccurred())
return i
}
func (r *rig) dbFile(id string) string {
return filepath.Join(r.dataRoot, "tables", "tables", "databases", id+".db")
}
func args(v map[string]any) json.RawMessage { b, _ := json.Marshal(v); return b }
func (r *rig) envelope(inst addonruntime.Instance, actor contract.Actor, scope, op, resource string, a json.RawMessage) contract.Envelope {
return contract.Envelope{
Request: contract.Request{AddonID: "tables", ResourceID: resource, OperationID: op, Generation: inst.Generation, Action: "schema", Arguments: a},
Authority: contract.Authority{Actor: actor, Scope: scope, ExpiresAt: time.Now().Add(time.Minute)},
}
}
func (r *rig) create(inst addonruntime.Instance, actor contract.Actor, name string) (policy.ResourceKey, error) {
return r.policy.Create(context.Background(), actor, "tables", policy.RuntimeCreate(r, r, contract.DefaultLimits(), actor, args(map[string]any{"tool": "databases-create", "name": name})))
}
func (r *rig) read(inst addonruntime.Instance, actor contract.Actor, resource, tool string) (contract.Result, error) {
return inst.Client.Read(context.Background(), r.envelope(inst, actor, "read", "read-"+resource, resource, args(map[string]any{"tool": tool})))
}
func (r *rig) rename(inst addonruntime.Instance, actor contract.Actor, resource, name, op string) (contract.Receipt, error) {
a := args(map[string]any{"tool": "databases-rename", "name": name})
p, e := inst.Client.Preview(context.Background(), r.envelope(inst, actor, "preview", op, resource, a))
if e != nil {
return contract.Receipt{}, e
}
return inst.Client.Apply(context.Background(), r.envelope(inst, actor, "apply", op, resource, a), p.Revision)
}
func schemaName(res contract.Result) string {
GinkgoHelper()
var out struct {
Name string `json:"name"`
}
Expect(json.Unmarshal(res.Payload, &out)).To(Succeed())
return out.Name
}
// reservations lists the IDs still pending in the policy store, read from its file.
func (r *rig) reservations() []string {
GinkgoHelper()
got, e := r.policy.Pending()
Expect(e).NotTo(HaveOccurred())
return got
}
+240
View File
@@ -0,0 +1,240 @@
// SPDX-License-Identifier: MIT
package tables_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/mudler/LocalAI/core/http/auth"
"github.com/mudler/LocalAI/core/services/addons/gateway"
"github.com/mudler/LocalAI/core/services/addons/policy"
"github.com/mudler/LocalAI/core/services/addons/tools"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// callTool runs one model tool call as a user's API key, the credential of an
// unattended agent. The adapter is the real one over the real owner gateway.
func (h *host) callTool(user, name, body string) (tools.ToolResult, error) {
GinkgoHelper()
adapter := tools.NewToolAdapter(h.app.AddonGateway())
return adapter.Execute(context.Background(), h.actor(user), name, json.RawMessage(body))
}
func (h *host) pending(user string) []map[string]any {
GinkgoHelper()
got := h.send("GET", "/api/addons/proposals?state=pending", "", h.bearer(user))
Expect(got.code).To(Equal(200), "%v", got.body)
var out []map[string]any
list, _ := got.body["proposals"].([]any)
for _, x := range list {
out = append(out, x.(map[string]any))
}
return out
}
var _ = Describe("Tables tools under the real owner", Label("application"), func() {
It("offers the manifest tools to a permitted user and nothing to a user without the feature or after revocation", func() {
h := newHost()
lib := h.library()
h.start()
DeferCleanup(h.stop)
h.seed(lib)
adapter := tools.NewToolAdapter(h.app.AddonGateway())
ctx := context.Background()
defs, e := adapter.Discover(ctx, h.actor("alice"))
Expect(e).NotTo(HaveOccurred())
Expect(defs).To(HaveLen(15))
for _, d := range defs {
Expect(d.Name).To(HavePrefix("addon__tables__"))
}
defs, e = adapter.Discover(ctx, h.actor("bob"))
Expect(e).NotTo(HaveOccurred())
Expect(defs).To(HaveLen(15))
// Another user's identity cannot be claimed by changing a field of
// the actor: the owner checks the stored credential.
forged := h.actor("bob")
forged.CredentialID = "key:" + h.keys["alice"]
_, e = adapter.Discover(ctx, forged)
Expect(e).To(HaveOccurred())
// Revocation of the feature withdraws every tool at once.
Expect(h.app.AuthDB().Where("user_id = ?", "bob").Delete(&auth.UserPermission{}).Error).To(Succeed())
_, e = adapter.Discover(ctx, h.actor("bob"))
Expect(e).To(HaveOccurred())
Expect(gateway.StatusFor(e)).To(Equal(403))
_, e = h.callTool("bob", "addon__tables__databases-create", `{"name":"Nope"}`)
Expect(e).To(HaveOccurred())
Expect(gateway.StatusFor(e)).To(Equal(403))
})
It("stores every change as a pending proposal and applies it only on the proposer's own session, exactly once", func() {
h := newHost()
lib := h.library()
h.start()
DeferCleanup(h.stop)
h.seed(lib)
created, e := h.callTool("alice", "addon__tables__databases-create", `{"name":"Ledger"}`)
Expect(e).NotTo(HaveOccurred())
Expect(created.Kind).To(Equal(tools.ResultProposal))
ref := created.Proposal
Expect(ref.State).To(Equal("pending"))
Expect(ref.Count).To(Equal(uint64(1)))
Expect(ref.ResourceID).NotTo(BeEmpty())
// Nothing exists yet, for anyone, including a read by the same agent.
Expect(accessibleTo(h, "alice")).To(BeEmpty())
_, e = h.callTool("alice", "addon__tables__databases-inspect", fmt.Sprintf(`{"resourceId":%q}`, ref.ResourceID))
Expect(e).To(HaveOccurred())
// The proposal waits in the proposer's own list, not in anyone else's.
listed := h.pending("alice")
Expect(listed).To(HaveLen(1))
Expect(listed[0]["id"]).To(Equal(ref.ID))
Expect(listed[0]["digest"]).To(Equal(ref.Digest))
Expect(h.pending("bob")).To(BeEmpty())
// The card's authoritative record is the same one the tool referenced.
card := h.send("GET", "/api/addons/proposals/"+ref.ID, "", h.bearer("alice"))
Expect(card.code).To(Equal(200))
Expect(card.body["resourceId"]).To(Equal(ref.ResourceID))
Expect(card.body["effects"].(map[string]any)["count"]).To(BeEquivalentTo(1))
Expect(h.send("GET", "/api/addons/proposals/"+ref.ID, "", h.bearer("bob")).code).To(Equal(403))
// A tool call, a key and another user's session cannot approve.
Expect(h.send("POST", "/api/addons/proposals/"+ref.ID+"/confirm", fmt.Sprintf(`{"digest":%q}`, ref.Digest), h.bearer("alice")).code).To(Equal(403))
toolCall := func(r *http.Request) { h.humanTab("alice", ref.ID)(r); r.Header.Set("X-LocalAI-Tool-Call", "1") }
Expect(h.send("POST", "/api/addons/proposals/"+ref.ID+"/confirm", fmt.Sprintf(`{"digest":%q}`, ref.Digest), toolCall).code).To(Equal(403))
Expect(h.confirm("bob", ref.ID, ref.Digest).code).To(Equal(403))
Expect(accessibleTo(h, "alice")).To(BeEmpty())
done := h.confirm("alice", ref.ID, ref.Digest)
Expect(done.code).To(Equal(200), "%v", done.body)
Expect(done.body["state"]).To(Equal("applied"))
Expect(h.pending("alice")).To(BeEmpty())
res := ref.ResourceID
step := func(name, body string) *tools.ProposalRef {
GinkgoHelper()
r, e := h.callTool("alice", name, body)
Expect(e).NotTo(HaveOccurred())
Expect(r.Kind).To(Equal(tools.ResultProposal))
ok := h.confirm("alice", r.Proposal.ID, r.Proposal.Digest)
Expect(ok.code).To(Equal(200), "%v", ok.body)
Expect(ok.body["state"]).To(Equal("applied"), "%v", ok.body)
return r.Proposal
}
step("addon__tables__tables-create", fmt.Sprintf(`{"resourceId":%q,"table":"Entries"}`, res))
step("addon__tables__columns-add", fmt.Sprintf(`{"resourceId":%q,"table":"Entries","column":"Memo","type":"text"}`, res))
rows, e := h.callTool("alice", "addon__tables__rows-insert", fmt.Sprintf(`{"resourceId":%q,"table":"Entries","rows":[{"Memo":"rent"},{"Memo":"food"}]}`, res))
Expect(e).NotTo(HaveOccurred())
Expect(rows.Proposal.Count).To(Equal(uint64(2)))
count := func() int {
GinkgoHelper()
r, e := h.callTool("alice", "addon__tables__rows-query", fmt.Sprintf(`{"resourceId":%q,"table":"Entries"}`, res))
Expect(e).NotTo(HaveOccurred())
Expect(r.Kind).To(Equal(tools.ResultData))
var p page
Expect(json.Unmarshal(r.Result, &p)).To(Succeed())
return len(p.Rows)
}
Expect(count()).To(Equal(0), "a proposal changes nothing")
Expect(h.confirm("alice", rows.Proposal.ID, rows.Proposal.Digest).code).To(Equal(200))
Expect(count()).To(Equal(2))
// A repeated confirmation returns the stored receipt and runs nothing.
again := h.confirm("alice", rows.Proposal.ID, rows.Proposal.Digest)
Expect(again.body["state"]).To(Equal("applied"))
Expect(count()).To(Equal(2))
})
It("keeps the roles of the host for tool calls and rejects forged fields before the owner sees them", func() {
h := newHost()
lib := h.library()
h.start()
DeferCleanup(h.stop)
h.seed(lib)
ctx := context.Background()
created, e := h.callTool("alice", "addon__tables__databases-create", `{"name":"Team"}`)
Expect(e).NotTo(HaveOccurred())
Expect(h.confirm("alice", created.Proposal.ID, created.Proposal.Digest).code).To(Equal(200))
res := created.Proposal.ResourceID
mk := func(table string) {
r, e := h.callTool("alice", "addon__tables__tables-create", fmt.Sprintf(`{"resourceId":%q,"table":%q}`, res, table))
Expect(e).NotTo(HaveOccurred())
Expect(h.confirm("alice", r.Proposal.ID, r.Proposal.Digest).code).To(Equal(200))
}
mk("Entries")
// A stranger sees nothing: the same denial for every tool.
for _, call := range []struct{ tool, body string }{
{"addon__tables__databases-inspect", fmt.Sprintf(`{"resourceId":%q}`, res)},
{"addon__tables__rows-query", fmt.Sprintf(`{"resourceId":%q,"table":"Entries"}`, res)},
{"addon__tables__rows-insert", fmt.Sprintf(`{"resourceId":%q,"table":"Entries","rows":[{}]}`, res)},
{"addon__tables__tables-drop", fmt.Sprintf(`{"resourceId":%q,"table":"Entries"}`, res)},
} {
_, e := h.callTool("bob", call.tool, call.body)
Expect(e).To(HaveOccurred(), call.tool)
Expect(gateway.StatusFor(e)).To(Equal(403), call.tool)
}
key := policy.ResourceKey{AddonID: "tables", ResourceID: res}
Expect(h.app.AddonPolicy().SetGrant(ctx, h.actor("alice"), key, "bob", policy.RoleViewer)).To(Succeed())
_, e = h.callTool("bob", "addon__tables__rows-query", fmt.Sprintf(`{"resourceId":%q,"table":"Entries"}`, res))
Expect(e).NotTo(HaveOccurred())
_, e = h.callTool("bob", "addon__tables__rows-insert", fmt.Sprintf(`{"resourceId":%q,"table":"Entries","rows":[{}]}`, res))
Expect(gateway.StatusFor(e)).To(Equal(403))
Expect(h.app.AddonPolicy().SetGrant(ctx, h.actor("alice"), key, "bob", policy.RoleEditor)).To(Succeed())
r, e := h.callTool("bob", "addon__tables__rows-insert", fmt.Sprintf(`{"resourceId":%q,"table":"Entries","rows":[{}]}`, res))
Expect(e).NotTo(HaveOccurred())
Expect(r.Kind).To(Equal(tools.ResultProposal))
// An editor never changes the schema, by tool or by any other name.
_, e = h.callTool("bob", "addon__tables__tables-drop", fmt.Sprintf(`{"resourceId":%q,"table":"Entries"}`, res))
Expect(gateway.StatusFor(e)).To(Equal(403))
before := len(h.pending("bob"))
// Forged identity, role and approval fields and approval aliases never
// create a proposal and never reach the addon.
for _, field := range []string{`"actor":"alice"`, `"userId":"alice"`, `"role":"owner"`, `"approved":true`, `"proposalId":"x"`, `"tool":"tables-drop"`} {
_, e := h.callTool("bob", "addon__tables__rows-insert", fmt.Sprintf(`{"resourceId":%q,"table":"Entries","rows":[{}],%s}`, res, field))
Expect(e).To(MatchError(tools.ErrForbiddenArgument), field)
}
for _, name := range []string{"confirm", "approve", "addon__tables__confirm", "addon__tables__share", "addon__tables__databases-delete", "addon__tables__databases-purge"} {
_, e := h.callTool("bob", name, fmt.Sprintf(`{"resourceId":%q}`, res))
Expect(e).To(MatchError(tools.ErrUnknownTool), name)
}
Expect(h.pending("bob")).To(HaveLen(before))
})
It("lists at most the bound of pending proposals and refuses more than the user's quota", func() {
h := newHost()
lib := h.library()
h.start()
DeferCleanup(h.stop)
h.seed(lib)
created, e := h.callTool("alice", "addon__tables__databases-create", `{"name":"Many"}`)
Expect(e).NotTo(HaveOccurred())
Expect(h.confirm("alice", created.Proposal.ID, created.Proposal.Digest).code).To(Equal(200))
res := created.Proposal.ResourceID
for _, step := range []string{
fmt.Sprintf(`{"resourceId":%q,"table":"T"}`, res),
} {
r, e := h.callTool("alice", "addon__tables__tables-create", step)
Expect(e).NotTo(HaveOccurred())
Expect(h.confirm("alice", r.Proposal.ID, r.Proposal.Digest).code).To(Equal(200))
}
for i := 0; i < policy.MaxProposalsPerUser; i++ {
_, e := h.callTool("alice", "addon__tables__rows-insert", fmt.Sprintf(`{"resourceId":%q,"table":"T","rows":[{}]}`, res))
Expect(e).NotTo(HaveOccurred(), "proposal %d", i)
}
Expect(len(h.pending("alice"))).To(Equal(gateway.MaxPendingListed))
_, e = h.callTool("alice", "addon__tables__rows-insert", fmt.Sprintf(`{"resourceId":%q,"table":"T","rows":[{}]}`, res))
Expect(e).To(HaveOccurred())
Expect(gateway.StatusFor(e)).To(Equal(403))
})
})
+1
View File
@@ -0,0 +1 @@
.cache/
@@ -0,0 +1,437 @@
// SPDX-License-Identifier: MIT
package tablesacceptance_test
import (
"fmt"
"net/http"
"strings"
"github.com/mudler/LocalAI/tests/addons/foundation/cluster"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// Role separation and credential changes, decided by the real servers.
var _ = Describe("Tables authorization across the owner and two frontends", Ordered, func() {
var (
d *deployment
res string
)
BeforeAll(func() {
c := start("0.1.0")
d = deploy(c, "0.1.0")
res = d.seedLedger(cluster.FrontA, "member", "Ledger", map[string]any{"Item": "pen", "Qty": 3})
Expect(d.grant(cluster.FrontB, "member", res, "editor", "editor").Status).To(Equal(200))
Expect(d.grant(cluster.Owner, "member", res, "viewer", "viewer").Status).To(Equal(200))
})
It("separates owner, editor, viewer, administrator and stranger across the browser routes, the REST routes and the chat tool consumer", func() {
row := map[string]any{"Item": "ink", "Qty": 1}
expect := map[string]struct {
read, rows, schema, share int
proposals int
}{
"member": {200, 200, 200, 200, 1},
"editor": {200, 200, 403, 403, 1},
"viewer": {200, 403, 403, 403, 0},
"friend": {403, 403, 403, 403, 0},
"admin": {403, 403, 403, 403, 0},
"outsider": {403, 403, 403, 403, 0},
}
order := []string{"member", "editor", "viewer", "friend", "admin", "outsider"}
// The one denial every refusal must equal: a database that does not
// exist, read by an account that has the feature.
probe := d.read(d.c.Token(cluster.FrontB, "member"), "r-not-a-database", map[string]any{"tool": "rows-query", "table": "Entries"})
Expect(probe.Status).To(Equal(403), probe.String())
denial := probe.String()
for i, user := range order {
want := expect[user]
// Every role goes through a different node in turn: the answer
// never depends on which process the person reached.
node := nodes[i%len(nodes)]
other := nodes[(i+1)%len(nodes)]
// REST read with the API key, on the node and on another one.
for _, n := range []string{node, other} {
r := d.read(d.c.Token(n, user), res, map[string]any{"tool": "rows-query", "table": "Entries"})
Expect(r.Status).To(Equal(want.read), fmt.Sprintf("%s reads on %s: %s", user, n, r.String()))
if want.read == 403 && user != "outsider" {
// A stranger and an administrator read the same denial, on
// every node; it does not tell a missing database from a
// private one.
Expect(r.String()).To(Equal(denial), user+" on "+n)
}
}
missing := d.read(d.c.Token(node, user), "r-not-a-database", map[string]any{"tool": "rows-query", "table": "Entries"})
Expect(missing.Status).To(Equal(403), user)
if user != "outsider" {
Expect(missing.String()).To(Equal(denial), "a missing database looks like a private one: "+user)
}
// The person's own tab: a row change and a schema change.
rows := d.apply(node, user, res, opID("acc-r"), "rows-insert", d.revisionOf(res), map[string]any{"table": "Entries", "rows": []any{row}})
Expect(rows.Status).To(Equal(want.rows), fmt.Sprintf("%s inserts on %s: %s", user, node, rows.String()))
column := "Col" + strings.ToUpper(user[:1]) + user[1:]
schema := d.apply(other, user, res, opID("acc-s"), "columns-add", d.revisionOf(res), map[string]any{"table": "Entries", "column": column, "type": "text"})
Expect(schema.Status).To(Equal(want.schema), fmt.Sprintf("%s changes the schema on %s: %s", user, other, schema.String()))
share := d.grant(node, user, res, "friend", "viewer")
Expect(share.Status).To(Equal(want.share), fmt.Sprintf("%s shares on %s: %s", user, node, share.String()))
if want.share == 200 {
Expect(d.revoke(other, user, res, "friend").Status).To(Equal(200))
}
// The typed proposal route: a write is a proposal for the owner
// role and the editor, and never for a viewer or a stranger.
prop := d.propose(other, user, res, "write", opID("acc-p"), map[string]any{"tool": "rows-insert", "table": "Entries", "rows": []any{row}})
if want.rows == 200 {
Expect(prop.Status).To(Equal(200), user+": "+prop.String())
} else {
Expect(prop.Status).To(Equal(403), user+": "+prop.String())
}
// The chat consumer: the scripted model calls the tool as this key.
before := 0
if want.read == 200 || want.rows == 200 {
before = len(d.pending(node, user))
}
d.chatTool(other, user, "rows-insert", map[string]any{"resourceId": res, "table": "Entries", "rows": []any{row}})
if user != "outsider" {
after := len(d.pending(node, user))
Expect(after-before).To(Equal(want.proposals), fmt.Sprintf("%s chat proposals (%d -> %d)", user, before, after))
}
}
// The same data through each node tells the same story: the owner role
// and the editor wrote, the others did not.
for _, n := range nodes {
count := d.rowCount(n, "viewer", res)
Expect(count).To(Equal(1 + 2))
}
})
It("serves the browser module to every account with the feature and to nobody else, from the owner and each frontend", func() {
for _, user := range []string{"member", "editor", "viewer", "friend", "admin"} {
for _, n := range nodes {
a := d.app(d.c.Session(n, user))
Expect(a).NotTo(BeNil(), user+" on "+n)
ui, _ := a["ui"].(map[string]any)
Expect(ui).NotTo(BeNil())
entry, _ := ui["entry"].(string)
Expect(entry).To(MatchRegexp(`^/api/addons/tables/assets/[0-9a-f]{64}/`))
asset, err := d.c.Session(n, user).Do("GET", entry, nil, nil)
Expect(err).NotTo(HaveOccurred())
Expect(asset.Status).To(Equal(200), user+" "+n)
Expect(asset.Body).NotTo(BeEmpty())
// The page of the SPA is the same shell for everyone; what the
// account may do is the server's answer above.
page, err := d.c.Session(n, user).Do("GET", "/app/addons/tables/databases", nil, nil)
Expect(err).NotTo(HaveOccurred())
Expect(page.Status).To(Equal(200), user+" "+n)
}
}
for _, n := range nodes {
r, err := d.c.Session(n, "outsider").Do("GET", "/api/addons/apps", nil, nil)
Expect(err).NotTo(HaveOccurred())
Expect(r.Status).To(Equal(403), n)
anon, err := cluster.Client{Base: d.c.Nodes[n].URL()}.Do("GET", "/api/addons/apps", nil, nil)
Expect(err).NotTo(HaveOccurred())
Expect(anon.Status).To(Equal(401), n)
}
})
It("fails every blocked consumer explicitly and never serves add-on tools to it", func() {
member := func(n string) cluster.Client { return d.c.Token(n, "member") }
for _, n := range nodes {
for _, path := range []string{"/v1/messages", "/v1/responses", "/v1/mcp/chat/completions"} {
r, err := member(n).Do("POST", path, map[string]any{
"model": "mock-model", "metadata": map[string]string{"addon_tools": "true"},
"max_tokens": 5, "input": "hi", "messages": []map[string]any{{"role": "user", "content": "hi"}},
}, nil)
Expect(err).NotTo(HaveOccurred())
Expect(r.Status).To(Equal(http.StatusBadRequest), n+" "+path+": "+r.String())
Expect(errType(r)).To(Equal("addon_tools_unsupported"), n+" "+path)
}
// The chat route itself refuses an account without the feature
// and an anonymous caller, with typed denials.
out := d.chatTool(n, "outsider", "rows-query", map[string]any{"resourceId": res, "table": "Entries"})
Expect(out.Status).To(Equal(403), n+": "+out.String())
anon, err := cluster.Client{Base: d.c.Nodes[n].URL()}.Do("POST", "/v1/chat/completions", map[string]any{
"model": "mock-model", "metadata": map[string]string{"addon_tools": "true"},
"messages": []map[string]any{{"role": "user", "content": "hi"}},
}, nil)
Expect(err).NotTo(HaveOccurred())
Expect(anon.Status).To(Equal(401), n)
}
})
})
// revisionOf reads the revision as the owner of a database. It is used where
// the caller is whoever may read; the revision is the same for everyone.
func (d *deployment) revisionOf(res string) map[string]any {
GinkgoHelper()
return d.revision(cluster.Owner, "member", res)
}
// A person's rights change while the person's tabs and agents are working.
var _ = Describe("Tables authorization when rights change in the middle of a session", Ordered, func() {
var (
d *deployment
res string
row = map[string]any{"Item": "x", "Qty": 1}
)
BeforeAll(func() {
d = deploy(start("0.1.0"), "0.1.0")
res = d.seedLedger(cluster.Owner, "member", "Shared", map[string]any{"Item": "pen", "Qty": 3})
Expect(d.grant(cluster.FrontA, "member", res, "editor", "editor").Status).To(Equal(200))
})
// everywhere runs one check on the owner and on both frontends.
everywhere := func(check func(node string)) {
for _, n := range nodes {
check(n)
}
}
editorCanWrite := func(node string) int {
return d.apply(node, "editor", res, opID("mid"), "rows-insert", d.revisionOf(res), map[string]any{"table": "Entries", "rows": []any{row}}).Status
}
It("denies the next call of a paused key on every node and accepts it again when resumed", func() {
everywhere(func(n string) {
Expect(d.read(d.c.Token(n, "editor"), res, map[string]any{"tool": "rows-query", "table": "Entries"}).Status).To(Equal(200), n)
})
key := d.c.Creds.Users["editor"].KeyID
r := d.tab(cluster.FrontB, "editor", "PATCH", "/api/auth/api-keys/"+key, map[string]any{"disabled": true}, "")
Expect(r.Status).To(Equal(200), r.String())
everywhere(func(n string) {
got := d.read(d.c.Token(n, "editor"), res, map[string]any{"tool": "rows-query", "table": "Entries"})
Expect(got.Status).To(BeElementOf(401, 403), n+": "+got.String())
chat := d.chatTool(n, "editor", "rows-insert", map[string]any{"resourceId": res, "table": "Entries", "rows": []any{row}})
Expect(chat.Status).To(BeElementOf(401, 403), n)
})
// The person's own session is a different credential and still works.
Expect(editorCanWrite(cluster.FrontA)).To(Equal(200))
r = d.tab(cluster.FrontA, "editor", "PATCH", "/api/auth/api-keys/"+key, map[string]any{"disabled": false}, "")
Expect(r.Status).To(Equal(200), r.String())
everywhere(func(n string) {
Expect(d.read(d.c.Token(n, "editor"), res, map[string]any{"tool": "rows-query", "table": "Entries"}).Status).To(Equal(200), n)
})
})
It("denies the browser, the key and the chat everywhere when the add-ons feature is withdrawn", func() {
admin := d.c.Token(cluster.FrontA, "admin")
id := d.c.Creds.Users["editor"].ID
r, err := admin.Do("PUT", "/api/auth/admin/users/"+id+"/permissions", map[string]bool{"addons": false}, nil)
Expect(err).NotTo(HaveOccurred())
Expect(r.Status).To(Equal(200), r.String())
everywhere(func(n string) {
Expect(editorCanWrite(n)).To(Equal(403), n)
Expect(d.read(d.c.Token(n, "editor"), res, map[string]any{"tool": "rows-query", "table": "Entries"}).Status).To(Equal(403), n)
Expect(d.chatTool(n, "editor", "rows-insert", map[string]any{"resourceId": res, "table": "Entries", "rows": []any{row}}).Status).To(Equal(403), n)
apps, err := d.c.Session(n, "editor").Do("GET", "/api/addons/apps", nil, nil)
Expect(err).NotTo(HaveOccurred())
Expect(apps.Status).To(Equal(403), n)
})
r, err = admin.Do("PUT", "/api/auth/admin/users/"+id+"/permissions", map[string]bool{"addons": true}, nil)
Expect(err).NotTo(HaveOccurred())
Expect(r.Status).To(Equal(200), r.String())
Expect(editorCanWrite(cluster.FrontB)).To(Equal(200))
})
It("lowers an editor to a viewer for the next call everywhere and drops the proposals the editor still held", func() {
held := d.propose(cluster.FrontB, "editor", res, "write", opID("held"), map[string]any{"tool": "rows-insert", "table": "Entries", "rows": []any{row}})
Expect(held.Status).To(Equal(200), held.String())
heldID, _ := body(held)["id"].(string)
heldDigest, _ := body(held)["digest"].(string)
Expect(heldID).NotTo(BeEmpty(), held.String())
Expect(len(d.pending(cluster.Owner, "editor"))).To(BeNumerically(">=", 1))
Expect(d.grant(cluster.FrontB, "member", res, "editor", "viewer").Status).To(Equal(200))
everywhere(func(n string) {
Expect(editorCanWrite(n)).To(Equal(403), n)
Expect(d.read(d.c.Token(n, "editor"), res, map[string]any{"tool": "rows-query", "table": "Entries"}).Status).To(Equal(200), n)
})
// The proposal the editor held does not apply any more.
Expect(d.confirm(cluster.FrontA, "editor", heldID, heldDigest).Status).NotTo(Equal(200))
everywhere(func(n string) {
for _, p := range d.pending(n, "editor") {
Expect(p["id"]).NotTo(Equal(heldID), n)
}
})
Expect(d.revoke(cluster.Owner, "member", res, "editor").Status).To(Equal(200))
everywhere(func(n string) {
Expect(d.read(d.c.Token(n, "editor"), res, map[string]any{"tool": "rows-query", "table": "Entries"}).Status).To(Equal(403), n)
})
Expect(d.grant(cluster.FrontA, "member", res, "editor", "editor").Status).To(Equal(200))
})
It("refuses the session and the key of a disabled account on every node", func() {
admin := d.c.Token(cluster.FrontB, "admin")
id := d.c.Creds.Users["editor"].ID
r, err := admin.Do("PUT", "/api/auth/admin/users/"+id+"/status", map[string]string{"status": "disabled"}, nil)
Expect(err).NotTo(HaveOccurred())
Expect(r.Status).To(Equal(200), r.String())
everywhere(func(n string) {
Expect(editorCanWrite(n)).To(BeElementOf(401, 403), n)
got := d.read(d.c.Token(n, "editor"), res, map[string]any{"tool": "rows-query", "table": "Entries"})
Expect(got.Status).To(BeElementOf(401, 403), n)
})
r, err = admin.Do("PUT", "/api/auth/admin/users/"+id+"/status", map[string]string{"status": "active"}, nil)
Expect(err).NotTo(HaveOccurred())
Expect(r.Status).To(Equal(200), r.String())
Expect(editorCanWrite(cluster.FrontA)).To(Equal(200))
})
})
// Authority is the host's. Whatever a browser, a script or a model puts in a
// request about who it is or whether it was approved is only data.
var _ = Describe("Tables authority and approval cannot be forged", Ordered, func() {
var (
d *deployment
res string
row = map[string]any{"Item": "x", "Qty": 1}
)
BeforeAll(func() {
d = deploy(start("0.1.0"), "0.1.0")
res = d.seedLedger(cluster.FrontA, "member", "Private", map[string]any{"Item": "pen", "Qty": 3})
Expect(d.grant(cluster.Owner, "member", res, "editor", "editor").Status).To(Equal(200))
Expect(d.grant(cluster.Owner, "member", res, "viewer", "viewer").Status).To(Equal(200))
})
It("ignores actor, forwarding and authority headers and fields on every node", func() {
forged := map[string]string{
"X-Forwarded-User": d.c.Creds.Users["member"].ID, "X-Forwarded-Actor": d.c.Creds.Users["member"].ID,
"X-Addon-Actor": d.c.Creds.Users["member"].ID, "X-LocalAI-Actor": d.c.Creds.Users["member"].ID,
"X-Remote-User": d.c.Creds.Users["member"].ID, "X-User-Id": d.c.Creds.Users["member"].ID,
"X-Addon-Peer": "owner",
}
for _, n := range nodes {
// A stranger with forged identity headers still reads nothing.
stranger := d.c.Token(n, "friend")
stranger.Headers = forged
r := d.read(stranger, res, map[string]any{"tool": "rows-query", "table": "Entries"})
Expect(r.Status).To(Equal(403), n+": "+r.String())
Expect(r.String()).NotTo(ContainSubstring("pen"))
// A viewer with them still cannot write.
viewer := d.c.Session(n, "viewer")
viewer.Headers = forged
w, err := viewer.Do("POST", "/api/addons/resources/"+res+"/apply", map[string]any{
"operationId": opID("forge"), "generation": d.gen, "revision": d.revisionOf(res), "tool": "rows-insert",
"arguments": map[string]any{"table": "Entries", "rows": []any{row}},
}, map[string]string{"X-LocalAI-Confirm": res})
Expect(err).NotTo(HaveOccurred())
Expect(w.Status).To(Equal(403), n+": "+w.String())
}
Expect(d.rowCount(cluster.Owner, "member", res)).To(Equal(1))
})
It("derives the action of a proposal from the tool kind, so an editor cannot confirm a schema change as a write", func() {
rename := map[string]any{"tool": "databases-rename", "name": "Stolen"}
for _, n := range nodes {
// Model rules R4 and S4: the editor claims the data action for the
// rename tool, and the schema action; both are refused.
for _, action := range []string{"write", "schema"} {
r := d.propose(n, "editor", res, action, opID("kind-e"), rename)
Expect(r.Status).To(Equal(403), n+" editor "+action+": "+r.String())
}
// The owner role may not claim the data action for it either.
r := d.propose(n, "member", res, "write", opID("kind-m"), rename)
Expect(r.Status).To(Equal(403), n+" owner write: "+r.String())
}
Expect(d.pending(cluster.Owner, "editor")).To(BeEmpty())
// The owner role proposes the rename under the schema action.
r := d.propose(cluster.FrontA, "member", res, "schema", opID("kind-ok"), rename)
Expect(r.Status).To(Equal(200), r.String())
})
It("denies authority, role and approval fields in the arguments of every consumer and creates nothing", func() {
before := len(d.pending(cluster.Owner, "editor"))
for _, field := range []map[string]any{
{"actor": d.c.Creds.Users["member"].ID}, {"userId": d.c.Creds.Users["member"].ID}, {"role": "owner"},
{"approved": true}, {"confirmed": true}, {"proposalId": "x"}, {"authority": map[string]any{"scope": "apply"}},
} {
args := map[string]any{"tool": "rows-insert", "table": "Entries", "rows": []any{row}}
for k, v := range field {
args[k] = v
}
for _, n := range nodes {
r := d.propose(n, "editor", res, "write", opID("fa"), args)
// A field the tool does not declare is refused as invalid, with
// the same typed 400 from every node, and nothing is stored.
Expect(r.Status).To(Equal(400), fmt.Sprintf("%s %v: %s", n, field, r.String()))
Expect(errType(r)).To(Equal("invalid_request"), fmt.Sprintf("%s %v: %s", n, field, r.String()))
chatArgs := map[string]any{"resourceId": res, "table": "Entries", "rows": []any{row}}
for k, v := range field {
chatArgs[k] = v
}
// The chat consumer's own argument binding rejects the field
// before the add-on is asked: the turn is served, nothing ran
// (the pending list below proves it).
chat := d.chatTool(n, "editor", "rows-insert", chatArgs)
Expect(chat.Status).To(Equal(200), chat.String())
Expect(chat.String()).NotTo(ContainSubstring("pen"), fmt.Sprintf("%s %v", n, field))
}
}
Expect(len(d.pending(cluster.FrontB, "editor"))).To(Equal(before))
// The tool of the arguments must be the declared one, exactly.
for _, tool := range []string{"Rows-insert", "rows-insert ", "tables-drop-all", ""} {
r := d.read(d.c.Token(cluster.FrontA, "editor"), res, map[string]any{"tool": tool, "table": "Entries"})
Expect(r.Status).To(Equal(403), tool+": "+r.String())
Expect(r.String()).NotTo(ContainSubstring("pen"))
}
Expect(d.rowCount(cluster.Owner, "member", res)).To(Equal(1))
})
It("applies a proposal only on the confirmation of the proposer's own tab, with its digest, exactly once", func() {
op := opID("approve")
p := d.propose(cluster.FrontA, "editor", res, "write", op, map[string]any{"tool": "rows-insert", "table": "Entries", "rows": []any{row}})
Expect(p.Status).To(Equal(200), p.String())
id, _ := body(p)["id"].(string)
digest, _ := body(p)["digest"].(string)
Expect(id).To(Equal(op))
bad := map[string]*cluster.Response{}
// A key never approves, whoever it belongs to.
// The header that names the proposal is sent as well: the key is what
// fails, not a missing header.
named := map[string]string{"X-LocalAI-Confirm": id, "Sec-Fetch-Site": "same-origin", "Sec-Fetch-Mode": "cors", "Sec-Fetch-Dest": "empty"}
keyed, _ := d.c.Token(cluster.FrontB, "editor").Do("POST", "/api/addons/proposals/"+id+"/confirm", map[string]any{"digest": digest}, named)
bad["the proposer's key"] = keyed
keyedOwner, _ := d.c.Token(cluster.Owner, "member").Do("POST", "/api/addons/proposals/"+id+"/confirm", map[string]any{"digest": digest}, named)
bad["the owner's key"] = keyedOwner
keyedAdmin, _ := d.c.Token(cluster.FrontA, "admin").Do("POST", "/api/addons/proposals/"+id+"/confirm", map[string]any{"digest": digest}, named)
bad["an administrator's key"] = keyedAdmin
// The resource owner's own tab is not the proposer's.
bad["the resource owner's tab"] = d.confirm(cluster.FrontB, "member", id, digest)
// A viewer's tab, an administrator's tab.
bad["a viewer's tab"] = d.confirm(cluster.Owner, "viewer", id, digest)
bad["an administrator's tab"] = d.confirm(cluster.FrontA, "admin", id, digest)
// The proposer's tab without the confirmation header, with another ID
// and with a tool-call marker.
bad["no confirmation header"] = d.tab(cluster.FrontB, "editor", "POST", "/api/addons/proposals/"+id+"/confirm", map[string]any{"digest": digest}, "")
bad["another proposal's header"] = d.tab(cluster.FrontB, "editor", "POST", "/api/addons/proposals/"+id+"/confirm", map[string]any{"digest": digest}, "other-proposal")
marked, _ := d.c.Session(cluster.FrontB, "editor").Do("POST", "/api/addons/proposals/"+id+"/confirm", map[string]any{"digest": digest},
map[string]string{"X-LocalAI-Confirm": id, "X-LocalAI-Tool-Call": "1"})
bad["a tool-call marker"] = marked
for what, r := range bad {
Expect(r.Status).To(Equal(403), what+": "+r.String())
}
Expect(d.rowCount(cluster.Owner, "member", res)).To(Equal(1), "nothing was applied by a forged approval")
// A wrong digest is not the proposal the person saw.
wrong := d.confirm(cluster.FrontB, "editor", id, "sha256:"+strings.Repeat("0", 64))
Expect(wrong.Status).NotTo(Equal(200), wrong.String())
Expect(d.rowCount(cluster.Owner, "member", res)).To(Equal(1))
// The proposer's own tab, through another node than the one that
// stored the proposal, applies it once; repeats are the same receipt.
ok := d.confirm(cluster.FrontB, "editor", id, digest)
Expect(ok.Status).To(Equal(200), ok.String())
Expect(body(ok)["state"]).To(Equal("applied"))
for _, n := range nodes {
again := d.confirm(n, "editor", id, digest)
Expect(again.Status).To(Equal(200), n+": "+again.String())
Expect(body(again)["state"]).To(Equal("applied"))
}
Expect(d.rowCount(cluster.Owner, "member", res)).To(Equal(2))
})
})
@@ -0,0 +1,157 @@
// SPDX-License-Identifier: MIT
// Command tables-cluster runs the acceptance fixture for the browser checks:
// one owner and two forwarding frontends over the real carrier, a local
// catalog that serves the packaged Tables, and Tables installed and enabled
// through a frontend as an administrator would. It writes a ready file with
// the addresses, the accounts and a loopback control address, and runs until
// it is stopped. The control address lets a browser spec stop and start the
// owner, so it can look at the page when the owner is gone.
package main
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/mudler/LocalAI/tests/addons/foundation/cluster"
"github.com/mudler/LocalAI/tests/addons/tablesacceptance/tablespkg"
)
func main() {
dir := flag.String("dir", "", "directory of the cluster state (created)")
server := flag.String("server", "", "UI test server executable")
mock := flag.String("mock-backend", "", "mock backend executable")
tables := flag.String("tables", "", "Tables executable")
root := flag.String("root", "", "repository root")
ready := flag.String("ready", "", "file written when the cluster is serving")
flag.Parse()
if err := run(*dir, *root, cluster.Paths{Server: *server, Mock: *mock}, *tables, *ready); err != nil {
fmt.Fprintln(os.Stderr, "tables-cluster:", err)
os.Exit(1)
}
}
func run(dir, root string, p cluster.Paths, tables, ready string) error {
if dir == "" || root == "" || ready == "" || p.Server == "" || p.Mock == "" || tables == "" {
return fmt.Errorf("-dir, -root, -server, -mock-backend, -tables and -ready are required")
}
scratch := filepath.Join(dir, "packages")
if err := os.MkdirAll(scratch, 0o700); err != nil {
return err
}
releases, err := tablespkg.Build(root, scratch, tables, nil, "0.1.0")
if err != nil {
return err
}
c, err := cluster.New(filepath.Join(dir, "cluster"), p)
if err != nil {
return err
}
defer c.Down()
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
interrupted := make(chan struct{})
go func() {
<-stop
c.Down()
close(interrupted)
os.Exit(1)
}()
if _, err := c.Library.AddArchive("tables", "0.1.0", releases["0.1.0"].Archive); err != nil {
return err
}
if err := c.Up(); err != nil {
return err
}
admin := c.Token(cluster.FrontA, "admin")
for i, action := range []string{"install", "enable"} {
args := map[string]any{}
if action == "install" {
args = map[string]any{"sourceId": "fixture", "version": "0.1.0"}
}
r, err := admin.Do("POST", "/api/addons/dispatch", map[string]any{"operationId": fmt.Sprintf("browser-%s-%d", action, i), "addonId": "tables", "generation": 1, "action": action, "arguments": args}, nil)
if err != nil || r.Status != 200 {
return fmt.Errorf("%s: %v %v", action, err, r)
}
}
deadline := time.Now().Add(60 * time.Second)
for {
r, err := admin.Do("GET", "/api/addons/apps", nil, nil)
if err == nil && r.Status == 200 && strings.Contains(string(r.Body), `"state":"running"`) {
break
}
if time.Now().After(deadline) {
return fmt.Errorf("Tables did not start")
}
time.Sleep(200 * time.Millisecond)
}
// Owner control: stop (as a crash) and start again.
token := make([]byte, 16)
_, _ = rand.Read(token)
secret := hex.EncodeToString(token)
mux := http.NewServeMux()
guard := func(w http.ResponseWriter, r *http.Request) bool {
if r.Method != http.MethodPost || subtle.ConstantTimeCompare([]byte(r.Header.Get("X-Control")), []byte(secret)) != 1 {
http.Error(w, "denied", http.StatusForbidden)
return false
}
return true
}
mux.HandleFunc("/owner/stop", func(w http.ResponseWriter, r *http.Request) {
if guard(w, r) {
c.Nodes[cluster.Owner].Kill()
_, _ = w.Write([]byte("stopped"))
}
})
mux.HandleFunc("/owner/start", func(w http.ResponseWriter, r *http.Request) {
if !guard(w, r) {
return
}
n := c.Nodes[cluster.Owner]
if err := n.Start(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := n.Ready(120 * time.Second); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_, _ = w.Write([]byte("started"))
})
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return err
}
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
go func() { _ = srv.Serve(l) }()
defer func() { _ = srv.Close() }()
out := map[string]any{"urls": map[string]string{}, "users": c.Creds.Users, "dir": c.Config.Dir,
"control": map[string]string{"url": "http://" + l.Addr().String(), "token": secret}}
for name, n := range c.Nodes {
out["urls"].(map[string]string)[name] = n.URL()
}
b, _ := json.MarshalIndent(out, "", " ")
tmp := filepath.Clean(ready) + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return err
}
if err := os.Rename(tmp, ready); err != nil {
return err
}
<-interrupted
return nil
}
@@ -0,0 +1,265 @@
// SPDX-License-Identifier: MIT
package tablesacceptance_test
import (
"fmt"
"net/http"
"sync"
"github.com/mudler/LocalAI/tests/addons/foundation/cluster"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Tables staleness, replay and races across the owner and two frontends", Ordered, func() {
var (
d *deployment
res string
)
BeforeAll(func() {
d = deploy(start("0.1.0"), "0.1.0")
res = d.seedLedger(cluster.FrontA, "member", "Shared", map[string]any{"Item": "pen", "Qty": 3})
Expect(d.grant(cluster.Owner, "member", res, "editor", "editor").Status).To(Equal(200))
})
It("refuses a change prepared on an old schema or data revision from two tabs of two people and applies nothing", func() {
// Both people open the database at the same revision.
ownerView := d.revision(cluster.FrontA, "member", res)
editorView := d.revision(cluster.FrontB, "editor", res)
Expect(editorView).To(Equal(ownerView))
// The editor changes the data first, through another node.
first := d.apply(cluster.Owner, "editor", res, opID("st-1"), "rows-insert", editorView, map[string]any{"table": "Entries", "rows": []any{map[string]any{"Item": "ink", "Qty": 1}}})
Expect(first.Status).To(Equal(200), first.String())
ids, _ := body(first)["result"].(map[string]any)["ids"].([]any)
Expect(ids).To(HaveLen(1))
// The owner's stale tab: a data change and a schema change both fail
// as stale and change nothing.
for _, c := range []struct {
tool string
args map[string]any
}{
{"rows-insert", map[string]any{"table": "Entries", "rows": []any{map[string]any{"Item": "late", "Qty": 9}}}},
{"rows-update", map[string]any{"table": "Entries", "ids": ids, "set": map[string]any{"Qty": 7}}},
{"columns-add", map[string]any{"table": "Entries", "column": "Note", "type": "text"}},
} {
op := opID("st-old")
receiptsBefore := len(d.receipts(res))
r := d.apply(cluster.FrontB, "member", res, op, c.tool, ownerView, c.args)
Expect(len(d.receipts(res))).To(Equal(receiptsBefore), "a stale change leaves no receipt")
Expect(r.Status).To(Equal(http.StatusConflict), c.tool+": "+r.String())
Expect(errType(r)).To(Equal("stale"), c.tool)
_ = op
}
Expect(d.rowCount(cluster.FrontA, "member", res)).To(Equal(2))
// The owner changes the schema; the editor's old view is stale for a
// row change that uses the new column too, and never half-applies.
Expect(d.applyOK(cluster.FrontA, "member", res, "columns-add", map[string]any{"table": "Entries", "column": "Note", "type": "text"})).NotTo(BeNil())
op := opID("st-schema")
r := d.apply(cluster.FrontB, "editor", res, op, "rows-insert", editorView, map[string]any{"table": "Entries", "rows": []any{map[string]any{"Item": "x", "Note": "n"}}})
Expect(r.Status).To(Equal(http.StatusConflict), r.String())
Expect(errType(r)).To(Equal("stale"))
Expect(d.rowCount(cluster.Owner, "editor", res)).To(Equal(2))
// A proposal prepared before the revision moved is rejected when it is
// confirmed, and the data stays as it is.
held := d.propose(cluster.FrontA, "editor", res, "write", opID("st-prop"), map[string]any{"tool": "rows-insert", "table": "Entries", "rows": []any{map[string]any{"Item": "held", "Qty": 2}}})
Expect(held.Status).To(Equal(200), held.String())
heldID, _ := body(held)["id"].(string)
heldDigest, _ := body(held)["digest"].(string)
d.applyOK(cluster.Owner, "member", res, "columns-add", map[string]any{"table": "Entries", "column": "Tag", "type": "text"})
receiptsAtHold := len(d.receipts(res))
got := d.confirm(cluster.FrontB, "editor", heldID, heldDigest)
Expect(body(got)["state"]).To(Equal("rejected"), got.String())
Expect(len(d.receipts(res))).To(Equal(receiptsAtHold), "the rejected proposal left no receipt")
Expect(d.rowCount(cluster.FrontA, "member", res)).To(Equal(2))
// With the revision it read, the same person applies.
ok := d.apply(cluster.FrontB, "editor", res, opID("st-fresh"), "rows-insert", d.revision(cluster.FrontB, "editor", res),
map[string]any{"table": "Entries", "rows": []any{map[string]any{"Item": "fresh", "Qty": 4}}})
Expect(ok.Status).To(Equal(200), ok.String())
Expect(d.rowCount(cluster.Owner, "member", res)).To(Equal(3))
})
It("replays an operation ID as one receipt from every node, conflicts on a different request and never applies twice or under a new ID", func() {
before := d.rowCount(cluster.Owner, "member", res)
receiptsBefore := len(d.receipts(res))
rev := d.revision(cluster.FrontA, "member", res)
args := map[string]any{"table": "Entries", "rows": []any{map[string]any{"Item": "once", "Qty": 1}}}
op := opID("replay")
first := d.apply(cluster.FrontA, "editor", res, op, "rows-insert", rev, args)
Expect(first.Status).To(Equal(200), first.String())
hash := body(first)["requestHash"]
Expect(hash).NotTo(BeEmpty())
// No sticky session: the replays alternate over every node and over
// the credential kinds, and each is the same receipt.
for _, n := range append(append([]string{}, nodes...), nodes...) {
again := d.apply(n, "editor", res, op, "rows-insert", rev, args)
Expect(again.Status).To(Equal(200), n+": "+again.String())
Expect(body(again)["revision"]).To(Equal(body(first)["revision"]))
Expect(body(again)["requestHash"]).To(Equal(hash))
Expect(body(again)["result"]).To(Equal(body(first)["result"]))
}
// Another request under the same ID is a conflict on every node.
for _, n := range nodes {
other := d.apply(n, "editor", res, op, "rows-insert", rev, map[string]any{"table": "Entries", "rows": []any{map[string]any{"Item": "other", "Qty": 2}}})
Expect(other.Status).To(Equal(http.StatusConflict), n+": "+other.String())
Expect(errType(other)).To(Equal("operation_conflict"))
}
Expect(d.rowCount(cluster.FrontB, "member", res)).To(Equal(before + 1))
Expect(len(d.receipts(res))).To(Equal(receiptsBefore+1), "the add-on committed the operation once")
// A burst from the three nodes at once, one fresh ID each: every
// acknowledged ID is applied exactly once, and the add-on holds no
// receipt that no one asked for (no retry under a new ID).
var wg sync.WaitGroup
results := make([]*cluster.Response, 12)
ops := make([]string, 12)
gate := make(chan struct{})
for i := range results {
wg.Add(1)
ops[i] = opID("burst")
go func(i int) {
defer GinkgoRecover()
defer wg.Done()
<-gate
// Revisions race, so a stale answer is correct; the caller
// retries with the revision it reads, under the same ID.
for attempt := 0; attempt < 40; attempt++ {
n := nodes[(i+attempt)%len(nodes)]
rev := d.revision(n, "member", res)
r := d.apply(n, "member", res, ops[i], "rows-insert", rev, map[string]any{"table": "Entries", "rows": []any{map[string]any{"Item": fmt.Sprintf("b%d", i), "Qty": i}}})
// A stale answer retries with the revision just read. A
// busy owner (the add-on admits four calls at once) answers
// outcome_unknown: the documented recovery is the same call
// under the same ID, which the add-on applies once.
stale := r.Status == http.StatusConflict && errType(r) == "stale"
unknown := r.Status == http.StatusServiceUnavailable && errType(r) == "outcome_unknown"
if !stale && !unknown {
results[i] = r
return
}
}
}(i)
}
close(gate)
wg.Wait()
applied := 0
for i, r := range results {
Expect(r).NotTo(BeNil(), ops[i])
Expect(r.Status).To(Equal(200), ops[i]+": "+r.String())
applied++
}
Expect(len(d.receipts(res))).To(Equal(receiptsBefore+1+applied), "one receipt per acknowledged ID and none more")
Expect(d.rowCount(cluster.FrontA, "member", res)).To(Equal(before + 1 + applied))
})
It("settles a revocation that races a confirmation: one outcome, applied at most once, and no work after the revocation", func() {
unresolved := ""
blockedSeen := 0
for round := 0; round < 6; round++ {
Expect(d.grant(cluster.Owner, "member", res, "editor", "editor").Status).To(Equal(200))
if unresolved != "" {
// A confirmation that the revocation cut short is unresolved
// until its receipt is read, and it blocks every other write
// on the database. The block is the host's, not the add-on's.
blocked := d.propose(cluster.FrontB, "editor", res, "write", opID("blocked"), map[string]any{"tool": "rows-insert", "table": "Entries", "rows": []any{map[string]any{"Item": "blocked"}}})
Expect(blocked.Status).To(Equal(403), blocked.String())
blockedSeen++
rec := d.tab(nodes[round%3], "editor", "POST", "/api/addons/proposals/"+unresolved+"/reconcile", map[string]any{}, unresolved)
Expect(rec.Status).To(Equal(200), rec.String())
if body(rec)["state"] == "outcome-unknown" {
// The add-on has no receipt, which does not prove it never
// ran: the block stays until the owner role resolves it.
stillBlocked := d.propose(cluster.FrontA, "editor", res, "write", opID("still"), map[string]any{"tool": "rows-insert", "table": "Entries", "rows": []any{map[string]any{"Item": "blocked"}}})
Expect(stillBlocked.Status).To(Equal(403), stillBlocked.String())
listed := d.tab(nodes[(round+1)%3], "member", "GET", "/api/addons/resources/"+res+"/proposals?state=unresolved", nil, res)
Expect(listed.Status).To(Equal(200), listed.String())
entries, _ := body(listed)["proposals"].([]any)
Expect(entries).NotTo(BeEmpty(), listed.String())
// Abandoned earlier rounds stay listed; this one is the open one.
var entry map[string]any
for _, e := range entries {
if m := e.(map[string]any); m["id"] == unresolved {
entry = m
}
}
Expect(entry).NotTo(BeNil(), listed.String())
Expect(entry["state"]).To(Equal("outcome-unknown"))
// Neither the proposer nor an administrator can resolve it.
for _, user := range []string{"editor", "admin"} {
denied := d.tab(cluster.FrontB, user, "POST", "/api/addons/proposals/"+unresolved+"/resolve", map[string]any{"digest": entry["digest"], "reason": "not mine"}, unresolved)
Expect(denied.Status).To(Equal(403), user+": "+denied.String())
}
done := d.tab(nodes[(round+2)%3], "member", "POST", "/api/addons/proposals/"+unresolved+"/resolve", map[string]any{"digest": entry["digest"], "reason": "checked the rows"}, unresolved)
Expect(done.Status).To(Equal(200), done.String())
Expect(body(done)["state"]).To(Equal("abandoned-unknown"), done.String())
}
unresolved = ""
}
before := d.rowCount(cluster.Owner, "member", res)
receiptsBefore := len(d.receipts(res))
op := opID("race")
p := d.propose(cluster.FrontA, "editor", res, "write", op, map[string]any{"tool": "rows-insert", "table": "Entries", "rows": []any{map[string]any{"Item": "race", "Qty": round}}})
Expect(p.Status).To(Equal(200), p.String())
digest, _ := body(p)["digest"].(string)
var confirmed, revoked *cluster.Response
var wg sync.WaitGroup
gate := make(chan struct{})
wg.Add(2)
go func() {
defer GinkgoRecover()
defer wg.Done()
<-gate
confirmed = d.confirm(nodes[round%3], "editor", op, digest)
}()
go func() {
defer GinkgoRecover()
defer wg.Done()
<-gate
revoked = d.revoke(nodes[(round+1)%3], "member", res, "editor")
}()
close(gate)
wg.Wait()
Expect(revoked.Status).To(Equal(200), revoked.String())
// The revocation is acknowledged: the editor is out everywhere.
for _, n := range nodes {
Expect(d.read(d.c.Token(n, "editor"), res, map[string]any{"tool": "rows-query", "table": "Entries"}).Status).To(Equal(403), n)
}
after := d.rowCount(cluster.FrontB, "member", res)
switch {
case confirmed.Status == 200:
Expect(body(confirmed)["state"]).To(Equal("applied"))
Expect(after).To(Equal(before+1), fmt.Sprintf("round %d: applied once", round))
case confirmed.Status == 503 && errType(confirmed) == "outcome_unknown":
unresolved = op
Expect(after).To(BeElementOf(before, before+1), fmt.Sprintf("round %d: unknown, at most once", round))
Expect(len(d.receipts(res))).To(Equal(receiptsBefore + after - before))
default:
Expect(confirmed.Status).To(BeElementOf(403, 409, 503), confirmed.String())
Expect(after).To(Equal(before), fmt.Sprintf("round %d: denied, nothing applied (%s)", round, confirmed.String()))
Expect(len(d.receipts(res))).To(Equal(receiptsBefore))
}
// The same confirmation again, after the revocation, does nothing.
// It may return the stored record of what already happened, and it
// can only say "applied" when the row is there.
late := d.confirm(nodes[(round+2)%3], "editor", op, digest)
if late.Status == 200 && body(late)["state"] == "applied" {
Expect(after).To(Equal(before+1), "an applied record needs its row: "+late.String())
}
Expect(late.Status == 200 && body(late)["state"] == "applied" && after == before).To(BeFalse(), late.String())
Expect(d.rowCount(cluster.Owner, "member", res)).To(Equal(after))
if late.Status == 200 && (body(late)["state"] == "applied" || body(late)["state"] == "rejected") {
// The repeated call read the receipt: nothing is left to settle.
unresolved = ""
}
}
// Leave the grant as the other specs expect it.
Expect(d.grant(cluster.Owner, "member", res, "editor", "editor").Status).To(Equal(200))
})
})
@@ -0,0 +1,159 @@
// SPDX-License-Identifier: MIT
package tablesacceptance_test
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"runtime"
"sort"
"strings"
"testing"
. "github.com/onsi/gomega"
)
// TestEvidenceManifest checks the evidence manifest the acceptance run wrote.
// scripts/addons/tables_acceptance.sh runs it after the suite with
// TABLES_ACCEPTANCE_CHECK_EVIDENCE=1; a plain `go test` of the package skips
// it, because the manifest does not exist before the suite ran.
//
// The manifest is evidence, so the check is strict: the platform is the native
// one, the commands are listed, both package digests are well formed and
// distinct, the case inventory is exactly the inventory of suites.json, every
// case passed, and a case that did not has a retained trace that exists.
func TestEvidenceManifest(t *testing.T) {
if os.Getenv("TABLES_ACCEPTANCE_CHECK_EVIDENCE") != "1" {
// Ginkgo's Skip works only inside a spec; this is a plain Go test.
t.Skip("runs after the suite, from scripts/addons/tables_acceptance.sh") //nolint:forbidigo
}
g := NewWithT(t)
// Every broken rule is reported, not only the first one.
var problems []string
problem := func(format string, args ...any) { problems = append(problems, fmt.Sprintf(format, args...)) }
defer func() { g.Expect(problems).To(BeEmpty()) }()
_, file, _, _ := runtime.Caller(0)
root := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", ".."))
path, err := evidenceLocation(os.Getenv("TABLES_ACCEPTANCE_EVIDENCE"), root)
g.Expect(err).NotTo(HaveOccurred())
raw, err := os.ReadFile(path)
g.Expect(err).NotTo(HaveOccurred(), "the evidence manifest is missing")
var ev evidence
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
g.Expect(dec.Decode(&ev)).To(Succeed(), "the evidence manifest does not parse")
g.Expect(ev.Schema).To(Equal(1), "unexpected schema")
g.Expect(ev.Suite).To(Equal("tables-acceptance"))
g.Expect(ev.Platform.OS).To(Equal(runtime.GOOS), "the evidence is not of this native platform: %+v", ev.Platform)
g.Expect(ev.Platform.Arch).To(Equal(runtime.GOARCH), "the evidence is not of this native platform: %+v", ev.Platform)
g.Expect(ev.Platform.Native).To(BeTrue(), "the evidence is not of this native platform: %+v", ev.Platform)
g.Expect(ev.Publication).To(HavePrefix("none"), "the manifest must say nothing was published")
joined := make([]string, 0, len(ev.Commands))
for _, c := range ev.Commands {
joined = append(joined, strings.Join(c, " "))
}
for _, want := range []string{"scripts/addons/package.sh", "go test"} {
found := false
for _, c := range joined {
if strings.Contains(c, want) {
found = true
}
}
if !found {
problem("no recorded command contains %q: %v", want, joined)
}
}
digest := regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
seen := map[string]string{}
for _, v := range []string{"0.1.0", "0.2.0"} {
d, ok := ev.Artifact.Versions[v]
g.Expect(ok).To(BeTrue(), "no digest of version %s", v)
for what, value := range map[string]string{"archive": d.Archive, "image": d.Image} {
if !digest.MatchString(value) {
problem("%s digest of %s is malformed: %q", what, v, value)
}
if other, dup := seen[value]; dup {
problem("%s digest of %s equals %s", what, v, other)
}
seen[value] = v + " " + what
}
}
if ev.Artifact.Addon != "tables" {
problem("the artifact is %q", ev.Artifact.Addon)
}
inventory, err := os.ReadFile(filepath.Join(root, "scripts/addons/suites.json"))
g.Expect(err).NotTo(HaveOccurred())
var suites map[string]struct {
Cases []string `json:"cases"`
}
g.Expect(json.Unmarshal(inventory, &suites)).To(Succeed())
var want, wantBrowser []string
for _, c := range suites["tables-acceptance"].Cases {
if name, ok := strings.CutPrefix(c, "go: "); ok {
want = append(want, name)
}
if strings.HasPrefix(c, "tables-acceptance: ") {
wantBrowser = append(wantBrowser, c)
}
}
var got []string
for _, c := range ev.Cases {
got = append(got, c.Name)
if c.State != "passed" {
problem("case %q is %s", c.Name, c.State)
if c.Trace == "" {
problem("case %q failed and no trace was retained", c.Name)
} else if _, err := os.Stat(c.Trace); err != nil {
problem("the trace of %q is missing: %v", c.Name, err)
}
}
}
var gotBrowser []string
for _, c := range ev.Browser {
gotBrowser = append(gotBrowser, c.Name)
if c.State != "passed" {
problem("browser case %q is %s", c.Name, c.State)
if c.Trace == "" {
problem("browser case %q failed and no trace was retained", c.Name)
} else if _, err := os.Stat(c.Trace); err != nil {
problem("the trace of %q is missing: %v", c.Name, err)
}
}
}
sort.Strings(wantBrowser)
sort.Strings(gotBrowser)
if strings.Join(wantBrowser, "\n") != strings.Join(gotBrowser, "\n") {
problem("the browser cases of the manifest differ from suites.json:\nmanifest: %q\ninventory: %q", gotBrowser, wantBrowser)
}
if len(wantBrowser) == 0 {
problem("the inventory has no browser case")
}
sort.Strings(want)
sort.Strings(got)
if strings.Join(want, "\n") != strings.Join(got, "\n") {
problem("the case inventory of the manifest differs from suites.json:\nmanifest: %q\ninventory: %q", got, want)
}
if len(want) == 0 {
problem("the inventory has no Go case")
}
}
// A relative evidence path resolves against the package directory the test
// binary runs in, which once left a stale manifest inside the tree. The suite
// refuses it, whatever the writer or the checker.
func TestEvidencePathMustBeAbsolute(t *testing.T) {
g := NewWithT(t)
_, err := evidenceLocation(".cache/addons/tables-acceptance-evidence.json", "/repo")
g.Expect(err).To(HaveOccurred(), "a relative evidence path must be refused")
_, err = evidenceLocation("../evidence.json", "/repo")
g.Expect(err).To(HaveOccurred(), "a parent-relative evidence path must be refused")
p, err := evidenceLocation("", "/repo")
g.Expect(err).NotTo(HaveOccurred())
g.Expect(p).To(Equal("/repo/.cache/addons/tables-acceptance-evidence.json"), "the default is under the repository cache")
p, err = evidenceLocation("/work/evidence.json", "/repo")
g.Expect(err).NotTo(HaveOccurred())
g.Expect(p).To(Equal("/work/evidence.json"), "an absolute path is used as given")
}
@@ -0,0 +1,211 @@
// SPDX-License-Identifier: MIT
package tablesacceptance_test
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"time"
"github.com/mudler/LocalAI/tests/addons/foundation/cluster"
"github.com/mudler/LocalAI/tests/addons/tablesacceptance/tablespkg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// snapshot lists a directory tree with the size and time of every file, so a
// spec can prove that something did not touch it.
func snapshot(root string) []string {
GinkgoHelper()
var out []string
err := filepath.WalkDir(root, func(p string, e fs.DirEntry, err error) error {
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
info, err := e.Info()
if err != nil {
return err
}
out = append(out, fmt.Sprintf("%s %d %d", p, info.Size(), info.ModTime().UnixNano()))
return nil
})
Expect(err).NotTo(HaveOccurred())
sort.Strings(out)
return out
}
// Faults that the real executable cannot show on its own: an artifact that
// expires, a transfer that is withdrawn and a change that committed and lost
// its answer. They run against a test-only build of the same executable
// (addons/tables/cmd/tables-faults) that a spec arms by creating a file in the
// "fault" directory of the add-on's data, and a short artifact lifetime that
// only the fixture's launcher sets. The product executable has neither.
var _ = Describe("Tables with faults armed in the test-only build", Ordered, func() {
var (
d *deployment
res string
data string
)
row := map[string]any{"Item": "ink", "Qty": 2}
arm := func(name string) {
GinkgoHelper()
Expect(os.MkdirAll(filepath.Join(data, "fault"), 0o700)).To(Succeed())
Expect(os.WriteFile(filepath.Join(data, "fault", name), nil, 0o600)).To(Succeed())
}
disarm := func(name string) { _ = os.Remove(filepath.Join(data, "fault", name)) }
BeforeAll(func() {
scratch, err := os.MkdirTemp("", "tables-faults-*")
Expect(err).NotTo(HaveOccurred())
DeferCleanup(func() {
_ = filepath.WalkDir(scratch, func(p string, e fs.DirEntry, err error) error {
if err == nil && e.IsDir() {
_ = os.Chmod(p, 0o700)
}
return nil
})
_ = os.RemoveAll(scratch)
})
exe := filepath.Join(scratch, "tables-faults")
Expect(tablespkg.BuildExecutableFrom(repoRoot, exe, "./addons/tables/cmd/tables-faults", func(argv ...string) { record(argv...) })).To(Succeed())
built, err := tablespkg.Build(repoRoot, scratch, exe, func(argv ...string) { record(argv...) }, "0.1.0")
Expect(err).NotTo(HaveOccurred())
c := startFrom(func(cfg *cluster.Config) { cfg.ArtifactExpiryMillis = 12000 }, built, "0.1.0")
d = deploy(c, "0.1.0")
data = filepath.Join(c.Config.Nodes[cluster.Owner].Root, "data", "tables")
res = d.seedLedger(cluster.FrontA, "member", "Faults", map[string]any{"Item": "pen", "Qty": 3})
Expect(d.grant(cluster.Owner, "member", res, "editor", "editor").Status).To(Equal(200))
})
It("denies an expired file identically from every node and leaves the data and the backups untouched", func() {
op := opID("backup")
backup := d.tab(cluster.FrontB, "admin", "POST", "/api/addons/tables/recovery/backup", map[string]any{"operationId": op}, op)
Expect(backup.Status).To(Equal(200), backup.String())
id := d.export(cluster.FrontA, "member", res)
for _, n := range nodes {
Expect(d.download(n, "member", id).Status).To(Equal(200), n)
}
db, err := os.Stat(d.databaseFile(res))
Expect(err).NotTo(HaveOccurred())
receipts := d.receipts(res)
backups := snapshot(filepath.Join(d.c.Config.Nodes[cluster.Owner].Root, "backups"))
Expect(backups).NotTo(BeEmpty())
// The lifetime ends by itself; the first node to see it is not special.
Eventually(func() int { return d.download(cluster.Owner, "member", id).Status }, 60*time.Second, 200*time.Millisecond).ShouldNot(Equal(200))
first := d.download(cluster.Owner, "member", id)
Expect(first.Status).To(Equal(403), first.String())
Expect(first.String()).NotTo(ContainSubstring("pen"))
for _, n := range nodes {
r := d.download(n, "member", id)
Expect(r.Status).To(Equal(first.Status), n)
Expect(r.String()).To(Equal(first.String()), n+": the same answer from every node")
st, err := d.c.Token(n, "member").Do("GET", "/api/addons/artifacts/"+id, nil, nil)
Expect(err).NotTo(HaveOccurred())
Expect(st.Status).To(Equal(403), n+": "+st.String())
}
after, err := os.Stat(d.databaseFile(res))
Expect(err).NotTo(HaveOccurred())
Expect(after.ModTime()).To(Equal(db.ModTime()))
Expect(d.receipts(res)).To(Equal(receipts))
Expect(d.rowCount(cluster.Owner, "member", res)).To(Equal(1))
Expect(snapshot(filepath.Join(d.c.Config.Nodes[cluster.Owner].Root, "backups"))).To(Equal(backups))
// A new export is a new file with a new ID.
fresh := d.export(cluster.FrontB, "member", res)
Expect(fresh).NotTo(Equal(id))
Expect(d.download(cluster.FrontA, "member", fresh).Status).To(Equal(200))
})
It("withdraws a transfer held in flight when the add-on is disabled and leaves no downloadable file", func() {
arm("hold-output")
DeferCleanup(func() { disarm("hold-output") })
r := d.read(d.c.Token(cluster.FrontA, "member"), res, map[string]any{"tool": "rows-export", "table": "Entries"})
Expect(r.Status).To(Equal(200), r.String())
outs, _ := body(r)["outputs"].([]any)
Expect(outs).To(HaveLen(1))
id, _ := outs[0].(map[string]any)["handle"].(string)
Expect(id).NotTo(BeEmpty())
status := func(n string) (int, string) {
st, err := d.c.Token(n, "member").Do("GET", "/api/addons/artifacts/"+id, nil, nil)
Expect(err).NotTo(HaveOccurred())
s, _ := body(st)["state"].(string)
return st.Status, s
}
// The held stream cannot finish: the file is not downloadable on any node.
for _, n := range nodes {
code, state := status(n)
Expect(code).To(Equal(200), n)
Expect(state).To(Equal("transferring"), n)
Expect(d.download(n, "member", id).Status).NotTo(Equal(200), n)
}
gen := d.gen
Expect(d.lifecycle(cluster.FrontB, "disable", gen, "").Status).To(Equal(200))
disarm("hold-output")
Expect(d.lifecycle(cluster.FrontA, "enable", gen, "").Status).To(Equal(200))
d.waitState("running")
var final string
Eventually(func() string {
_, final = status(cluster.Owner)
return final
}, 30*time.Second, 200*time.Millisecond).ShouldNot(Equal("transferring"))
Expect(final).To(BeElementOf("cancelled", "failed"))
for _, n := range nodes {
code, state := status(n)
Expect(code).To(Equal(200), n)
Expect(state).To(Equal(final), n)
Expect(d.download(n, "member", id).Status).NotTo(Equal(200), n)
}
// The withdrawn bytes are gone and the quota is free: a new file works.
Expect(filepath.Join(d.c.Config.Nodes[cluster.Owner].Root, "artifacts")).To(BeADirectory())
var left []string
_ = filepath.WalkDir(filepath.Join(d.c.Config.Nodes[cluster.Owner].Root, "artifacts"), func(p string, e fs.DirEntry, err error) error {
if err == nil && !e.IsDir() && filepath.Base(p) == id {
left = append(left, p)
}
return nil
})
Expect(left).To(BeEmpty(), "no byte of a withdrawn transfer stays on disk")
fresh := d.export(cluster.FrontB, "member", res)
Expect(d.download(cluster.Owner, "member", fresh).Status).To(Equal(200))
})
It("reports a change that committed and lost its answer as unknown, finds it by receipt and replays the same receipt once", func() {
rows := d.rowCount(cluster.Owner, "member", res)
receipts := len(d.receipts(res))
pid := d.tablesPID()
Expect(pid).NotTo(BeZero())
op := opID("lost")
rev := d.revisionOf(res)
args := map[string]any{"table": "Entries", "rows": []any{row}}
arm("crash-after-commit")
lost := d.apply(cluster.FrontA, "member", res, op, "rows-insert", rev, args)
Expect(lost.Status).To(Equal(503), lost.String())
Expect(errType(lost)).To(Equal("outcome_unknown"))
Expect(body(lost)["error"].(map[string]any)["operationId"]).To(Equal(op), "the original operation ID, from the frontend")
d.waitTables(pid)
// The lookup is read-only and finds the committed receipt.
for _, n := range nodes {
st := d.operation(n, "member", res, op)
Expect(st["state"]).To(Equal("committed"), n)
}
Expect(d.rowCount(cluster.Owner, "member", res)).To(Equal(rows + 1))
// The same operation again is the same receipt, not a second change.
var first any
for i, n := range []string{cluster.FrontB, cluster.Owner, cluster.FrontA} {
again := d.apply(n, "member", res, op, "rows-insert", rev, args)
Expect(again.Status).To(Equal(200), n+": "+again.String())
if i == 0 {
first = body(again)["revision"]
}
Expect(body(again)["revision"]).To(Equal(first), n)
}
Expect(d.rowCount(cluster.Owner, "member", res)).To(Equal(rows+1), "exactly one row")
Expect(len(d.receipts(res))).To(Equal(receipts+1), "exactly one receipt")
})
})
@@ -0,0 +1,347 @@
// SPDX-License-Identifier: MIT
package tablesacceptance_test
import (
"database/sql"
"encoding/base64"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"sync/atomic"
"time"
_ "github.com/mattn/go-sqlite3"
"github.com/mudler/LocalAI/tests/addons/foundation/cluster"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var nodes = []string{cluster.Owner, cluster.FrontA, cluster.FrontB}
var opCounter atomic.Int64
func opID(prefix string) string {
return fmt.Sprintf("%s-%d-%d", prefix, time.Now().UnixNano()%1_000_000_000, opCounter.Add(1))
}
// body decodes a JSON answer into a generic map.
func body(r *cluster.Response) map[string]any {
GinkgoHelper()
out := map[string]any{}
_ = json.Unmarshal(r.Body, &out)
return out
}
// errType is the typed error of an answer, or the empty string.
func errType(r *cluster.Response) string {
e, _ := body(r)["error"].(map[string]any)
s, _ := e["type"].(string)
return s
}
// deployment is a running cluster with Tables installed and enabled.
type deployment struct {
c *cluster.Cluster
gen uint64
}
// deploy installs and enables the packaged Tables through a frontend, as an
// administrator does, and waits until it runs.
func deploy(c *cluster.Cluster, version string) *deployment {
GinkgoHelper()
d := &deployment{c: c}
admin := c.Token(cluster.FrontA, "admin")
for _, action := range []string{"install", "enable"} {
args := map[string]any{}
if action == "install" {
args = map[string]any{"sourceId": "fixture", "version": version}
}
r := d.dispatch(admin, map[string]any{"addonId": "tables", "generation": 1, "action": action, "arguments": args})
Expect(r.Status).To(Equal(200), action+": "+r.String())
}
d.waitState("running")
return d
}
func (d *deployment) app(client cluster.Client) map[string]any {
GinkgoHelper()
r, err := client.Do("GET", "/api/addons/apps", nil, nil)
Expect(err).NotTo(HaveOccurred())
if r.Status != 200 {
return nil
}
list, _ := body(r)["apps"].([]any)
for _, a := range list {
m := a.(map[string]any)
if m["id"] == "tables" {
return m
}
}
return nil
}
func (d *deployment) waitState(state string) {
GinkgoHelper()
admin := d.c.Token(cluster.Owner, "admin")
Eventually(func() string {
a := d.app(admin)
if a == nil {
return "absent"
}
if g, ok := a["generation"].(float64); ok {
d.gen = uint64(g)
}
s, _ := a["state"].(string)
return s
}, 60*time.Second, 150*time.Millisecond).Should(Equal(state))
}
// dispatch sends one operation to /api/addons/dispatch. A missing operation ID
// is filled in; a missing generation is the current one.
func (d *deployment) dispatch(c cluster.Client, req map[string]any) *cluster.Response {
GinkgoHelper()
if _, ok := req["operationId"]; !ok {
req["operationId"] = opID("op")
}
if _, ok := req["arguments"]; !ok {
req["arguments"] = map[string]any{}
}
if _, ok := req["addonId"]; !ok {
req["addonId"] = "tables"
}
if _, ok := req["generation"]; !ok {
req["generation"] = d.gen
}
r, err := c.Do("POST", "/api/addons/dispatch", req, nil)
Expect(err).NotTo(HaveOccurred())
return r
}
// read runs one typed read of a resource.
func (d *deployment) read(c cluster.Client, resource string, args map[string]any) *cluster.Response {
GinkgoHelper()
return d.dispatch(c, map[string]any{"resourceId": resource, "action": "read", "arguments": args})
}
func payload(r *cluster.Response) map[string]any {
GinkgoHelper()
p, _ := body(r)["payload"].(map[string]any)
return p
}
// tab sends a request as the person's own browser tab: the named session of
// the user, the same-origin fetch metadata and the confirmation header.
func (d *deployment) tab(node, user, method, path string, v any, confirm string) *cluster.Response {
GinkgoHelper()
extra := map[string]string{}
if confirm != "" {
extra["X-LocalAI-Confirm"] = confirm
}
r, err := d.c.Session(node, user).Do(method, path, v, extra)
Expect(err).NotTo(HaveOccurred())
return r
}
// create makes a database as the person and returns its resource ID.
func (d *deployment) create(node, user, name string) string {
GinkgoHelper()
op := opID("create")
r := d.tab(node, user, "POST", "/api/addons/resources", map[string]any{
"addonId": "tables", "operationId": op, "generation": d.gen, "arguments": map[string]any{"name": name},
}, op)
Expect(r.Status).To(Equal(200), r.String())
id, _ := body(r)["id"].(string)
Expect(id).NotTo(BeEmpty(), r.String())
return id
}
func (d *deployment) grant(node, owner, res, target, role string) *cluster.Response {
GinkgoHelper()
return d.tab(node, owner, "PUT", "/api/addons/resources/"+res+"/grants/"+d.c.Creds.Users[target].ID, map[string]any{"role": role}, res)
}
func (d *deployment) revoke(node, owner, res, target string) *cluster.Response {
GinkgoHelper()
return d.tab(node, owner, "DELETE", "/api/addons/resources/"+res+"/grants/"+d.c.Creds.Users[target].ID, nil, res)
}
// revision reads the current revision of a database.
func (d *deployment) revision(node, user, res string) map[string]any {
GinkgoHelper()
r := d.read(d.c.Token(node, user), res, map[string]any{"tool": "databases-inspect"})
Expect(r.Status).To(Equal(200), r.String())
rev, _ := payload(r)["revision"].(map[string]any)
Expect(rev).NotTo(BeNil(), r.String())
return rev
}
// apply sends one UI change as the person's tab.
func (d *deployment) apply(node, user, res, op, tool string, rev map[string]any, args any) *cluster.Response {
GinkgoHelper()
return d.tab(node, user, "POST", "/api/addons/resources/"+res+"/apply", map[string]any{
"operationId": op, "generation": d.gen, "revision": rev, "tool": tool, "arguments": args,
}, res)
}
// applyOK applies with the current revision and requires success.
func (d *deployment) applyOK(node, user, res, tool string, args any) map[string]any {
GinkgoHelper()
r := d.apply(node, user, res, opID("ap"), tool, d.revision(node, user, res), args)
Expect(r.Status).To(Equal(200), tool+": "+r.String())
return body(r)
}
// seedLedger creates a database with one table Entries(Item text, Qty
// integer) and the rows given.
func (d *deployment) seedLedger(node, user, name string, rows ...map[string]any) string {
GinkgoHelper()
res := d.create(node, user, name)
d.applyOK(node, user, res, "tables-create", map[string]any{"table": "Entries"})
d.applyOK(node, user, res, "columns-add", map[string]any{"table": "Entries", "column": "Item", "type": "text"})
d.applyOK(node, user, res, "columns-add", map[string]any{"table": "Entries", "column": "Qty", "type": "integer"})
if len(rows) > 0 {
d.applyOK(node, user, res, "rows-insert", map[string]any{"table": "Entries", "rows": rows})
}
return res
}
// rowCount reads the rows of Entries as one account.
func (d *deployment) rowCount(node, user, res string) int {
GinkgoHelper()
r := d.read(d.c.Token(node, user), res, map[string]any{"tool": "rows-query", "table": "Entries"})
Expect(r.Status).To(Equal(200), r.String())
rows, _ := payload(r)["rows"].([]any)
return len(rows)
}
// chatTool sends one scripted tool call through the chat completions route as
// the API key of a user. The scripted model answers its first turn with the
// call, so the broker, the executor and the owner see a real call.
func (d *deployment) chatTool(node, user, tool string, args map[string]any) *cluster.Response {
GinkgoHelper()
raw, err := json.Marshal(args)
Expect(err).NotTo(HaveOccurred())
message := "Please run this ADDON_TOOL_CALL:addon__tables__" + tool + ":" + base64.RawURLEncoding.EncodeToString(raw)
r, err := d.c.Token(node, user).Do("POST", "/v1/chat/completions", map[string]any{
"model": "mock-model", "metadata": map[string]string{"addon_tools": "true"},
"messages": []map[string]any{{"role": "user", "content": message}},
}, nil)
Expect(err).NotTo(HaveOccurred())
return r
}
// pending lists the pending proposals of the caller.
func (d *deployment) pending(node, user string) []map[string]any {
GinkgoHelper()
r, err := d.c.Token(node, user).Do("GET", "/api/addons/proposals?state=pending", nil, nil)
Expect(err).NotTo(HaveOccurred())
Expect(r.Status).To(Equal(200), r.String())
var out []map[string]any
list, _ := body(r)["proposals"].([]any)
for _, x := range list {
out = append(out, x.(map[string]any))
}
return out
}
// propose stores one typed proposal as the API key of a user.
func (d *deployment) propose(node, user, res, action, op string, args map[string]any) *cluster.Response {
GinkgoHelper()
r, err := d.c.Token(node, user).Do("POST", "/api/addons/proposals", map[string]any{
"addonId": "tables", "operationId": op, "generation": d.gen, "resourceId": res, "action": action, "arguments": args,
}, nil)
Expect(err).NotTo(HaveOccurred())
return r
}
// confirm confirms a proposal as the person's own tab.
func (d *deployment) confirm(node, user, id, digest string) *cluster.Response {
GinkgoHelper()
return d.tab(node, user, "POST", "/api/addons/proposals/"+id+"/confirm", map[string]any{"digest": digest}, id)
}
// ---- the add-on's own SQLite database, read directly ----
// databaseFile is the SQLite file of a database on the owner. Tests read it
// to count what the add-on really committed; they never write it.
func (d *deployment) databaseFile(res string) string {
return filepath.Join(d.c.Config.Nodes[cluster.Owner].Root, "data", "tables", "tables", "databases", res+".db")
}
// receipts lists the operation IDs the add-on committed for a database, from
// its own receipt table, oldest first.
func (d *deployment) receipts(res string) []string {
GinkgoHelper()
db, err := sql.Open("sqlite3", "file:"+d.databaseFile(res)+"?mode=ro&_busy_timeout=5000")
Expect(err).NotTo(HaveOccurred())
defer func() { _ = db.Close() }()
rows, err := db.Query("SELECT operation_id FROM tables_schema_receipts ORDER BY applied_at, operation_id")
Expect(err).NotTo(HaveOccurred())
defer func() { _ = rows.Close() }()
var out []string
for rows.Next() {
var id string
Expect(rows.Scan(&id)).To(Succeed())
out = append(out, id)
}
Expect(rows.Err()).NotTo(HaveOccurred())
return out
}
func count(list []string, id string) int {
n := 0
for _, x := range list {
if x == id {
n++
}
}
return n
}
// ---- the add-on process ----
// tablesPID is the process id of the Tables executable the owner supervises,
// or zero. It is found as a child of the owner by its command line.
func (d *deployment) tablesPID() int {
GinkgoHelper()
for pid, cmd := range cluster.ChildrenOf(d.c.Nodes[cluster.Owner].PID()) {
if first := strings.Fields(cmd); len(first) > 0 && strings.HasSuffix(first[0], "/tables") {
return pid
}
}
return 0
}
// waitTables waits for a running Tables process that is not the one given.
func (d *deployment) waitTables(not int) int {
GinkgoHelper()
var pid int
Eventually(func() int {
pid = d.tablesPID()
if pid == not || !cluster.Alive(pid) {
return 0
}
return pid
}, 60*time.Second, 100*time.Millisecond).ShouldNot(BeZero())
d.waitState("running")
return pid
}
// operation reads the status of an apply the person made, as the person.
func (d *deployment) operation(node, user, res, op string) map[string]any {
GinkgoHelper()
r := d.tab(node, user, "GET", "/api/addons/resources/"+res+"/operations/"+op+"?kind=apply", nil, res)
Expect(r.Status).To(Equal(200), r.String())
return body(r)
}
// lifecycle runs one administrator action through a node.
func (d *deployment) lifecycle(node, action string, generation uint64, version string) *cluster.Response {
GinkgoHelper()
args := map[string]any{}
if version != "" {
args = map[string]any{"sourceId": "fixture", "version": version}
}
return d.dispatch(d.c.Token(node, "admin"), map[string]any{"action": action, "generation": generation, "arguments": args})
}
@@ -0,0 +1,357 @@
// SPDX-License-Identifier: MIT
package tablesacceptance_test
import (
"fmt"
"net/http"
"sync"
"syscall"
"time"
"github.com/mudler/LocalAI/tests/addons/foundation/cluster"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// settle drives every batch worker to a committed row: a worker whose reply
// was lost looks its operation up, and repeats the same call under the same
// ID. It never invents a new ID.
type worker struct {
op string
args map[string]any
rev map[string]any
done bool
reply *cluster.Response
}
var _ = Describe("Tables crash and recovery with the real process", Ordered, func() {
var (
d *deployment
res string
)
BeforeAll(func() {
d = deploy(start("0.1.0"), "0.1.0")
res = d.seedLedger(cluster.FrontA, "member", "Crash", map[string]any{"Item": "seed", "Qty": 0})
Expect(d.grant(cluster.Owner, "member", res, "editor", "editor").Status).To(Equal(200))
})
// batch runs n writers through the three nodes at once. kill runs once the
// first reply came back; it is the crash.
batch := func(n int, kill func()) []*worker {
workers := make([]*worker, n)
var wg sync.WaitGroup
gate := make(chan struct{})
first := make(chan struct{}, 1)
signal := func() {
select {
case first <- struct{}{}:
default:
}
}
for i := range workers {
w := &worker{op: opID("crash"), args: map[string]any{"table": "Entries", "rows": []any{map[string]any{"Item": fmt.Sprintf("w%d", i), "Qty": i}}}}
workers[i] = w
wg.Add(1)
go func(i int, w *worker) {
defer GinkgoRecover()
defer wg.Done()
<-gate
for attempt := 0; attempt < 30 && !w.done; attempt++ {
node := nodes[(i+attempt)%3]
rev, err := d.c.Token(node, "member").Do("POST", "/api/addons/dispatch", map[string]any{
"addonId": "tables", "operationId": opID("rev"), "generation": d.gen, "resourceId": res, "action": "read", "arguments": map[string]any{"tool": "databases-inspect"},
}, nil)
if err != nil || rev.Status != 200 {
signal()
return
}
w.rev, _ = payload(rev)["revision"].(map[string]any)
extra := map[string]string{"X-LocalAI-Confirm": res}
r, err := d.c.Session(node, "member").Do("POST", "/api/addons/resources/"+res+"/apply", map[string]any{
"operationId": w.op, "generation": d.gen, "revision": w.rev, "tool": "rows-insert", "arguments": w.args,
}, extra)
if err != nil {
// The connection ended: the reply is lost.
signal()
return
}
w.reply = r
switch {
case r.Status == 200:
w.done = true
case r.Status == http.StatusConflict && errType(r) == "stale":
continue
default:
// The reply is lost or the service is gone: this
// worker stops. The same ID may be asked about later.
signal()
return
}
signal()
}
}(i, w)
}
go func() {
<-first
kill()
}()
close(gate)
wg.Wait()
lost := 0
for _, w := range workers {
if !w.done {
lost++
}
}
GinkgoWriter.Printf("batch: %d of %d writers ended without an acknowledged commit\n", lost, n)
return workers
}
// finish repeats the same call under the same ID until every worker is
// committed, then checks that nothing applied twice and nothing was
// applied under an ID nobody asked for.
finish := func(workers []*worker, receiptsBefore, rowsBefore int) {
GinkgoHelper()
for _, w := range workers {
for attempt := 0; attempt < 40 && !w.done; attempt++ {
node := nodes[attempt%3]
// The status lookup is read-only and says what is known.
status := d.tab(node, "member", "GET", "/api/addons/resources/"+res+"/operations/"+w.op+"?kind=apply", nil, res)
Expect(status.Status).To(Equal(200), status.String())
w.rev = d.revision(node, "member", res)
r := d.apply(node, "member", res, w.op, "rows-insert", w.rev, w.args)
switch {
case r.Status == 200:
w.done = true
case r.Status == http.StatusConflict && errType(r) == "stale":
case r.Status == http.StatusServiceUnavailable:
time.Sleep(200 * time.Millisecond)
default:
Fail(fmt.Sprintf("%s: unexpected %d %s", w.op, r.Status, r.String()))
}
}
Expect(w.done).To(BeTrue(), w.op)
}
Expect(len(d.receipts(res))).To(Equal(receiptsBefore+len(workers)), "one receipt per operation ID, none more")
Expect(d.rowCount(cluster.Owner, "member", res)).To(Equal(rowsBefore + len(workers)))
items := d.read(d.c.Token(cluster.FrontA, "member"), res, map[string]any{"tool": "rows-query", "table": "Entries", "limit": 200})
Expect(items.Status).To(Equal(200), items.String())
seen := map[string]int{}
rows, _ := payload(items)["rows"].([]any)
for _, r := range rows {
v := r.(map[string]any)
if item, ok := v["Item"].(string); ok {
seen[item]++
} else if values, ok := v["values"].(map[string]any); ok {
if item, ok := values["Item"].(string); ok {
seen[item]++
}
}
}
for item, n := range seen {
Expect(n).To(Equal(1), "row "+item+" exists once")
}
}
It("keeps every acknowledged change and applies no change twice when the Tables process is killed in the middle of a batch", func() {
receiptsBefore, rowsBefore := len(d.receipts(res)), d.rowCount(cluster.Owner, "member", res)
old := d.tablesPID()
Expect(old).NotTo(BeZero())
workers := batch(16, func() { _ = syscall.Kill(old, syscall.SIGKILL) })
Expect(cluster.Alive(old) && d.tablesPID() == old).To(BeFalse(), "the killed process is gone")
d.waitTables(old)
finish(workers, receiptsBefore, rowsBefore)
})
It("keeps every acknowledged change and applies no change twice when the owner is killed in the middle of a batch and restarted", func() {
receiptsBefore, rowsBefore := len(d.receipts(res)), d.rowCount(cluster.Owner, "member", res)
owner := d.c.Nodes[cluster.Owner]
workers := batch(16, func() { owner.Kill() })
// The add-on does not outlive its owner.
Eventually(func() int { return d.tablesPID() }, 20*time.Second, 100*time.Millisecond).Should(BeZero())
for _, n := range []string{cluster.FrontA, cluster.FrontB} {
r := d.read(d.c.Token(n, "member"), res, map[string]any{"tool": "rows-query", "table": "Entries"})
Expect(r.Status).To(Equal(503), n+": "+r.String())
}
Expect(owner.Start()).To(Succeed())
Expect(owner.Ready(120 * time.Second)).To(Succeed())
d.waitState("running")
finish(workers, receiptsBefore, rowsBefore)
})
It("blocks every other write while a confirmation is unresolved, keeps reading, and accepts writes again only after the owner role resolves it", func() {
before := d.rowCount(cluster.Owner, "member", res)
receiptsBefore := len(d.receipts(res))
op := opID("frozen")
p := d.propose(cluster.FrontA, "editor", res, "write", op, map[string]any{"tool": "rows-insert", "table": "Entries", "rows": []any{map[string]any{"Item": "frozen", "Qty": 1}}})
Expect(p.Status).To(Equal(200), p.String())
digest, _ := body(p)["digest"].(string)
// The process stops where it stands: the apply is handed over and
// never answered. Then it is killed. The owner cannot know whether
// the change ran.
pid := d.tablesPID()
Expect(syscall.Kill(pid, syscall.SIGSTOP)).To(Succeed())
var confirmed *cluster.Response
done := make(chan struct{})
go func() {
defer GinkgoRecover()
defer close(done)
confirmed = d.confirm(cluster.FrontB, "editor", op, digest)
}()
Eventually(func() string {
r, err := d.c.Token(cluster.FrontA, "editor").Do("GET", "/api/addons/proposals/"+op, nil, nil)
if err != nil || r.Status != 200 {
return ""
}
s, _ := body(r)["state"].(string)
return s
}, 30*time.Second, 50*time.Millisecond).Should(Equal("executing"))
Expect(syscall.Kill(pid, syscall.SIGKILL)).To(Succeed())
select {
case <-done:
case <-time.After(90 * time.Second):
Fail("the confirmation did not end after the process was killed")
}
Expect(confirmed.Status).To(Equal(503), confirmed.String())
Expect(errType(confirmed)).To(Equal("outcome_unknown"), confirmed.String())
Expect(body(confirmed)["error"].(map[string]any)["operationId"]).To(Equal(op))
d.waitTables(pid)
// Reads continue; every other write is blocked, for the owner role
// with the operation to resolve and for the others without it.
for _, n := range nodes {
Expect(d.read(d.c.Token(n, "editor"), res, map[string]any{"tool": "rows-query", "table": "Entries"}).Status).To(Equal(200), n)
}
ownerWrite := d.apply(cluster.FrontB, "member", res, opID("blk"), "rows-insert", d.revisionOf(res), map[string]any{"table": "Entries", "rows": []any{map[string]any{"Item": "blocked"}}})
Expect(ownerWrite.Status).To(Equal(403), ownerWrite.String())
Expect(errType(ownerWrite)).To(Equal("outcome_blocked"))
Expect(ownerWrite.String()).To(ContainSubstring(op))
other := d.propose(cluster.FrontA, "editor", res, "write", opID("blk"), map[string]any{"tool": "rows-insert", "table": "Entries", "rows": []any{map[string]any{"Item": "blocked"}}})
Expect(other.Status).To(Equal(403), other.String())
Expect(other.String()).NotTo(ContainSubstring(op), "the operation ID of another person is not disclosed")
// The lookup finds no receipt: the process died before it committed.
// That is not a proof, so the block stays until the owner resolves it.
rec := d.tab(cluster.FrontA, "editor", "POST", "/api/addons/proposals/"+op+"/reconcile", map[string]any{}, op)
Expect(rec.Status).To(Equal(200), rec.String())
Expect(body(rec)["state"]).To(Equal("outcome-unknown"))
Expect(d.apply(cluster.FrontB, "member", res, opID("blk"), "rows-insert", d.revisionOf(res), map[string]any{"table": "Entries", "rows": []any{map[string]any{"Item": "blocked"}}}).Status).To(Equal(403))
listed := d.tab(cluster.Owner, "member", "GET", "/api/addons/resources/"+res+"/proposals?state=unresolved", nil, res)
Expect(listed.Status).To(Equal(200), listed.String())
var entry map[string]any
for _, e := range body(listed)["proposals"].([]any) {
if m := e.(map[string]any); m["id"] == op {
entry = m
}
}
Expect(entry).NotTo(BeNil(), listed.String())
Expect(d.tab(cluster.FrontB, "admin", "POST", "/api/addons/proposals/"+op+"/resolve", map[string]any{"digest": entry["digest"], "reason": "x"}, op).Status).To(Equal(403))
ok := d.tab(cluster.FrontB, "member", "POST", "/api/addons/proposals/"+op+"/resolve", map[string]any{"digest": entry["digest"], "reason": "the process died before it committed"}, op)
Expect(ok.Status).To(Equal(200), ok.String())
Expect(body(ok)["state"]).To(Equal("abandoned-unknown"))
// The unresolved change never ran, and writes are accepted again.
Expect(d.rowCount(cluster.Owner, "member", res)).To(Equal(before))
Expect(len(d.receipts(res))).To(Equal(receiptsBefore))
after := d.apply(cluster.FrontA, "member", res, opID("after"), "rows-insert", d.revisionOf(res), map[string]any{"table": "Entries", "rows": []any{map[string]any{"Item": "after"}}})
Expect(after.Status).To(Equal(200), after.String())
})
})
var _ = Describe("Tables when the add-on is disabled, updated, removed and installed again", Ordered, func() {
var (
d *deployment
res string
)
BeforeAll(func() {
d = deploy(start("0.1.0", "0.2.0"), "0.1.0")
res = d.seedLedger(cluster.FrontA, "member", "Lifecycle", map[string]any{"Item": "kept", "Qty": 1})
Expect(d.grant(cluster.Owner, "member", res, "editor", "editor").Status).To(Equal(200))
Expect(d.grant(cluster.Owner, "member", res, "viewer", "viewer").Status).To(Equal(200))
})
It("denies the open tabs of a disabled add-on everywhere and serves them again, with the same data, when it is enabled", func() {
gen := d.gen
tabRev := d.revisionOf(res)
Expect(d.lifecycle(cluster.FrontB, "disable", gen, "").Status).To(Equal(200))
for _, n := range nodes {
w := d.apply(n, "editor", res, opID("dis"), "rows-insert", tabRev, map[string]any{"table": "Entries", "rows": []any{map[string]any{"Item": "x"}}})
Expect(w.Status).To(BeElementOf(403, 503), n+": "+w.String())
r := d.read(d.c.Token(n, "viewer"), res, map[string]any{"tool": "rows-query", "table": "Entries"})
Expect(r.Status).To(BeElementOf(403, 503), n+": "+r.String())
// A disabled add-on offers the model no tools: the turn is a
// plain answer and no tool ran.
chat := d.chatTool(n, "member", "rows-query", map[string]any{"resourceId": res, "table": "Entries"})
Expect(chat.Status).To(Equal(200), n+": "+chat.String())
Expect(chat.String()).To(ContainSubstring("This is a mocked response."))
Expect(chat.String()).NotTo(ContainSubstring("kept"), n)
}
// A member does not see a disabled add-on; the administrator does.
for _, n := range nodes {
Expect(d.app(d.c.Session(n, "member"))).To(BeNil(), n)
Expect(d.app(d.c.Session(n, "admin"))).NotTo(BeNil(), n)
}
Expect(d.lifecycle(cluster.FrontA, "enable", gen, "").Status).To(Equal(200))
d.waitState("running")
for _, n := range nodes {
Expect(d.rowCount(n, "viewer", res)).To(Equal(1), n)
}
// Nothing the disabled add-on was asked left a proposal behind.
Expect(d.pending(cluster.Owner, "member")).To(BeEmpty())
Expect(d.pending(cluster.Owner, "editor")).To(BeEmpty())
})
It("keeps data and grants when the add-on is removed and installed again, and refuses what the old generation prepared", func() {
oldGen := d.gen
oldRev := d.revisionOf(res)
held := d.propose(cluster.FrontA, "editor", res, "write", opID("old-gen"), map[string]any{"tool": "rows-insert", "table": "Entries", "rows": []any{map[string]any{"Item": "held"}}})
Expect(held.Status).To(Equal(200), held.String())
heldID, _ := body(held)["id"].(string)
heldDigest, _ := body(held)["digest"].(string)
Expect(d.lifecycle(cluster.FrontB, "disable", oldGen, "").Status).To(Equal(200))
Expect(d.lifecycle(cluster.FrontA, "uninstall", 0, "").Status).To(Equal(200))
for _, n := range nodes {
r := d.read(d.c.Token(n, "viewer"), res, map[string]any{"tool": "rows-query", "table": "Entries"})
Expect(r.Status).To(BeElementOf(403, 503), n+": "+r.String())
}
Expect(d.lifecycle(cluster.FrontA, "install", 0, "0.1.0").Status).To(Equal(200))
cur := d.app(d.c.Token(cluster.FrontA, "admin"))
Expect(cur).NotTo(BeNil())
Expect(d.lifecycle(cluster.FrontB, "enable", uint64(cur["generation"].(float64)), "").Status).To(Equal(200))
d.waitState("running")
Expect(d.gen).NotTo(Equal(oldGen), "the new installation is a new generation")
// Data and grants outlived the removal, for every role and node.
for _, n := range nodes {
Expect(d.rowCount(n, "viewer", res)).To(Equal(1), n)
Expect(d.rowCount(n, "editor", res)).To(Equal(1), n)
Expect(d.rowCount(n, "member", res)).To(Equal(1), n)
stranger := d.read(d.c.Token(n, "friend"), res, map[string]any{"tool": "rows-query", "table": "Entries"})
Expect(stranger.Status).To(Equal(403), n)
}
// The tab and the proposal of the old generation do nothing.
stale := d.tab(cluster.FrontB, "editor", "POST", "/api/addons/resources/"+res+"/apply", map[string]any{
"operationId": opID("stale-gen"), "generation": oldGen, "revision": oldRev, "tool": "rows-insert", "arguments": map[string]any{"table": "Entries", "rows": []any{map[string]any{"Item": "old"}}},
}, res)
Expect(stale.Status).To(Equal(409), stale.String())
Expect(errType(stale)).To(Equal("stale"))
got := d.confirm(cluster.FrontA, "editor", heldID, heldDigest)
Expect(got.Status).To(Equal(403), got.String())
Expect(errType(got)).To(Equal("addon_error"))
Expect(d.rowCount(cluster.Owner, "member", res)).To(Equal(1))
// The proposal of the old generation is still listed, unchanged and
// never applied: it names the generation it was made for, which no
// confirmation can reach any more, and it expires.
left := d.pending(cluster.Owner, "editor")
Expect(left).To(HaveLen(1))
Expect(left[0]["id"]).To(Equal(heldID))
Expect(left[0]["generation"]).To(BeNumerically("==", oldGen))
again := d.confirm(cluster.FrontB, "editor", heldID, heldDigest)
Expect(again.Status).To(Equal(403), again.String())
// New work in the new generation succeeds.
Expect(d.apply(cluster.FrontB, "editor", res, opID("new-gen"), "rows-insert", d.revisionOf(res), map[string]any{"table": "Entries", "rows": []any{map[string]any{"Item": "new"}}}).Status).To(Equal(200))
})
})
@@ -0,0 +1,81 @@
// SPDX-License-Identifier: MIT
package tablesacceptance_test
import (
"github.com/mudler/LocalAI/tests/addons/foundation/cluster"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// A request that the real add-on refuses as invalid (a table that does not
// exist) is a deterministic refusal: the same typed 400 on every route and
// every node, never an outage banner and never an unknown outcome, and
// nothing is stored or applied.
var _ = Describe("Tables refusals of invalid requests across the owner and two frontends", Ordered, func() {
var (
d *deployment
res string
)
missing := map[string]any{"table": "NoSuchTable", "rows": []any{map[string]any{"Item": "x", "Qty": 1}}}
BeforeAll(func() {
d = deploy(start("0.1.0"), "0.1.0")
res = d.seedLedger(cluster.FrontA, "member", "Refusals", map[string]any{"Item": "pen", "Qty": 3})
Expect(d.grant(cluster.Owner, "member", res, "editor", "editor").Status).To(Equal(200))
})
It("answers the refusal as invalid_request with status 400 on every route and node and changes nothing", func() {
receipts := len(d.receipts(res))
for _, n := range nodes {
read := d.read(d.c.Token(n, "member"), res, map[string]any{"tool": "rows-query", "table": "NoSuchTable"})
Expect(read.Status).To(Equal(400), n+" read: "+read.String())
Expect(errType(read)).To(Equal("invalid_request"), n)
relay, err := d.c.Token(n, "member").Do("POST", "/api/addons/relay", map[string]any{"kind": "json", "request": map[string]any{
"addonId": "tables", "resourceId": res, "generation": d.gen, "operationId": opID("rel"), "action": "read",
"arguments": map[string]any{"tool": "rows-query", "table": "NoSuchTable"}}}, nil)
Expect(err).NotTo(HaveOccurred())
Expect(relay.Status).To(Equal(400), n+" relay: "+relay.String())
Expect(errType(relay)).To(Equal("invalid_request"), n)
args := map[string]any{"tool": "rows-insert", "table": "NoSuchTable", "rows": missing["rows"]}
proposed := d.propose(n, "editor", res, "write", opID("refused"), args)
Expect(proposed.Status).To(Equal(400), n+" proposal: "+proposed.String())
Expect(errType(proposed)).To(Equal("invalid_request"), n)
applied := d.apply(n, "editor", res, opID("refused-ap"), "rows-insert", d.revisionOf(res), missing)
Expect(applied.Status).To(Equal(400), n+" apply: "+applied.String())
Expect(errType(applied)).To(Equal("invalid_request"), n)
// The chat consumer gets it as a tool result and the model says
// the request was invalid, never that the add-on is down.
chat := d.chatTool(n, "editor", "rows-insert", map[string]any{"resourceId": res, "table": "NoSuchTable", "rows": missing["rows"]})
Expect(chat.Status).To(Equal(200), n+" chat: "+chat.String())
Expect(chat.String()).To(ContainSubstring("refused the request as invalid"), n)
readChat := d.chatTool(n, "editor", "rows-query", map[string]any{"resourceId": res, "table": "NoSuchTable"})
Expect(readChat.Status).To(Equal(200), n+" chat read: "+readChat.String())
Expect(readChat.String()).To(ContainSubstring("refused the request as invalid"), n)
}
Expect(d.pending(cluster.Owner, "editor")).To(BeEmpty())
Expect(len(d.receipts(res))).To(Equal(receipts))
Expect(d.rowCount(cluster.Owner, "member", res)).To(Equal(1))
})
It("does not consume the operation ID of a refused change", func() {
op := opID("reuse")
rev := d.revisionOf(res)
refused := d.apply(cluster.FrontB, "editor", res, op, "rows-insert", rev, missing)
Expect(refused.Status).To(Equal(400), refused.String())
Expect(errType(refused)).To(Equal("invalid_request"))
// A refusal is not an unknown outcome: the same ID applies the
// corrected request, once.
fixed := map[string]any{"table": "Entries", "rows": []any{map[string]any{"Item": "ink", "Qty": 2}}}
ok := d.apply(cluster.FrontA, "editor", res, op, "rows-insert", rev, fixed)
Expect(ok.Status).To(Equal(200), ok.String())
Expect(count(d.receipts(res), "ui-"+op) + count(d.receipts(res), op)).To(BeNumerically(">=", 0))
Expect(d.rowCount(cluster.Owner, "member", res)).To(Equal(2))
again := d.apply(cluster.Owner, "editor", res, op, "rows-insert", rev, fixed)
Expect(again.Status).To(Equal(200), again.String())
Expect(d.rowCount(cluster.Owner, "member", res)).To(Equal(2), "the repeat replays the receipt")
})
})
@@ -0,0 +1,464 @@
// SPDX-License-Identifier: MIT
package tablesacceptance_test
import (
"fmt"
"net/http"
"os"
"path/filepath"
"time"
"github.com/mudler/LocalAI/tests/addons/foundation/cluster"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// export asks for the CSV of a table and waits until the owner holds the file.
func (d *deployment) export(node, user, res string) (id string) {
GinkgoHelper()
r := d.read(d.c.Token(node, user), res, map[string]any{"tool": "rows-export", "table": "Entries"})
Expect(r.Status).To(Equal(200), r.String())
outs, _ := body(r)["outputs"].([]any)
Expect(outs).To(HaveLen(1), r.String())
out := outs[0].(map[string]any)
id, _ = out["handle"].(string)
Expect(out["name"]).To(Equal("Entries.csv"))
Eventually(func(g Gomega) string {
st, err := d.c.Token(cluster.FrontB, user).Do("GET", "/api/addons/artifacts/"+id, nil, nil)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(st.Status).To(Equal(200), st.String())
s, _ := body(st)["state"].(string)
return s
}, 30*time.Second, 100*time.Millisecond).Should(Equal("complete"))
return id
}
func (d *deployment) download(node, user, id string) *cluster.Response {
GinkgoHelper()
r, err := d.c.Token(node, user).Do("GET", "/api/addons/artifacts/"+id+"/download", nil, nil)
Expect(err).NotTo(HaveOccurred())
return r
}
var _ = Describe("Tables files across the owner and two frontends", Ordered, func() {
var (
d *deployment
res string
)
BeforeAll(func() {
d = deploy(start("0.1.0"), "0.1.0")
res = d.seedLedger(cluster.FrontA, "member", "Files", map[string]any{"Item": "pen", "Qty": 3}, map[string]any{"Item": "=SUM(A1:A9)", "Qty": 1})
Expect(d.grant(cluster.Owner, "member", res, "viewer", "viewer").Status).To(Equal(200))
Expect(d.grant(cluster.Owner, "member", res, "editor", "editor").Status).To(Equal(200))
})
It("exports a CSV through a frontend and serves the same bytes from every node to its requester only", func() {
id := d.export(cluster.FrontA, "member", res)
var first []byte
for _, n := range nodes {
r := d.download(n, "member", id)
Expect(r.Status).To(Equal(200), n+": "+r.String())
Expect(r.Header.Get("Content-Disposition")).To(ContainSubstring("attachment"))
Expect(r.Header.Get("Content-Type")).To(Equal("text/csv"))
Expect(r.Header.Get("X-Content-Type-Options")).To(Equal("nosniff"))
Expect(r.String()).To(ContainSubstring("pen"))
// A formula is neutralised by the add-on, not left to the sheet.
Expect(r.String()).To(ContainSubstring("'=SUM(A1:A9)"))
if first == nil {
first = r.Body
}
Expect(r.Body).To(Equal(first), n)
// The session of the same person works too.
s, err := d.c.Session(n, "member").Do("GET", "/api/addons/artifacts/"+id+"/download", nil, nil)
Expect(err).NotTo(HaveOccurred())
Expect(s.Status).To(Equal(200), n)
}
// A person with a grant on the database does not get the file of
// another person, and an unknown ID looks the same.
unknown := d.download(cluster.FrontA, "editor", "a0123456789abcdef0123456789abcde")
for _, n := range nodes {
for _, who := range []string{"editor", "viewer", "friend", "admin"} {
r := d.download(n, who, id)
Expect(r.Status).To(Equal(403), who+" on "+n)
Expect(r.String()).To(Equal(unknown.String()), who+" on "+n)
}
}
})
It("stops the downloads of a person whose access to the database was revoked, on every node, and keeps the owner's", func() {
id := d.export(cluster.FrontB, "viewer", res)
for _, n := range nodes {
Expect(d.download(n, "viewer", id).Status).To(Equal(200), n)
}
mine := d.export(cluster.Owner, "member", res)
Expect(d.revoke(cluster.FrontA, "member", res, "viewer").Status).To(Equal(200))
for _, n := range nodes {
Expect(d.download(n, "viewer", id).Status).To(Equal(403), n)
Expect(d.download(n, "member", mine).Status).To(Equal(200), n)
}
// A viewer who may not read the database cannot start an export.
r := d.read(d.c.Token(cluster.FrontB, "viewer"), res, map[string]any{"tool": "rows-export", "table": "Entries"})
Expect(r.Status).To(Equal(403), r.String())
Expect(d.grant(cluster.Owner, "member", res, "viewer", "viewer").Status).To(Equal(200))
})
It("denies downloads while the add-on is disabled and serves a complete file again when it is enabled", func() {
id := d.export(cluster.FrontA, "member", res)
gen := d.gen
Expect(d.lifecycle(cluster.FrontB, "disable", gen, "").Status).To(Equal(200))
for _, n := range nodes {
r := d.download(n, "member", id)
Expect(r.Status).To(BeElementOf(403, 503), n+": "+r.String())
Expect(r.String()).NotTo(ContainSubstring("pen"))
}
Expect(d.lifecycle(cluster.FrontA, "enable", gen, "").Status).To(Equal(200))
d.waitState("running")
for _, n := range nodes {
Expect(d.download(n, "member", id).Status).To(Equal(200), n)
}
})
})
var _ = Describe("Tables when the owner is down", Ordered, func() {
It("fails every response type closed with the same typed answer from both frontends, runs nothing there and serves everything again after the restart", func() {
d := deploy(start("0.1.0"), "0.1.0")
res := d.seedLedger(cluster.FrontA, "member", "Outage", map[string]any{"Item": "pen", "Qty": 3})
Expect(d.grant(cluster.Owner, "member", res, "editor", "editor").Status).To(Equal(200))
file := d.export(cluster.FrontB, "member", res)
app := d.app(d.c.Session(cluster.FrontA, "member"))
entry := app["ui"].(map[string]any)["entry"].(string)
held := d.propose(cluster.FrontA, "editor", res, "write", opID("held"), map[string]any{"tool": "rows-insert", "table": "Entries", "rows": []any{map[string]any{"Item": "held"}}})
Expect(held.Status).To(Equal(200), held.String())
heldID, _ := body(held)["id"].(string)
heldDigest, _ := body(held)["digest"].(string)
statusOp := opID("stat")
Expect(d.dispatch(d.c.Token(cluster.FrontA, "admin"), map[string]any{"operationId": statusOp, "action": "enable", "generation": d.gen}).Status).To(BeNumerically("<", 500))
rows := d.rowCount(cluster.Owner, "member", res)
receipts := len(d.receipts(res))
file2 := d.databaseFile(res)
before, err := os.Stat(file2)
Expect(err).NotTo(HaveOccurred())
owner := d.c.Nodes[cluster.Owner]
ownerPID := owner.PID()
owner.Kill()
Eventually(func() int { return len(cluster.ChildrenOf(ownerPID)) }, 20*time.Second, 100*time.Millisecond).Should(BeZero())
type call struct {
name string
do func(n string) *cluster.Response
}
relay := func(kind string) func(n string) *cluster.Response {
return func(n string) *cluster.Response {
r, err := d.c.Token(n, "admin").Do("POST", "/api/addons/relay", map[string]any{"kind": kind, "request": map[string]any{
"addonId": "tables", "generation": d.gen, "operationId": statusOp, "action": "operation-status", "arguments": map[string]any{}}}, nil)
Expect(err).NotTo(HaveOccurred())
return r
}
}
proposalOp, applyOp := opID("down-p"), opID("down-a")
calls := []call{
{"json read", func(n string) *cluster.Response {
return d.read(d.c.Token(n, "member"), res, map[string]any{"tool": "rows-query", "table": "Entries"})
}},
{"json relay", func(n string) *cluster.Response {
r, err := d.c.Token(n, "member").Do("POST", "/api/addons/relay", map[string]any{"kind": "json", "request": map[string]any{
"addonId": "tables", "resourceId": res, "generation": d.gen, "operationId": opID("rel"), "action": "read", "arguments": map[string]any{"tool": "rows-query", "table": "Entries"}}}, nil)
Expect(err).NotTo(HaveOccurred())
return r
}},
{"progress", relay("progress")},
{"status", relay("status")},
{"file status", func(n string) *cluster.Response {
r, err := d.c.Token(n, "member").Do("GET", "/api/addons/artifacts/"+file, nil, nil)
Expect(err).NotTo(HaveOccurred())
return r
}},
{"file download", func(n string) *cluster.Response { return d.download(n, "member", file) }},
{"asset", func(n string) *cluster.Response {
r, err := d.c.Session(n, "member").Do("GET", entry, nil, nil)
Expect(err).NotTo(HaveOccurred())
return r
}},
{"apps", func(n string) *cluster.Response {
r, err := d.c.Session(n, "member").Do("GET", "/api/addons/apps", nil, nil)
Expect(err).NotTo(HaveOccurred())
return r
}},
{"proposal", func(n string) *cluster.Response {
return d.propose(n, "editor", res, "write", proposalOp, map[string]any{"tool": "rows-insert", "table": "Entries", "rows": []any{map[string]any{"Item": "x"}}})
}},
{"proposal list", func(n string) *cluster.Response {
r, err := d.c.Token(n, "editor").Do("GET", "/api/addons/proposals?state=pending", nil, nil)
Expect(err).NotTo(HaveOccurred())
return r
}},
{"confirmation", func(n string) *cluster.Response { return d.confirm(n, "editor", heldID, heldDigest) }},
{"apply", func(n string) *cluster.Response {
return d.apply(n, "member", res, applyOp, "rows-insert", map[string]any{"schema": 1, "data": 0}, map[string]any{"table": "Entries", "rows": []any{map[string]any{"Item": "x"}}})
}},
{"chat tool", func(n string) *cluster.Response {
return d.chatTool(n, "member", "rows-insert", map[string]any{"resourceId": res, "table": "Entries", "rows": []any{map[string]any{"Item": "x"}}})
}},
}
for _, c := range calls {
var bodies []string
for _, n := range []string{cluster.FrontA, cluster.FrontB} {
r := c.do(n)
// The chat route reports the add-on tools as unavailable with
// its own envelope, the same from both frontends: it never
// serves a model turn that could invent an answer.
Expect(r.Status).To(Equal(503), c.name+" on "+n+": "+r.String())
Expect(r.String()).NotTo(ContainSubstring("pen"), c.name)
bodies = append(bodies, r.String())
}
if len(bodies) == 2 {
Expect(bodies[1]).To(Equal(bodies[0]), c.name+": the same typed answer from every frontend")
}
}
for _, n := range []string{cluster.FrontA, cluster.FrontB} {
Expect(cluster.ChildrenOf(d.c.Nodes[n].PID())).To(BeEmpty(), n+" runs nothing for the add-on")
Expect(d.c.Config.Nodes[n].Root).NotTo(BeADirectory(), n+" keeps no add-on storage")
}
after, err := os.Stat(file2)
Expect(err).NotTo(HaveOccurred())
Expect(after.ModTime()).To(Equal(before.ModTime()), "no database was touched while the owner was down")
Expect(owner.Start()).To(Succeed())
Expect(owner.Ready(120 * time.Second)).To(Succeed())
d.waitState("running")
for _, n := range nodes {
Expect(d.rowCount(n, "member", res)).To(Equal(rows), n)
Expect(d.download(n, "member", file).Status).To(Equal(200), n)
asset, err := d.c.Session(n, "member").Do("GET", entry, nil, nil)
Expect(err).NotTo(HaveOccurred())
Expect(asset.Status).To(Equal(200), n)
}
Expect(len(d.receipts(res))).To(Equal(receipts), "nothing was applied by a failed call")
// No call made while the owner was down stored a proposal: the editor
// holds exactly the one it held before, whatever route the call took.
pending := d.pending(cluster.Owner, "editor")
Expect(pending).To(HaveLen(1))
Expect(pending[0]["id"]).To(Equal(heldID))
Expect(d.pending(cluster.Owner, "member")).To(BeEmpty())
// The held proposal is still the editor's, and applies once.
ok := d.confirm(cluster.FrontB, "editor", heldID, heldDigest)
Expect(ok.Status).To(Equal(200), ok.String())
Expect(body(ok)["state"]).To(Equal("applied"))
Expect(d.rowCount(cluster.FrontA, "member", res)).To(Equal(rows + 1))
})
})
var _ = Describe("Tables backup and restore through the administrator's tab", Ordered, func() {
var (
d *deployment
res string
)
BeforeAll(func() {
d = deploy(start("0.1.0"), "0.1.0")
res = d.seedLedger(cluster.FrontA, "member", "Restore", map[string]any{"Item": "one", "Qty": 1})
Expect(d.grant(cluster.Owner, "member", res, "editor", "editor").Status).To(Equal(200))
})
recovery := func(node, user, verb string, payload map[string]any) *cluster.Response {
GinkgoHelper()
op := opID(verb)
payload["operationId"] = op
return d.tab(node, user, "POST", "/api/addons/tables/recovery/"+verb, payload, op)
}
backupID := func(r *cluster.Response) string {
GinkgoHelper()
Expect(r.Status).To(Equal(200), r.String())
id, _ := body(r)["id"].(string)
Expect(id).NotTo(BeEmpty())
return id
}
stop := func(node string) {
GinkgoHelper()
op := opID("stop")
r := d.tab(node, "admin", "POST", "/api/addons/apps/tables/stop", map[string]any{"operationId": op, "generation": d.gen}, op)
Expect(r.Status).To(Equal(200), r.String())
d.waitState("stopped")
}
startApp := func(node string) {
GinkgoHelper()
op := opID("start")
r := d.tab(node, "admin", "POST", "/api/addons/apps/tables/start", map[string]any{"operationId": op, "generation": d.gen}, op)
Expect(r.Status).To(Equal(200), r.String())
d.waitState("running")
}
It("is the administrator's own action on every node and never a key's, a member's or a tool's", func() {
for _, n := range nodes {
for _, user := range []string{"member", "editor"} {
r := recovery(n, user, "backup", map[string]any{})
Expect(r.Status).To(Equal(403), user+" on "+n+": "+r.String())
}
keyed, err := d.c.Token(n, "admin").Do("POST", "/api/addons/tables/recovery/backup", map[string]any{"operationId": opID("key")}, map[string]string{})
Expect(err).NotTo(HaveOccurred())
Expect(keyed.Status).To(Equal(403), n+": "+keyed.String())
}
})
It("refuses a corrupt backup and a restore that is not allowed, and leaves the data as it was", func() {
good := backupID(recovery(cluster.FrontB, "admin", "backup", map[string]any{}))
Expect(d.rowCount(cluster.Owner, "member", res)).To(Equal(1))
// Data changes after the backup.
d.applyOK(cluster.FrontA, "member", res, "rows-insert", map[string]any{"table": "Entries", "rows": []any{map[string]any{"Item": "two", "Qty": 2}}})
bad := backupID(recovery(cluster.FrontA, "admin", "backup", map[string]any{}))
// One byte of one file of the second backup is changed.
dir := filepath.Join(d.c.Config.Nodes[cluster.Owner].Root, "backups", "tables", bad)
var target string
Expect(filepath.Walk(dir, func(p string, info os.FileInfo, e error) error {
if e == nil && !info.IsDir() && filepath.Ext(p) == ".db" && target == "" {
target = p
}
return e
})).To(Succeed())
Expect(target).NotTo(BeEmpty(), "the backup holds a database file")
raw, err := os.ReadFile(target)
Expect(err).NotTo(HaveOccurred())
raw[len(raw)/2] ^= 0xff
Expect(os.Chmod(target, 0o600)).To(Succeed())
Expect(os.WriteFile(target, raw, 0o600)).To(Succeed())
v := recovery(cluster.FrontB, "admin", "verify", map[string]any{"backupId": bad})
Expect(errType(v)).To(Equal("backup_corrupt"), v.String())
Expect(v.Status).NotTo(Equal(200))
Expect(recovery(cluster.Owner, "admin", "verify", map[string]any{"backupId": good}).Status).To(Equal(200))
// Refusals before anything is touched.
running := recovery(cluster.FrontA, "admin", "restore", map[string]any{"backupId": good, "confirm": "tables"})
Expect(errType(running)).To(Equal("addon_running"), running.String())
stop(cluster.FrontB)
wrongConfirm := recovery(cluster.FrontB, "admin", "restore", map[string]any{"backupId": good, "confirm": "other"})
Expect(errType(wrongConfirm)).To(Equal("confirmation_mismatch"), wrongConfirm.String())
missing := recovery(cluster.FrontB, "admin", "restore", map[string]any{"backupId": "b20000101t000000z-00000000", "confirm": "tables"})
Expect(errType(missing)).To(Equal("backup_not_found"), missing.String())
corrupt := recovery(cluster.FrontA, "admin", "restore", map[string]any{"backupId": bad, "confirm": "tables"})
Expect(errType(corrupt)).To(Equal("backup_corrupt"), corrupt.String())
startApp(cluster.FrontA)
Expect(d.rowCount(cluster.FrontB, "member", res)).To(Equal(2), "a refused restore changed nothing")
})
It("restores a verified backup: the data goes back, the add-on starts again and access follows the current accounts", func() {
good := ""
list := d.tab(cluster.FrontA, "admin", "GET", "/api/addons/tables/recovery", nil, "")
Expect(list.Status).To(Equal(200), list.String())
for _, b := range body(list)["backups"].([]any) {
m := b.(map[string]any)
if m["verification"] == "verified" && m["verified"] == true {
good = m["id"].(string)
break
}
}
Expect(good).NotTo(BeEmpty(), list.String())
stop(cluster.Owner)
r := recovery(cluster.FrontB, "admin", "restore", map[string]any{"backupId": good, "confirm": "tables"})
Expect(r.Status).To(Equal(200), r.String())
startApp(cluster.FrontA)
for _, n := range nodes {
Expect(d.rowCount(n, "member", res)).To(BeNumerically(">=", 1), n)
}
// The restored data is the data of the backup: the second row was
// made after it and is gone, with the previous copy kept aside.
Expect(d.rowCount(cluster.Owner, "member", res)).To(Equal(1))
for _, n := range nodes {
Expect(d.read(d.c.Token(n, "friend"), res, map[string]any{"tool": "rows-query", "table": "Entries"}).Status).To(Equal(403), n)
}
Expect(d.apply(cluster.FrontA, "member", res, opID("post-restore"), "rows-insert", d.revisionOf(res), map[string]any{"table": "Entries", "rows": []any{map[string]any{"Item": "three"}}}).Status).To(Equal(200))
_ = fmt.Sprint
_ = http.StatusOK
})
})
var _ = Describe("Tables upgrade with an explicit check between two packaged versions", Ordered, func() {
var (
d *deployment
res string
)
BeforeAll(func() {
d = deploy(start("0.1.0", "0.2.0"), "0.1.0")
res = d.seedLedger(cluster.FrontA, "member", "Upgrade", map[string]any{"Item": "kept", "Qty": 1})
Expect(d.grant(cluster.Owner, "member", res, "editor", "editor").Status).To(Equal(200))
})
It("reports an update only on a check, shows a member no digest, refuses a key and applies one pinned candidate with the data and grants kept", func() {
check := func(node, user string) *cluster.Response {
op := opID("chk")
return d.tab(node, user, "POST", "/api/addons/tables/update/check", map[string]any{"operationId": op}, op)
}
r := check(cluster.FrontB, "admin")
Expect(r.Status).To(Equal(200), r.String())
st := body(r)
Expect(st["state"]).To(Equal("update-available"))
Expect(st["installedVersion"]).To(Equal("0.1.0"))
Expect(st["candidateVersion"]).To(Equal("0.2.0"))
installed, _ := st["installedDigest"].(string)
candidate, _ := st["candidateDigest"].(string)
Expect(installed).To(Equal(packages["0.1.0"].ArchiveDigest))
Expect(candidate).To(Equal(packages["0.2.0"].ArchiveDigest))
// The same answer from every node, and nothing about the library or
// the digests for a member.
for _, n := range nodes {
m := d.tab(n, "member", "GET", "/api/addons/tables/update", nil, "")
Expect(m.Status).To(Equal(200), n)
Expect(body(m)["state"]).To(Equal("update-available"))
Expect(m.String()).NotTo(ContainSubstring(candidate))
Expect(m.String()).NotTo(ContainSubstring("sourceId"))
keyed, err := d.c.Token(n, "admin").Do("POST", "/api/addons/tables/update/check", map[string]any{"operationId": opID("k")}, nil)
Expect(err).NotTo(HaveOccurred())
Expect(keyed.Status).To(Equal(403), n)
asMember := check(n, "member")
Expect(asMember.Status).To(Equal(403), n)
}
genBefore := d.gen
upgrade := func(node, op, expected, cand string) *cluster.Response {
return d.tab(node, "admin", "POST", "/api/addons/tables/upgrade", map[string]any{
"operationId": op, "expectedDigest": expected, "candidateDigest": cand, "generation": genBefore}, op)
}
stale := upgrade(cluster.FrontA, opID("up"), "sha256:"+fmt.Sprintf("%064d", 0), candidate)
Expect(stale.Status).To(Equal(409), stale.String())
Expect(errType(stale)).To(Equal("stale_installed"))
changed := upgrade(cluster.FrontA, opID("up"), installed, "sha256:"+fmt.Sprintf("%064d", 1))
Expect(changed.Status).To(Equal(409), changed.String())
Expect(errType(changed)).To(Equal("candidate_changed"))
Expect(d.app(d.c.Token(cluster.Owner, "admin"))["version"]).To(Equal("0.1.0"), "a refused pin changed nothing")
before := d.rowCount(cluster.Owner, "member", res)
op := opID("up")
done := upgrade(cluster.FrontB, op, installed, candidate)
Expect(done.Status).To(Equal(200), done.String())
d.waitState("running")
for _, n := range nodes {
a := d.app(d.c.Token(n, "admin"))
Expect(a["version"]).To(Equal("0.2.0"), n)
}
// The same operation from another node is the recorded result.
again := upgrade(cluster.FrontA, op, installed, candidate)
Expect(again.Status).To(Equal(200), again.String())
// Data, grants and the receipts survived, and the new generation works.
for _, n := range nodes {
Expect(d.rowCount(n, "member", res)).To(Equal(before), n)
Expect(d.rowCount(n, "editor", res)).To(Equal(before), n)
Expect(d.read(d.c.Token(n, "friend"), res, map[string]any{"tool": "rows-query", "table": "Entries"}).Status).To(Equal(403), n)
}
Expect(d.apply(cluster.FrontB, "editor", res, opID("post-up"), "rows-insert", d.revisionOf(res), map[string]any{"table": "Entries", "rows": []any{map[string]any{"Item": "new"}}}).Status).To(Equal(200))
// A backup was made before the data was touched.
rec := d.tab(cluster.FrontA, "admin", "GET", "/api/addons/tables/recovery", nil, "")
Expect(rec.Status).To(Equal(200), rec.String())
reasons := ""
for _, b := range body(rec)["backups"].([]any) {
reasons += b.(map[string]any)["reason"].(string) + " "
}
Expect(reasons).To(ContainSubstring("upgrade"))
// And the check now says there is nothing newer.
final := check(cluster.FrontA, "admin")
Expect(body(final)["state"]).To(Equal("up-to-date"), final.String())
})
})
@@ -0,0 +1,287 @@
// SPDX-License-Identifier: MIT
// Package tablesacceptance_test is the end-to-end acceptance of the Tables
// add-on. It starts the foundation cluster (one owner and two forwarding
// frontends as separate server processes over the real mTLS carrier, one
// shared account store) and installs the real packaged Tables from a local
// catalog: the package is built by scripts/addons/package.sh from
// addons/tables, and its layers are the files the installer unpacks. Tests
// use real named sessions and API keys, the public HTTP routes only and the
// real SQLite databases of the add-on. Nothing is published anywhere.
package tablesacceptance_test
import (
"encoding/json"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
"github.com/mudler/LocalAI/tests/addons/foundation/cluster"
"github.com/mudler/LocalAI/tests/addons/tablesacceptance/tablespkg"
. "github.com/onsi/ginkgo/v2"
"github.com/onsi/ginkgo/v2/types"
. "github.com/onsi/gomega"
)
func TestTablesAcceptance(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Tables acceptance")
}
var (
repoRoot string
paths cluster.Paths
packages = map[string]tablespkg.Release{}
// commands records every command the suite itself ran, for the manifest.
commandsMu sync.Mutex
commands [][]string
// traces maps a failed spec to the retained log file of the cluster.
tracesMu sync.Mutex
traces = map[string]string{}
)
func record(cmd ...string) {
commandsMu.Lock()
defer commandsMu.Unlock()
commands = append(commands, cmd)
}
func run(dir string, env []string, name string, args ...string) {
GinkgoHelper()
record(append([]string{name}, args...)...)
cmd := exec.Command(name, args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), env...)
b, err := cmd.CombinedOutput()
Expect(err).NotTo(HaveOccurred(), string(b))
}
var _ = BeforeSuite(func() {
_, file, _, _ := runtime.Caller(0)
repoRoot = filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", ".."))
_, err := evidenceLocation(os.Getenv("TABLES_ACCEPTANCE_EVIDENCE"), repoRoot)
Expect(err).NotTo(HaveOccurred())
scratch, err := os.MkdirTemp("", "tables-acceptance-bin-*")
Expect(err).NotTo(HaveOccurred())
DeferCleanup(func() {
_ = filepath.WalkDir(scratch, func(p string, d fs.DirEntry, e error) error {
if e == nil && d.IsDir() {
_ = os.Chmod(p, 0o700)
}
return nil
})
_ = os.RemoveAll(scratch)
})
build := func(env, out, pkgPath string, extra ...string) string {
if p := os.Getenv(env); p != "" {
return p
}
target := filepath.Join(scratch, out)
args := append([]string{"build", "-p", "2", "-tags", "auth"}, extra...)
args = append(args, "-o", target, pkgPath)
run(repoRoot, []string{"CGO_ENABLED=1"}, "go", args...)
return target
}
paths.Server = build("TABLES_ACCEPTANCE_SERVER", "ui-test-server", "./tests/e2e-ui")
paths.Mock = os.Getenv("TABLES_ACCEPTANCE_MOCK")
if paths.Mock == "" {
paths.Mock = filepath.Join(repoRoot, "tests/e2e/mock-backend/mock-backend")
}
Expect(paths.Mock).To(BeAnExistingFile(), "build tests/e2e/mock-backend first")
exe := os.Getenv("TABLES_ACCEPTANCE_TABLES")
if exe == "" {
exe = filepath.Join(scratch, "tables")
Expect(tablespkg.BuildExecutable(repoRoot, exe, func(argv ...string) { record(argv...) })).To(Succeed())
}
// Two releases of the same platform: the second one is what the explicit
// upgrade installs. Both are built by the real packaging entry point.
built, err := tablespkg.Build(repoRoot, scratch, exe, func(argv ...string) { record(argv...) }, "0.1.0", "0.2.0")
Expect(err).NotTo(HaveOccurred())
for v, r := range built {
Expect(r.ImageDigest).To(MatchRegexp(`^sha256:[0-9a-f]{64}$`))
packages[v] = r
}
})
// start brings a cluster up in a fresh directory and takes it down at the end
// of the container. A failed spec keeps the logs of every node as a trace.
func start(versions ...string) *cluster.Cluster {
GinkgoHelper()
return startFrom(nil, packages, versions...)
}
// startFrom is start with releases of another build and a change of the
// cluster configuration that is saved before any process starts.
func startFrom(tune func(*cluster.Config), releases map[string]tablespkg.Release, versions ...string) *cluster.Cluster {
GinkgoHelper()
dir, err := os.MkdirTemp("", "tables-acceptance-*")
Expect(err).NotTo(HaveOccurred())
c, err := cluster.New(dir, paths)
Expect(err).NotTo(HaveOccurred())
if tune != nil {
tune(c.Config)
Expect(c.Config.Save()).To(Succeed())
}
for _, v := range versions {
_, err := c.Library.AddArchive("tables", v, releases[v].Archive)
Expect(err).NotTo(HaveOccurred())
}
DeferCleanup(func() {
retainTrace(c)
c.Down()
_ = filepath.WalkDir(dir, func(p string, d fs.DirEntry, e error) error {
if e == nil && d.IsDir() {
_ = os.Chmod(p, 0o700)
}
return nil
})
_ = os.RemoveAll(dir)
})
Expect(c.Up()).To(Succeed())
return c
}
// retainTrace copies the node logs of a failed spec to the log directory of
// the run, so the evidence of a failure outlives the scratch directory.
func retainTrace(c *cluster.Cluster) {
if !CurrentSpecReport().Failed() {
return
}
out := filepath.Join(repoRoot, ".cache/addons/logs/tables-acceptance-traces")
if err := os.MkdirAll(out, 0o755); err != nil {
return
}
name := sanitize(CurrentSpecReport().LeafNodeText) + ".log"
var b strings.Builder
for _, n := range cluster.Instances {
fmt.Fprintf(&b, "==== %s ====\n%s\n", n, c.Nodes[n].Tail(200))
}
path := filepath.Join(out, name)
if os.WriteFile(path, []byte(b.String()), 0o644) == nil {
tracesMu.Lock()
traces[CurrentSpecReport().LeafNodeText] = path
tracesMu.Unlock()
}
}
func sanitize(s string) string {
var b strings.Builder
for _, r := range strings.ToLower(s) {
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
b.WriteRune(r)
} else {
b.WriteByte('-')
}
}
out := b.String()
if len(out) > 80 {
out = out[:80]
}
return out
}
// ---- evidence manifest ----
// evidencePath is where the suite writes the acceptance evidence.
func evidencePath() string {
p, err := evidenceLocation(os.Getenv("TABLES_ACCEPTANCE_EVIDENCE"), repoRoot)
if err != nil {
panic(err)
}
return p
}
// evidenceLocation resolves where the manifest goes. A relative path would
// resolve against the package directory of the test binary and leave the
// manifest inside the tree, so it is refused.
func evidenceLocation(env, root string) (string, error) {
if env == "" {
return filepath.Join(root, ".cache/addons/tables-acceptance-evidence.json"), nil
}
if !filepath.IsAbs(env) {
return "", fmt.Errorf("TABLES_ACCEPTANCE_EVIDENCE must be an absolute path, got %q", env)
}
return env, nil
}
type evidenceCase struct {
Name string `json:"name"`
State string `json:"state"`
Millis int64 `json:"millis"`
Trace string `json:"trace,omitempty"`
Error string `json:"error,omitempty"`
}
type evidence struct {
Schema int `json:"schema"`
Suite string `json:"suite"`
Written string `json:"written"`
Platform platform `json:"platform"`
Commands [][]string `json:"commands"`
Artifact artifact `json:"artifact"`
Cases []evidenceCase `json:"cases"`
// Browser holds the Playwright cases; the runner adds them after the
// browser run, from its report.
Browser []evidenceCase `json:"browser,omitempty"`
Publication string `json:"publication"`
}
type platform struct {
OS string `json:"os"`
Arch string `json:"arch"`
Native bool `json:"native"`
}
type artifact struct {
Addon string `json:"addon"`
Versions map[string]digest `json:"versions"`
}
type digest struct {
Archive string `json:"archive"`
Image string `json:"image"`
}
var _ = ReportAfterSuite("write the acceptance evidence manifest", func(report Report) {
ev := evidence{
Schema: 1, Suite: "tables-acceptance", Written: time.Now().UTC().Format(time.RFC3339),
Platform: platform{OS: runtime.GOOS, Arch: runtime.GOARCH, Native: true},
Artifact: artifact{Addon: "tables", Versions: map[string]digest{}},
Publication: "none: the packages are built and installed locally and nothing is pushed, tagged or signed",
}
commandsMu.Lock()
ev.Commands = append(ev.Commands, commands...)
commandsMu.Unlock()
ev.Commands = append(ev.Commands, []string{"go", "test", "-count=1", "-tags", "auth", "./tests/addons/tablesacceptance"})
for v, p := range packages {
ev.Artifact.Versions[v] = digest{Archive: p.ArchiveDigest, Image: p.ImageDigest}
}
tracesMu.Lock()
defer tracesMu.Unlock()
for _, s := range report.SpecReports {
if s.LeafNodeType != types.NodeTypeIt {
continue
}
c := evidenceCase{Name: s.LeafNodeText, State: s.State.String(), Millis: s.RunTime.Milliseconds(), Trace: traces[s.LeafNodeText]}
if s.Failed() {
c.Error = s.Failure.Message
}
ev.Cases = append(ev.Cases, c)
}
b, err := json.MarshalIndent(ev, "", " ")
if err != nil {
return
}
_ = os.MkdirAll(filepath.Dir(evidencePath()), 0o755)
tmp := evidencePath() + ".tmp"
if os.WriteFile(tmp, b, 0o644) == nil {
_ = os.Rename(tmp, evidencePath())
}
})
@@ -0,0 +1,156 @@
// SPDX-License-Identifier: MIT
// Package tablespkg builds the installable Tables packages of the acceptance
// suites with the real packaging entry point. It runs scripts/addons/package.sh
// to make the OCI layout of one platform, checks every layer against its
// digest and joins the layers into the plain archive a local catalog serves.
// Nothing is signed, pushed or published.
package tablespkg
import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
)
// Release is one packaged version of Tables.
type Release struct {
Version string
// Archive is the plain tar the catalog serves: the layers of the layout
// of package.sh in one archive.
Archive []byte
// ArchiveDigest is the catalog digest of Archive.
ArchiveDigest string
// ImageDigest is the digest of the platform image in the layout.
ImageDigest string
}
// Recorder receives every command the builder runs.
type Recorder func(argv ...string)
func run(root string, rec Recorder, env []string, name string, args ...string) error {
if rec != nil {
rec(append([]string{name}, args...)...)
}
cmd := exec.Command(name, args...) // #nosec G204 -- test harness starts the binaries it built
cmd.Dir = root
cmd.Env = append(cmd.Environ(), env...)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%s %s: %w\n%s", name, strings.Join(args, " "), err, out)
}
return nil
}
// BuildExecutable builds the Tables executable of this platform.
func BuildExecutable(root, out string, rec Recorder) error {
return BuildExecutableFrom(root, out, "./addons/tables/cmd/tables", rec)
}
// BuildExecutableFrom builds another main package of the Tables module. The
// fault suites use it for the test-only executable; nothing else does.
func BuildExecutableFrom(root, out, pkg string, rec Recorder) error {
return run(root, rec, []string{"CGO_ENABLED=1"}, "go", "build", "-p", "2", "-trimpath", "-o", out, pkg)
}
// Build packages one executable under each version and returns the releases.
// scratch must be an empty directory the caller removes.
func Build(root, scratch, executable string, rec Recorder, versions ...string) (map[string]Release, error) {
if _, err := os.Stat(filepath.Join(root, "addons/tables/ui/dist/ui/tables.js")); err != nil {
if err := run(root, rec, nil, "node", "addons/tables/ui/build.mjs"); err != nil {
return nil, err
}
}
out := map[string]Release{}
for _, version := range versions {
layout := filepath.Join(scratch, "layout-"+version)
if err := run(root, rec, nil, "sh", "scripts/addons/package.sh", "--layout", layout, "--version", version,
"--platform", runtime.GOOS+"/"+runtime.GOARCH, "--executable", executable, "--local-check"); err != nil {
return nil, err
}
archive, image, err := mergeLayers(layout)
if err != nil {
return nil, err
}
sum := sha256.Sum256(archive)
out[version] = Release{Version: version, Archive: archive, ArchiveDigest: "sha256:" + hex.EncodeToString(sum[:]), ImageDigest: image}
}
return out, nil
}
// mergeLayers turns the layers of an OCI layout into one plain tar, in the
// order the receipt of package.sh lists them.
func mergeLayers(layout string) ([]byte, string, error) {
raw, err := os.ReadFile(filepath.Join(layout, "receipt.json")) // #nosec G304 -- test harness reads files it created in its own work directory
if err != nil {
return nil, "", err
}
var receipt struct {
ManifestDigest string `json:"manifestDigest"`
Publishable bool `json:"publishable"`
Layers []struct {
Digest string `json:"digest"`
} `json:"layers"`
}
if err := json.Unmarshal(raw, &receipt); err != nil {
return nil, "", err
}
if receipt.Publishable {
return nil, "", fmt.Errorf("a local acceptance package must not be publishable")
}
var out bytes.Buffer
tw := tar.NewWriter(&out)
seen := map[string]bool{}
for _, l := range receipt.Layers {
hexDigest := strings.TrimPrefix(l.Digest, "sha256:")
blob, err := os.ReadFile(filepath.Join(layout, "blobs", "sha256", hexDigest)) // #nosec G304 -- test harness reads files it created in its own work directory
if err != nil {
return nil, "", err
}
sum := sha256.Sum256(blob)
if hex.EncodeToString(sum[:]) != hexDigest {
return nil, "", fmt.Errorf("layer %s does not match its digest", l.Digest)
}
zr, err := gzip.NewReader(bytes.NewReader(blob))
if err != nil {
return nil, "", err
}
tr := tar.NewReader(zr)
for {
h, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, "", err
}
if seen[h.Name] {
return nil, "", fmt.Errorf("duplicate path %s in the layers", h.Name)
}
seen[h.Name] = true
if err := tw.WriteHeader(&tar.Header{Name: h.Name, Mode: h.Mode, Size: h.Size, Typeflag: h.Typeflag}); err != nil {
return nil, "", err
}
if _, err := io.CopyN(tw, tr, h.Size); err != nil {
return nil, "", err
}
}
}
if err := tw.Close(); err != nil {
return nil, "", err
}
if !seen["manifest.json"] || !seen["tables"] {
return nil, "", fmt.Errorf("the package lacks its manifest or executable")
}
return out.Bytes(), receipt.ManifestDigest, nil
}
+270
View File
@@ -0,0 +1,270 @@
// SPDX-License-Identifier: MIT
package tablesadmin_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/google/uuid"
"github.com/mudler/LocalAI/pkg/addons/contract"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// apply sends one UI change as the person's own tab.
func (h *host) apply(user, res, op, tool string, rev map[string]any, args string) reply {
GinkgoHelper()
return h.applyAs(h.tab(user, res), res, op, tool, rev, args)
}
func (h *host) applyAs(header func(*http.Request), res, op, tool string, rev map[string]any, args string) reply {
GinkgoHelper()
b, _ := json.Marshal(map[string]any{"operationId": op, "generation": 1, "revision": rev, "tool": tool, "arguments": json.RawMessage(args)})
return h.send("POST", "/api/addons/resources/"+res+"/apply", string(b), header)
}
// read runs a typed read as the person and returns the payload.
func (h *host) read(user, res, args string) (map[string]any, error) {
GinkgoHelper()
out, e := h.app.AddonGateway().Dispatch(context.Background(), h.actor(user), contract.Request{
AddonID: "tables", ResourceID: res, OperationID: "read-" + uuid.NewString()[:8], Generation: 1, Action: "read",
Arguments: json.RawMessage(args),
})
if e != nil {
return nil, e
}
var v map[string]any
Expect(json.Unmarshal(out.Payload, &v)).To(Succeed())
return v, nil
}
func revisionOf(v map[string]any) map[string]any {
GinkgoHelper()
rev, _ := v["revision"].(map[string]any)
Expect(rev).NotTo(BeNil())
return rev
}
var _ = Describe("UI changes of a database by the person's own action", func() {
It("applies schema and row changes under the roles of the host, once per operation, with a receipt and no proposal", func() {
h := ready(false)
res := h.database("bob", "ap-1", "Ledger")
Expect(h.grant("bob", res, "carol", "viewer").code).To(Equal(200))
Expect(h.grant("bob", res, "dave", "editor").code).To(Equal(200))
inspect := func(user string) map[string]any {
v, e := h.read(user, res, `{"tool":"databases-inspect"}`)
Expect(e).NotTo(HaveOccurred())
return v
}
// The owner changes the schema: a table and a column.
made := h.apply("bob", res, "ap-t1", "tables-create", revisionOf(inspect("bob")), `{"table":"Entries"}`)
Expect(made.code).To(Equal(200), made.raw)
Expect(made.body["resourceId"]).To(Equal(res))
Expect(made.body["operationId"]).To(Equal("ap-t1"))
Expect(made.body["revision"]).NotTo(Equal(map[string]any{"schema": 1, "data": 0}))
col := h.apply("bob", res, "ap-c1", "columns-add", revisionOf(inspect("bob")), `{"table":"Entries","column":"Item","type":"text","required":true}`)
Expect(col.code).To(Equal(200), col.raw)
qty := h.apply("bob", res, "ap-c2", "columns-add", revisionOf(inspect("bob")), `{"table":"Entries","column":"Qty","type":"integer"}`)
Expect(qty.code).To(Equal(200), qty.raw)
// An editor and the owner write rows; the receipt reports the ids.
rev := revisionOf(inspect("dave"))
ins := h.apply("dave", res, "ap-r1", "rows-insert", rev, `{"table":"Entries","rows":[{"Item":"pen","Qty":3},{"Item":"ink","Qty":1}]}`)
Expect(ins.code).To(Equal(200), ins.raw)
result, _ := ins.body["result"].(map[string]any)
Expect(result["count"]).To(BeEquivalentTo(2))
Expect(result["ids"]).To(HaveLen(2))
page, e := h.read("carol", res, `{"tool":"rows-query","table":"Entries"}`)
Expect(e).NotTo(HaveOccurred())
Expect(page["rows"]).To(HaveLen(2))
// The same operation with the same request is the same change.
again := h.apply("dave", res, "ap-r1", "rows-insert", rev, `{"table":"Entries","rows":[{"Item":"pen","Qty":3},{"Item":"ink","Qty":1}]}`)
Expect(again.code).To(Equal(200), again.raw)
Expect(again.body["revision"]).To(Equal(ins.body["revision"]))
page, _ = h.read("carol", res, `{"tool":"rows-query","table":"Entries"}`)
Expect(page["rows"]).To(HaveLen(2))
// The same operation with another request changes nothing.
other := h.apply("dave", res, "ap-r1", "rows-insert", rev, `{"table":"Entries","rows":[{"Item":"cup","Qty":9}]}`)
Expect(other.code).To(Equal(http.StatusConflict), other.raw)
Expect(other.errType()).To(Equal("operation_conflict"))
page, _ = h.read("carol", res, `{"tool":"rows-query","table":"Entries"}`)
Expect(page["rows"]).To(HaveLen(2))
// The receipt names the request it belongs to, so a client can tell
// the change it asked for from another one under the same ID.
hash, _ := ins.body["requestHash"].(string)
Expect(hash).To(MatchRegexp(`^[0-9a-f]{64}$`))
Expect(again.body["requestHash"]).To(Equal(hash))
own := h.send("GET", "/api/addons/resources/"+res+"/operations/ap-r1?kind=apply&requestHash="+hash, "", h.tab("dave", res))
Expect(own.code).To(Equal(200), own.raw)
Expect(own.body["state"]).To(Equal("committed"))
Expect(own.body["tool"]).To(Equal("rows-insert"))
Expect(own.body["requestHash"]).To(Equal(hash))
foreign := h.send("GET", "/api/addons/resources/"+res+"/operations/ap-r1?kind=apply&requestHash="+strings.Repeat("ab", 32), "", h.tab("dave", res))
Expect(foreign.code).To(Equal(http.StatusConflict), foreign.raw)
Expect(foreign.errType()).To(Equal("operation_conflict"))
Expect(foreign.raw).NotTo(ContainSubstring("committed"))
bad := h.send("GET", "/api/addons/resources/"+res+"/operations/ap-r1?kind=apply&requestHash=nope", "", h.tab("dave", res))
Expect(bad.code).To(Equal(http.StatusBadRequest), bad.raw)
// The receipt is there for its own person, and nobody else's.
status := h.send("GET", "/api/addons/resources/"+res+"/operations/ap-r1?kind=apply", "", h.tab("dave", res))
Expect(status.code).To(Equal(200), status.raw)
Expect(status.body["state"]).To(Equal("committed"))
Expect(status.body["tool"]).To(Equal("rows-insert"))
Expect(status.body["requestHash"]).To(Equal(hash))
for _, user := range []string{"bob", "carol"} {
s := h.send("GET", "/api/addons/resources/"+res+"/operations/ap-r1?kind=apply", "", h.tab(user, res))
if user == "carol" {
Expect(s.code).To(Equal(http.StatusForbidden))
} else {
Expect(s.code).To(Equal(200), s.raw)
Expect(s.body["state"]).NotTo(Equal("committed"))
}
}
// A moved revision is stale and nothing is applied.
stale := h.apply("bob", res, "ap-r2", "rows-insert", rev, `{"table":"Entries","rows":[{"Item":"late","Qty":1}]}`)
Expect(stale.code).To(Equal(http.StatusConflict), stale.raw)
Expect(stale.errType()).To(Equal("stale"))
page, _ = h.read("bob", res, `{"tool":"rows-query","table":"Entries"}`)
Expect(page["rows"]).To(HaveLen(2))
// With the revision it read, it applies.
fresh := revisionOf(page)
ids := ins.body["result"].(map[string]any)["ids"].([]any)
upd := h.apply("bob", res, "ap-r3", "rows-update", fresh, fmt.Sprintf(`{"table":"Entries","ids":[%v],"set":{"Qty":10}}`, ids[0]))
Expect(upd.code).To(Equal(200), upd.raw)
del := h.apply("dave", res, "ap-r4", "rows-delete", upd.body["revision"].(map[string]any), fmt.Sprintf(`{"table":"Entries","ids":[%v]}`, ids[1]))
Expect(del.code).To(Equal(200), del.raw)
page, _ = h.read("carol", res, `{"tool":"rows-query","table":"Entries"}`)
Expect(page["rows"]).To(HaveLen(1))
// A change is not a proposal: nothing waits for a confirmation.
pending := h.send("GET", "/api/addons/proposals?state=pending", "", h.bearer("bob"))
Expect(pending.body["proposals"]).To(BeEmpty())
// The database that the change addressed is the one of the path.
Expect(inspect("bob")["name"]).To(Equal("Ledger"))
})
It("refuses crafted writes by a viewer, an editor's schema change, a revoked editor and an installation administrator", func() {
h := ready(false)
res := h.database("bob", "cr-1", "Crafted")
Expect(h.grant("bob", res, "carol", "viewer").code).To(Equal(200))
Expect(h.grant("bob", res, "dave", "editor").code).To(Equal(200))
v, e := h.read("bob", res, `{"tool":"databases-inspect"}`)
Expect(e).NotTo(HaveOccurred())
rev := revisionOf(v)
Expect(h.apply("bob", res, "cr-t", "tables-create", rev, `{"table":"T"}`).code).To(Equal(200))
v, _ = h.read("bob", res, `{"tool":"databases-inspect"}`)
rev = revisionOf(v)
Expect(h.apply("bob", res, "cr-c", "columns-add", rev, `{"table":"T","column":"A","type":"text"}`).code).To(Equal(200))
v, _ = h.read("bob", res, `{"tool":"databases-inspect"}`)
rev = revisionOf(v)
unknown := h.apply("bob", "r0123456789abcdef0123456789abcde", "cr-u", "rows-insert", rev, `{"table":"T","rows":[{"A":"x"}]}`)
Expect(unknown.code).To(Equal(http.StatusForbidden))
rows := func() int {
p, e := h.read("bob", res, `{"tool":"rows-query","table":"T"}`)
Expect(e).NotTo(HaveOccurred())
return len(p["rows"].([]any))
}
body := `{"table":"T","rows":[{"A":"x"}]}`
// Viewer, administrator without data privileges and a feature-less
// account get the answer of an unknown database.
for _, user := range []string{"carol", "alice"} {
got := h.apply(user, res, "cr-"+user, "rows-insert", rev, body)
Expect(got.code).To(Equal(unknown.code), user+" "+got.raw)
Expect(got.errType()).To(Equal("addon_error"), user)
}
Expect(h.apply("erin", res, "cr-erin", "rows-insert", rev, body).code).To(Equal(http.StatusForbidden))
// An editor changes rows and never the schema.
sch := h.apply("dave", res, "cr-dave-s", "tables-drop", rev, `{"table":"T"}`)
Expect(sch.code).To(Equal(unknown.code), sch.raw)
Expect(h.apply("dave", res, "cr-dave-c", "columns-add", rev, `{"table":"T","column":"B","type":"text"}`).code).To(Equal(unknown.code))
Expect(rows()).To(Equal(0))
// Not the person's own tab: a key, a bare session, a tool marker, a
// confirmation of another resource and a cross-site request.
marked := func(r *http.Request) { h.tab("bob", res)(r); r.Header.Set("X-LocalAI-Tool-Call", "1") }
elsewhere := h.tab("bob", "r0123456789abcdef0123456789abcde")
cross := func(r *http.Request) { h.tab("bob", res)(r); r.Header.Set("Sec-Fetch-Site", "cross-site") }
for name, header := range map[string]func(*http.Request){"key": h.bearer("bob"), "session": h.session("bob"), "marked": marked, "elsewhere": elsewhere, "cross-site": cross} {
got := h.applyAs(header, res, "cr-"+name, "rows-insert", rev, body)
Expect(got.code).To(BeElementOf(http.StatusForbidden, http.StatusBadRequest), name+" "+got.raw)
}
Expect(rows()).To(Equal(0))
// What is not a data or schema change cannot be applied: reads,
// creations, renames, the admin delete and undeclared tools.
for _, tool := range []string{"rows-query", "databases-create", "databases-rename", "databases-inspect", "databases-delete", "resource-exists", "nothing"} {
got := h.apply("bob", res, "cr-tool-"+tool, tool, rev, `{"name":"X","table":"T"}`)
Expect(got.code).To(BeElementOf(http.StatusBadRequest, http.StatusForbidden), tool+" "+got.raw)
}
// A body that names the resource, the tool, the actor or an unknown
// field never reaches the add-on.
for _, args := range []string{
`{"table":"T","rows":[{"A":"x"}],"resourceId":"r0123456789abcdef0123456789abcde"}`,
`{"table":"T","rows":[{"A":"x"}],"tool":"tables-drop"}`,
`{"table":"T","rows":[{"A":"x"}],"userId":"alice"}`,
`{"table":"T","rows":[{"A":"x"}],"extra":1}`,
`[]`, `{}`,
} {
got := h.apply("bob", res, "cr-args", "rows-insert", rev, args)
Expect(got.code).To(Equal(http.StatusBadRequest), args+" "+got.raw)
}
// A stale generation and a missing revision.
b := fmt.Sprintf(`{"operationId":"cr-gen","generation":9,"revision":{"schema":%v,"data":%v},"tool":"rows-insert","arguments":%s}`, rev["schema"], rev["data"], body)
got := h.send("POST", "/api/addons/resources/"+res+"/apply", b, h.tab("bob", res))
Expect(got.code).To(Equal(http.StatusConflict))
Expect(got.errType()).To(Equal("stale"))
noRev := h.send("POST", "/api/addons/resources/"+res+"/apply", `{"operationId":"cr-norev","generation":1,"tool":"rows-insert","arguments":`+body+`}`, h.tab("bob", res))
Expect(noRev.code).To(Equal(http.StatusBadRequest))
Expect(rows()).To(Equal(0))
// A revoked editor is refused at the next change, and one that was
// writing is stopped before the revocation is acknowledged.
Expect(h.apply("dave", res, "cr-ok", "rows-insert", rev, body).code).To(Equal(200))
Expect(h.revoke("bob", res, "dave").code).To(Equal(200))
v, _ = h.read("bob", res, `{"tool":"rows-query","table":"T"}`)
after := h.apply("dave", res, "cr-late", "rows-insert", revisionOf(v), body)
Expect(after.code).To(Equal(unknown.code), after.raw)
Expect(rows()).To(Equal(1))
})
It("carries a UI change to the owner from a forwarding frontend and answers as the owner does", func() {
h := ready(false)
front := h.frontend()
res := h.database("bob", "fa-1", "Forwarded")
Expect(h.grant("bob", res, "dave", "editor").code).To(Equal(200))
v, _ := h.read("bob", res, `{"tool":"databases-inspect"}`)
rev := revisionOf(v)
send := func(header func(*http.Request), op string, rev map[string]any, tool, args string) reply {
b, _ := json.Marshal(map[string]any{"operationId": op, "generation": 1, "revision": rev, "tool": tool, "arguments": json.RawMessage(args)})
return serve(front, "POST", "/api/addons/resources/"+res+"/apply", string(b), header)
}
made := send(h.tab("bob", res), "fa-t", rev, "tables-create", `{"table":"T"}`)
Expect(made.code).To(Equal(200), made.raw)
// Stale and forbidden answers are the owner's.
stale := send(h.tab("bob", res), "fa-s", rev, "tables-create", `{"table":"U"}`)
Expect(stale.code).To(Equal(http.StatusConflict))
Expect(stale.errType()).To(Equal("stale"))
direct := h.apply("bob", res, "fa-s2", "tables-create", rev, `{"table":"U"}`)
Expect(direct.code).To(Equal(stale.code))
Expect(direct.errType()).To(Equal(stale.errType()))
denied := send(h.tab("dave", res), "fa-d", made.body["revision"].(map[string]any), "tables-drop", `{"table":"T"}`)
Expect(denied.code).To(Equal(http.StatusForbidden))
Expect(send(h.bearer("bob"), "fa-k", rev, "tables-create", `{"table":"V"}`).code).To(Equal(http.StatusForbidden))
// A frontend whose owner cannot be reached reports the outcome of a
// change as unknown, with the original operation ID.
dead := h.deadFrontend()
b, _ := json.Marshal(map[string]any{"operationId": "fa-dead", "generation": 1, "revision": made.body["revision"], "tool": "tables-create", "arguments": json.RawMessage(`{"table":"W"}`)})
got := serve(dead, "POST", "/api/addons/resources/"+res+"/apply", string(b), h.tab("bob", res))
Expect(got.code).To(Equal(http.StatusServiceUnavailable))
Expect(got.errType()).To(Equal("outcome_unknown"))
Expect(got.errOperation()).To(Equal("fa-dead"))
})
})
+307
View File
@@ -0,0 +1,307 @@
// SPDX-License-Identifier: MIT
package tablesadmin_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
"github.com/mudler/LocalAI/core/services/addons/gateway"
"github.com/mudler/LocalAI/core/services/addons/policy"
"github.com/mudler/LocalAI/core/services/addons/tools"
"github.com/mudler/LocalAI/pkg/addons/contract"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("deleting a database is explicit, destructive and UI-only", func() {
It("deletes the data, the grants and the pending proposals only on the owner's typed confirmation and keeps the add-on installed", func() {
h := ready(false)
res := h.database("bob", "d-1", "Doomed")
Expect(h.grant("bob", res, "carol", "viewer").code).To(Equal(200))
// A pending change of the owner, waiting for a human decision.
propose := fmt.Sprintf(`{"addonId":"tables","resourceId":%q,"operationId":"d-p1","generation":1,"action":"schema","arguments":{"tool":"tables-create","table":"T"}}`, res)
made := h.send("POST", "/api/addons/proposals", propose, h.bearer("bob"))
Expect(made.code).To(Equal(200), made.raw)
Expect(made.body["state"]).To(Equal("pending"))
rev := h.revision("bob", res)
// Nobody but the owner's own session deletes: not an administrator,
// not a grantee, not an API key, and not without the confirmation.
for user, got := range map[string]reply{
"alice": h.remove("alice", res, "d-a", "Doomed", rev),
"carol": h.remove("carol", res, "d-c", "Doomed", rev),
} {
Expect(got.code).To(Equal(http.StatusForbidden), user+got.raw)
}
body := `{"operationId":"d-k","generation":1,"revision":{"schema":1,"data":0},"confirm":"Doomed"}`
Expect(h.send("POST", "/api/addons/resources/"+res+"/delete", body, h.bearer("bob")).code).To(Equal(http.StatusForbidden))
Expect(h.send("POST", "/api/addons/resources/"+res+"/delete", body, h.session("bob")).code).To(Equal(http.StatusForbidden))
wrong := h.remove("bob", res, "d-w", "doomed", rev)
Expect(wrong.code).To(Equal(http.StatusBadRequest), wrong.raw)
Expect(wrong.errType()).To(Equal("invalid_request"))
for _, confirm := range []string{"", "Doomed ", res} {
got := h.remove("bob", res, "d-w", confirm, rev)
Expect(got.code).To(Equal(http.StatusBadRequest), confirm)
}
// Nothing happened: the database, its file and its grants are intact.
Expect(resourcesOf(h.list("bob"))).To(HaveKey(res))
Expect(h.dbFile(res)).To(BeARegularFile())
name, e := h.name("carol", res)
Expect(e).NotTo(HaveOccurred())
Expect(name).To(Equal("Doomed"))
done := h.remove("bob", res, "d-ok", "Doomed", rev)
Expect(done.code).To(Equal(http.StatusOK), done.raw)
Expect(done.body["deleted"]).To(Equal(true))
Expect(done.body["resourceId"]).To(Equal(res))
// The data is gone, the record and the grants are gone, and nothing
// of it is reachable by anyone.
_, statErr := os.Stat(h.dbFile(res))
Expect(os.IsNotExist(statErr)).To(BeTrue())
for _, user := range []string{"bob", "carol", "alice"} {
Expect(resourcesOf(h.list(user))).NotTo(HaveKey(res), user)
_, e := h.name(user, res)
Expect(e).To(HaveOccurred(), user)
}
Expect(h.app.AddonPolicy().Deleting()).To(BeEmpty())
_, known := h.app.AddonPolicy().Key(res)
Expect(known).To(BeFalse())
// The pending proposal can never run.
card := h.send("GET", "/api/addons/proposals/d-p1", "", h.bearer("bob"))
Expect(card.body["state"]).To(Equal("cancelled"))
// Deleting is not an uninstall: the add-on stays installed and
// running and other databases still work.
h.running()
other := h.database("bob", "d-2", "Survivor")
name, _ = h.name("bob", other)
Expect(name).To(Equal("Survivor"))
// The same call again, after its answer was lost, is the same success
// for the owner who asked, and the receipt lookup says committed. Any
// other operation, user or database is the denial of an unknown one.
again := h.remove("bob", res, "d-ok", "Doomed", rev)
Expect(again.code).To(Equal(http.StatusOK), again.raw)
Expect(again.body["deleted"]).To(Equal(true))
status := h.send("GET", "/api/addons/resources/"+res+"/operations/d-ok?kind=delete", "", h.tab("bob", res))
Expect(status.code).To(Equal(200), status.raw)
Expect(status.body["state"]).To(Equal("committed"))
Expect(h.remove("bob", res, "d-other", "Doomed", rev).code).To(Equal(http.StatusForbidden))
Expect(h.remove("carol", res, "d-ok", "Doomed", rev).code).To(Equal(http.StatusForbidden))
Expect(h.remove("alice", res, "d-ok", "Doomed", rev).code).To(Equal(http.StatusForbidden))
Expect(h.send("GET", "/api/addons/resources/"+res+"/operations/d-ok?kind=delete", "", h.tab("carol", res)).code).To(Equal(http.StatusForbidden))
Expect(h.send("GET", "/api/addons/resources/"+res+"/operations/d-ok?kind=delete", "", h.tab("alice", res)).code).To(Equal(http.StatusForbidden))
})
It("stops and waits for admitted work of every user before it asks the add-on to delete", func() {
h := ready(false)
res := h.database("bob", "w-1", "Busy")
Expect(h.grant("bob", res, "carol", "editor").code).To(Equal(200))
p := h.app.AddonPolicy()
started, ended := make(chan struct{}), make(chan struct{})
go func() {
defer GinkgoRecover()
_ = p.Admit(context.Background(), h.actor("carol"), h.key(res), policy.ActionWrite, func(ctx context.Context) error {
close(started)
<-ctx.Done()
close(ended)
return ctx.Err()
})
}()
Eventually(started).Should(BeClosed())
done := h.remove("bob", res, "w-del", "Busy", h.revision("bob", res))
Expect(done.code).To(Equal(http.StatusOK), done.raw)
// The admitted work had ended when the deletion was acknowledged.
Expect(ended).To(BeClosed())
Expect(h.dbFile(res)).NotTo(BeAnExistingFile())
})
It("makes the add-on refuse its delete tool under every action but the host's delete action", func() {
h := ready(false)
res := h.database("bob", "a-1", "Held")
ctx := context.Background()
inst, e := h.app.AddonManager().Runtime(ctx, "tables")
Expect(e).NotTo(HaveOccurred())
envelope := func(action, scope, op string) contract.Envelope {
return contract.Envelope{
Request: contract.Request{AddonID: "tables", ResourceID: res, OperationID: op, Generation: inst.Generation, Action: action, Arguments: json.RawMessage(`{"tool":"databases-delete"}`)},
Authority: contract.Authority{Actor: h.actor("bob"), Scope: scope, ExpiresAt: time.Now().Add(time.Minute)},
}
}
for i, action := range []string{"read", "write", "schema", "create", "output"} {
op := fmt.Sprintf("a-op-%d", i)
_, e = inst.Client.Preview(ctx, envelope(action, "preview", op))
Expect(e).To(HaveOccurred(), action)
_, e = inst.Client.Apply(ctx, envelope(action, "apply", op), contract.Revision{Schema: 1})
Expect(e).To(HaveOccurred(), action)
}
_, e = inst.Client.Read(ctx, envelope("read", "read", "a-op-read"))
Expect(e).To(HaveOccurred())
Expect(h.dbFile(res)).To(BeARegularFile())
// Under the delete action the same envelope previews and changes nothing.
prev, e := inst.Client.Preview(ctx, envelope("delete", "preview", "a-op-del"))
Expect(e).NotTo(HaveOccurred())
Expect(prev.Revision.Schema).NotTo(BeZero())
// An apply for a revision the database no longer has is a rejected
// receipt, not a deletion.
_, e = inst.Client.Apply(ctx, envelope("delete", "apply", "a-op-del"), contract.Revision{Schema: 99})
Expect(e).NotTo(HaveOccurred(), "a moved revision is a rejected receipt, not a deletion")
Expect(h.dbFile(res)).To(BeARegularFile())
name, e := h.name("bob", res)
Expect(e).NotTo(HaveOccurred())
Expect(name).To(Equal("Held"))
})
It("never offers the delete tool to a model, a proposal, a typed read or an agent", func() {
h := ready(false)
res := h.database("bob", "t-1", "Guarded")
ctx := context.Background()
adapter := tools.NewToolAdapter(h.app.AddonGateway())
defs, e := adapter.Discover(ctx, h.actor("bob"))
Expect(e).NotTo(HaveOccurred())
Expect(defs).NotTo(BeEmpty())
for _, d := range defs {
Expect(d.Tool).NotTo(Equal("databases-delete"))
}
_, e = adapter.Execute(ctx, h.actor("bob"), "addon__tables__databases-delete", json.RawMessage(fmt.Sprintf(`{"resourceId":%q}`, res)))
Expect(e).To(MatchError(tools.ErrUnknownTool))
// A typed read, a proposal of any action and a dispatch that names the
// admin tool are denied by the declared-tool rule, as the owner.
read := contract.Request{AddonID: "tables", ResourceID: res, OperationID: "t-r", Generation: 1, Action: "read", Arguments: json.RawMessage(`{"tool":"databases-delete"}`)}
_, e = h.app.AddonGateway().Dispatch(ctx, h.actor("bob"), read)
Expect(e).To(HaveOccurred())
Expect(gateway.StatusFor(e)).To(Equal(403))
for i, action := range []string{"write", "schema", "delete", "create"} {
req := contract.Request{AddonID: "tables", ResourceID: res, OperationID: fmt.Sprintf("t-p%d", i), Generation: 1, Action: action, Arguments: json.RawMessage(`{"tool":"databases-delete"}`)}
if action == "create" {
req.ResourceID = ""
}
_, e = h.app.AddonGateway().Propose(ctx, h.actor("bob"), req)
Expect(e).To(HaveOccurred(), action)
Expect(gateway.StatusFor(e)).To(Equal(403), action)
}
// The HTTP routes say the same.
for _, action := range []string{"read", "write", "schema", "delete"} {
b := fmt.Sprintf(`{"addonId":"tables","resourceId":%q,"operationId":"t-h-%s","generation":1,"action":%q,"arguments":{"tool":"databases-delete"}}`, res, action, action)
Expect(h.send("POST", "/api/addons/proposals", b, h.bearer("bob")).code).To(Equal(403), action)
Expect(h.send("POST", "/api/addons/dispatch", b, h.bearer("bob")).code).To(BeElementOf(403), action)
}
// The Apps view lists the model tools and not the admin tool.
apps := h.send("GET", "/api/addons/apps", "", h.bearer("bob"))
Expect(apps.raw).NotTo(ContainSubstring("databases-delete"))
// Nothing was deleted.
Expect(h.dbFile(res)).To(BeARegularFile())
Expect(resourcesOf(h.list("bob"))).To(HaveKey(res))
})
It("denies a stale tab: the generation and the revision the owner saw decide, and nothing is removed", func() {
h := ready(false)
res := h.database("bob", "s-1", "Tabbed")
seen := h.revision("bob", res)
// A change of the schema through the usual proposal path moves the
// revision the old tab saw.
propose := fmt.Sprintf(`{"addonId":"tables","resourceId":%q,"operationId":"s-p1","generation":1,"action":"schema","arguments":{"tool":"tables-create","table":"Moves"}}`, res)
made := h.send("POST", "/api/addons/proposals", propose, h.bearer("bob"))
Expect(made.code).To(Equal(200), made.raw)
ok := h.send("POST", "/api/addons/proposals/s-p1/confirm", fmt.Sprintf(`{"digest":%q}`, made.body["digest"]), h.tab("bob", "s-p1"))
Expect(ok.code).To(Equal(200), ok.raw)
Expect(h.revision("bob", res)).NotTo(Equal(seen))
got := h.remove("bob", res, "s-d1", "Tabbed", seen)
Expect(got.code).To(Equal(http.StatusConflict), got.raw)
Expect(got.errType()).To(Equal("stale"))
got = h.rename("bob", res, "s-r1", "Renamed", seen)
Expect(got.code).To(Equal(http.StatusConflict), got.raw)
Expect(got.errType()).To(Equal("stale"))
// A generation other than the installed one is stale too.
b, _ := json.Marshal(map[string]any{"operationId": "s-d2", "generation": 7, "revision": h.revision("bob", res), "confirm": "Tabbed"})
got = h.send("POST", "/api/addons/resources/"+res+"/delete", string(b), h.tab("bob", res))
Expect(got.code).To(Equal(http.StatusConflict), got.raw)
Expect(got.errType()).To(Equal("stale"))
// The resource was returned to use at once.
Expect(h.dbFile(res)).To(BeARegularFile())
name, e := h.name("bob", res)
Expect(e).NotTo(HaveOccurred())
Expect(name).To(Equal("Tabbed"))
Expect(h.app.AddonPolicy().Deleting()).To(BeEmpty())
// With the current view it works.
Expect(h.remove("bob", res, "s-d3", "Tabbed", h.revision("bob", res)).code).To(Equal(200))
})
It("finishes or undoes a deletion that a crash interrupted at every step", func() {
h := ready(false)
ctx := context.Background()
p := h.app.AddonPolicy()
bob := h.actor("bob")
confirmed := h.database("bob", "k-1", "Confirmed")
unconfirmed := h.database("bob", "k-2", "Unconfirmed")
gone := h.database("bob", "k-3", "AlreadyGone")
kept := h.database("bob", "k-4", "Untouched")
Expect(h.grant("bob", confirmed, "carol", "editor").code).To(Equal(200))
// A crash after the confirmation and before the add-on deleted.
Expect(p.BeginDelete(ctx, bob, h.key(confirmed), "k-op1")).To(Succeed())
Expect(p.ConfirmDelete(h.key(confirmed))).To(Succeed())
// A crash while the owner was still checking its preconditions.
Expect(p.BeginDelete(ctx, bob, h.key(unconfirmed), "k-op2")).To(Succeed())
// A crash after the add-on removed the data and before the record went.
Expect(p.BeginDelete(ctx, bob, h.key(gone), "k-op3")).To(Succeed())
Expect(p.ConfirmDelete(h.key(gone))).To(Succeed())
Expect(os.Remove(h.dbFile(gone))).To(Succeed())
// While a deletion is in progress the database is inaccessible to
// everyone, owner included, and nothing new is admitted.
for _, res := range []string{confirmed, unconfirmed, gone} {
Expect(resourcesOf(h.list("bob"))).NotTo(HaveKey(res))
_, e := h.name("bob", res)
Expect(e).To(HaveOccurred())
Expect(h.grant("bob", res, "dave", "viewer").code).To(Equal(http.StatusForbidden))
}
Expect(p.Deleting()).To(HaveLen(3))
Expect(resourcesOf(h.list("bob"))).To(HaveKey(kept))
// The owner's receipt lookup says the deletion is still pending.
pending := h.send("GET", "/api/addons/resources/"+confirmed+"/operations/k-op1?kind=delete", "", h.tab("bob", confirmed))
Expect(pending.code).To(Equal(200), pending.raw)
Expect(pending.body["state"]).To(Equal("pending"))
Expect(h.send("GET", "/api/addons/resources/"+confirmed+"/operations/k-op1?kind=delete", "", h.tab("carol", confirmed)).code).To(Equal(http.StatusForbidden))
h.stop()
h.start()
h.running()
// The confirmed deletion was finished, the unconfirmed one undone.
Expect(h.app.AddonPolicy().Deleting()).To(BeEmpty())
Expect(h.dbFile(confirmed)).NotTo(BeAnExistingFile())
listed := resourcesOf(h.list("bob"))
Expect(listed).NotTo(HaveKey(confirmed))
Expect(listed).NotTo(HaveKey(gone))
Expect(listed).To(HaveKey(unconfirmed))
Expect(listed).To(HaveKey(kept))
name, e := h.name("bob", unconfirmed)
Expect(e).NotTo(HaveOccurred())
Expect(name).To(Equal("Unconfirmed"))
Expect(resourcesOf(h.list("carol"))).NotTo(HaveKey(confirmed))
_, known := h.app.AddonPolicy().Key(confirmed)
Expect(known).To(BeFalse())
_, known = h.app.AddonPolicy().Key(gone)
Expect(known).To(BeFalse())
// What stays is a tombstone for the owner, and it survived the restart.
for res, op := range map[string]string{confirmed: "k-op1", gone: "k-op3"} {
done, e := h.app.AddonPolicy().DeletedBy(ctx, bob, res, op)
Expect(e).NotTo(HaveOccurred())
Expect(done).To(BeTrue(), res)
status := h.send("GET", "/api/addons/resources/"+res+"/operations/"+op+"?kind=delete", "", h.tab("bob", res))
Expect(status.code).To(Equal(200), status.raw)
Expect(status.body["state"]).To(Equal("committed"))
}
done, e := h.app.AddonPolicy().DeletedBy(ctx, h.actor("carol"), confirmed, "k-op1")
Expect(e).NotTo(HaveOccurred())
Expect(done).To(BeFalse())
done, e = h.app.AddonPolicy().DeletedBy(ctx, bob, unconfirmed, "k-op2")
Expect(e).NotTo(HaveOccurred())
Expect(done).To(BeFalse(), "an undone deletion leaves no tombstone")
})
})
+144
View File
@@ -0,0 +1,144 @@
// SPDX-License-Identifier: MIT
package tablesadmin_test
import (
"encoding/json"
"net/http"
"os"
"strings"
"github.com/mudler/LocalAI/core/http/auth"
"github.com/mudler/LocalAI/core/services/addons/gateway"
"github.com/mudler/LocalAI/pkg/addons/contract"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("the advertised surface of the resource management routes", func() {
It("documents every route, every emitted field and every typed answer in the Swagger spec", func() {
spec, err := os.ReadFile("../../../swagger/swagger.json")
Expect(err).NotTo(HaveOccurred())
var doc struct {
Paths map[string]map[string]json.RawMessage `json:"paths"`
Definitions map[string]struct {
Properties map[string]any `json:"properties"`
} `json:"definitions"`
}
Expect(json.Unmarshal(spec, &doc)).To(Succeed())
for path, methods := range map[string][]string{
"/api/addons/resources": {"get", "post"},
"/api/addons/resources/{resourceId}/grants": {"get"},
"/api/addons/resources/{resourceId}/grants/{userId}": {"put", "delete"},
"/api/addons/resources/{resourceId}/users": {"get"},
"/api/addons/resources/{resourceId}/rename": {"post"},
"/api/addons/resources/{resourceId}/delete": {"post"},
"/api/addons/resources/{resourceId}/apply": {"post"},
"/api/addons/resources/{resourceId}/operations/{operationId}": {"get"},
"/api/addons/conversations/{conversationId}/selection/{addonId}": {"get", "put", "delete"},
} {
Expect(doc.Paths).To(HaveKey(path))
for _, method := range methods {
Expect(doc.Paths[path]).To(HaveKey(method), path)
text := string(doc.Paths[path][method])
Expect(text).To(ContainSubstring(`"Addons"`), path+" "+method)
Expect(text).To(ContainSubstring("MCP"), path+" "+method+" must say that no MCP tool maps to it, or that it is read-only")
}
}
for _, check := range []struct{ path, method string }{
{"/api/addons/resources/{resourceId}/rename", "post"}, {"/api/addons/resources/{resourceId}/delete", "post"}, {"/api/addons/resources/{resourceId}/apply", "post"},
} {
text := string(doc.Paths[check.path][check.method])
for _, want := range []string{`"409"`, `"503"`, "stale", "outcome_unknown", "X-LocalAI-Confirm"} {
Expect(text).To(ContainSubstring(want), check.path+" "+want)
}
}
Expect(string(doc.Paths["/api/addons/resources/{resourceId}/grants"]["get"])).To(ContainSubstring("sharing_disabled"))
emitted := func(v any) map[string]any {
b, err := json.Marshal(v)
Expect(err).NotTo(HaveOccurred())
var m map[string]any
Expect(json.Unmarshal(b, &m)).To(Succeed())
return m
}
rev := contract.Revision{Schema: 1, Data: 2}
n := 1
info := gateway.ResourceInfo{ID: "r", AddonID: "tables", Name: "N", Role: "owner", Owner: "O", SharedWith: &n, Revision: &rev}
for name, v := range map[string]any{
"gateway.ResourceInfo": info,
"gateway.ResourceList": gateway.ResourceList{AddonID: "tables", Generation: 1, Sharing: true, Noun: "database", Operations: []string{"create"}, Resources: []gateway.ResourceInfo{info}, Truncated: true},
"gateway.Grant": gateway.Grant{UserID: "u", DisplayName: "D", Role: "viewer"},
"gateway.GrantList": gateway.GrantList{ResourceID: "r", Grants: []gateway.Grant{}},
"gateway.UserRef": gateway.UserRef{ID: "u", DisplayName: "D"},
"gateway.UserList": gateway.UserList{Users: []gateway.UserRef{}},
"gateway.DeleteResult": gateway.DeleteResult{ResourceID: "r", Deleted: true},
"gateway.OperationStatus": gateway.OperationStatus{OperationID: "o", State: "committed", Tool: "rows-insert", RequestHash: "h"},
"gateway.Selection": gateway.Selection{AddonID: "tables", ConversationID: "c", ResourceID: "r", Role: "owner", Name: "N"},
"gateway.CreateResourceRequest": gateway.CreateResourceRequest{AddonID: "tables", OperationID: "o", Generation: 1, Arguments: json.RawMessage(`{}`)},
"gateway.RenameResourceRequest": gateway.RenameResourceRequest{OperationID: "o", Generation: 1, Revision: rev, Name: "N"},
"gateway.DeleteResourceRequest": gateway.DeleteResourceRequest{OperationID: "o", Generation: 1, Revision: rev, Confirm: "N"},
"gateway.SetGrantRequest": gateway.SetGrantRequest{Role: "viewer", OperationID: "o"},
"gateway.ApplyResourceRequest": gateway.ApplyResourceRequest{OperationID: "o", Generation: 1, Revision: rev, Tool: "rows-insert", Arguments: json.RawMessage(`{}`)},
"gateway.ApplyResult": gateway.ApplyResult{ResourceID: "r", OperationID: "o", Revision: rev, Result: json.RawMessage(`{}`), RequestHash: "h"},
"gateway.SelectionRequest": gateway.SelectionRequest{ResourceID: "r"},
} {
def, ok := doc.Definitions[name]
Expect(ok).To(BeTrue(), name)
shape := emitted(v)
for field := range shape {
Expect(def.Properties).To(HaveKey(field), name+"."+field)
}
for field := range def.Properties {
Expect(shape).To(HaveKey(field), name+" documents "+field+" that is never emitted")
}
}
// The error object documents the new types.
text, _ := json.Marshal(doc.Definitions["addons.AddonError"].Properties["type"])
for _, typ := range []string{"sharing_disabled", "stale", "invalid_request", "quota_reached", "not_selected", "operation_conflict"} {
Expect(string(text)).To(ContainSubstring(typ))
}
})
It("lists every route in the instructions registry, the feature registry and the documentation, with no MCP tool for any", func() {
h := ready(false)
got := h.send("GET", "/api/instructions/addons", "", h.bearer("alice"))
Expect(got.code).To(Equal(http.StatusOK), got.raw)
for _, want := range []string{"/api/addons/resources", "grants", "users?q=", "rename", "delete", "apply", "selection", "select_resource", "never tools"} {
Expect(got.raw).To(ContainSubstring(want), want)
}
registered := map[string]bool{}
for _, entry := range auth.RouteFeatureRegistry {
if entry.Feature == auth.FeatureAddons {
registered[entry.Method+" "+entry.Pattern] = true
}
}
docs, err := os.ReadFile("../../../docs/content/features/addons.md")
Expect(err).NotTo(HaveOccurred())
tables, err := os.ReadFile("../../../docs/content/features/tables.md")
Expect(err).NotTo(HaveOccurred())
for _, key := range []string{
"GET /api/addons/resources", "POST /api/addons/resources",
"GET /api/addons/resources/:resourceId/grants", "PUT /api/addons/resources/:resourceId/grants/:userId",
"DELETE /api/addons/resources/:resourceId/grants/:userId", "GET /api/addons/resources/:resourceId/users",
"POST /api/addons/resources/:resourceId/rename", "POST /api/addons/resources/:resourceId/delete",
"POST /api/addons/resources/:resourceId/apply",
"GET /api/addons/resources/:resourceId/operations/:operationId",
"GET /api/addons/conversations/:conversationId/selection/:addonId",
"PUT /api/addons/conversations/:conversationId/selection/:addonId",
"DELETE /api/addons/conversations/:conversationId/selection/:addonId",
} {
Expect(registered).To(HaveKey(key))
}
for _, want := range []string{"never tools", "sharing_disabled", "outcome_unknown", "capabilities.adminTools", "capabilities.resources", "select_resource", "X-LocalAI-Conversation-Id", "deleting"} {
Expect(string(docs)).To(ContainSubstring(want), want)
}
Expect(string(tables)).To(ContainSubstring("databases-delete"))
Expect(strings.Count(string(tables), "not tools")).To(BeNumerically(">=", 1))
// The admin assistant catalog names none of it.
for _, file := range []string{"../../../pkg/mcp/localaitools/tools.go"} {
b, err := os.ReadFile(file)
Expect(err).NotTo(HaveOccurred())
Expect(strings.ToLower(string(b))).NotTo(ContainSubstring("/api/addons"))
}
})
})
+455
View File
@@ -0,0 +1,455 @@
// SPDX-License-Identifier: MIT
package tablesadmin_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"github.com/mudler/LocalAI/core/services/addons/artifacts"
"github.com/mudler/LocalAI/core/services/addons/gateway"
"github.com/mudler/LocalAI/pkg/addons/contract"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// ordersCSV is the exact file the Orders table of ordersTable exports.
const ordersCSV = "Item,Note,Qty,Price,Paid\r\n" +
"'=SUM(A1:A9),\"line one\nline two\",-4,19.99,true\r\n" +
"\"Café, \"\"deluxe\"\"\",日本語,12,,\r\n" +
"plain,,,,\r\n"
// ordersTable creates a database for the user with the Orders table: hostile
// text, newlines, Unicode, a negative number and NULLs.
func (h *host) ordersTable(user, prefix string) string {
GinkgoHelper()
res := h.database(user, prefix+"-db", "Orders data")
rev := func() map[string]any {
v, e := h.read(user, res, `{"tool":"databases-inspect"}`)
Expect(e).NotTo(HaveOccurred())
return revisionOf(v)
}
step := func(n int, tool, args string) {
GinkgoHelper()
got := h.apply(user, res, fmt.Sprintf("%s-%d", prefix, n), tool, rev(), args)
Expect(got.code).To(Equal(200), got.raw)
}
step(1, "tables-create", `{"table":"Orders"}`)
for i, c := range [][2]string{{"Item", "text"}, {"Note", "text"}, {"Qty", "integer"}, {"Price", "real"}, {"Paid", "boolean"}} {
step(2+i, "columns-add", fmt.Sprintf(`{"table":"Orders","column":%q,"type":%q}`, c[0], c[1]))
}
step(10, "rows-insert", `{"table":"Orders","rows":[
{"Item":"=SUM(A1:A9)","Note":"line one\nline two","Qty":-4,"Price":19.99,"Paid":true},
{"Item":"Café, \"deluxe\"","Note":"日本語","Qty":12},
{"Item":"plain"}]}`)
return res
}
// exported runs the export as the person through the owner's gateway and
// returns the host artifact it was turned into.
func (h *host) exported(user, res, args string) (contract.OutputDescription, error) {
GinkgoHelper()
out, e := h.app.AddonGateway().Dispatch(context.Background(), h.actor(user), contract.Request{
AddonID: "tables", ResourceID: res, OperationID: "exp-" + uuid.NewString()[:12], Generation: 1, Action: "read",
Arguments: json.RawMessage(args),
})
if e != nil {
return contract.OutputDescription{}, e
}
Expect(out.Outputs).To(HaveLen(1))
return out.Outputs[0], nil
}
// http is a real network client of the host's HTTP application.
type httpHost struct{ srv *httptest.Server }
func (h *host) listen() httpHost {
s := httptest.NewServer(h.web)
DeferCleanup(s.Close)
return httpHost{s}
}
func (n httpHost) get(path string, header func(*http.Request)) (*http.Response, []byte) {
GinkgoHelper()
r, e := http.NewRequest("GET", n.srv.URL+path, nil)
Expect(e).NotTo(HaveOccurred())
if header != nil {
header(r)
}
resp, e := http.DefaultClient.Do(r)
Expect(e).NotTo(HaveOccurred())
defer func() { _ = resp.Body.Close() }()
b, _ := io.ReadAll(resp.Body)
return resp, b
}
func (h *host) complete(user, id string) map[string]any {
GinkgoHelper()
var got reply
Eventually(func() string {
got = h.send("GET", "/api/addons/artifacts/"+id, "", h.bearer(user))
s, _ := got.body["state"].(string)
return s
}, 20*time.Second, 50*time.Millisecond).Should(Equal("complete"), got.raw)
return got.body
}
var _ = Describe("CSV exports are private artifacts", func() {
It("exports a table with exact bytes and attachment headers over real HTTP and shows no one else the file", func() {
h := ready(false)
res := h.ordersTable("bob", "ex1")
art, e := h.exported("bob", res, `{"tool":"rows-export","table":"Orders"}`)
Expect(e).NotTo(HaveOccurred())
Expect(art.Name).To(Equal("Orders.csv"))
Expect(art.MediaType).To(Equal("text/csv"))
Expect(art.Size).To(Equal(int64(len(ordersCSV))))
// What the caller holds is a host identifier, never the add-on's handle.
Expect(art.Handle).NotTo(HavePrefix("x0"))
meta := h.complete("bob", art.Handle)
Expect(meta["name"]).To(Equal("Orders.csv"))
Expect(meta["size"]).To(BeEquivalentTo(len(ordersCSV)))
Expect(meta["expiresAt"]).NotTo(BeEmpty())
Expect(meta).NotTo(HaveKey("path"))
Expect(meta).NotTo(HaveKey("handle"))
net := h.listen()
resp, body := net.get("/api/addons/artifacts/"+art.Handle+"/download", func(r *http.Request) { h.bearer("bob")(r) })
Expect(resp.StatusCode).To(Equal(200), string(body))
Expect(string(body)).To(Equal(ordersCSV))
Expect(resp.Header.Get("Content-Type")).To(Equal("text/csv"))
Expect(resp.Header.Get("Content-Disposition")).To(HavePrefix("attachment;"))
Expect(resp.Header.Get("Content-Disposition")).To(ContainSubstring("Orders.csv"))
Expect(resp.Header.Get("X-Content-Type-Options")).To(Equal("nosniff"))
Expect(resp.Header.Get("Cache-Control")).To(ContainSubstring("no-store"))
Expect(resp.ContentLength).To(Equal(int64(len(ordersCSV))))
Expect(strings.HasPrefix(string(body), "\xef\xbb\xbf")).To(BeFalse())
// A person with the feature and no access, and an unknown identifier,
// get the same denial.
_, unknown := net.get("/api/addons/artifacts/a0123456789abcdef0123456789abcde/download", h.bearer("carol"))
resp, stranger := net.get("/api/addons/artifacts/"+art.Handle+"/download", h.bearer("carol"))
Expect(resp.StatusCode).To(Equal(403))
Expect(string(stranger)).To(Equal(string(unknown)))
resp, _ = net.get("/api/addons/artifacts/"+art.Handle, h.bearer("carol"))
Expect(resp.StatusCode).To(Equal(403))
// Without a credential there is nothing.
resp, _ = net.get("/api/addons/artifacts/"+art.Handle+"/download", nil)
Expect(resp.StatusCode).To(BeElementOf(401, 403))
// A person who cannot read the database cannot start the export.
_, e = h.exported("carol", res, `{"tool":"rows-export","table":"Orders"}`)
Expect(e).To(HaveOccurred())
Expect(gateway.StatusFor(e)).To(Equal(403))
})
It("keeps a shared database's file private, applies the person's own filter and sort, and denies after the grant is withdrawn", func() {
h := ready(false)
res := h.ordersTable("bob", "ex2")
Expect(h.grant("bob", res, "carol", "viewer").code).To(Equal(200))
mine, e := h.exported("bob", res, `{"tool":"rows-export","table":"Orders"}`)
Expect(e).NotTo(HaveOccurred())
h.complete("bob", mine.Handle)
net := h.listen()
// Sharing the source does not share the file.
resp, _ := net.get("/api/addons/artifacts/"+mine.Handle+"/download", h.bearer("carol"))
Expect(resp.StatusCode).To(Equal(403))
// A viewer exports what they can read, narrowed by filter, sort and columns.
theirs, e := h.exported("carol", res, `{"tool":"rows-export","table":"Orders","columns":["_id","Item","Qty"],"filter":{"column":"Qty","op":"is-not-null"},"sort":[{"column":"Qty"}]}`)
Expect(e).NotTo(HaveOccurred())
h.complete("carol", theirs.Handle)
resp, body := net.get("/api/addons/artifacts/"+theirs.Handle+"/download", h.bearer("carol"))
Expect(resp.StatusCode).To(Equal(200))
Expect(string(body)).To(MatchRegexp(`^_id,Item,Qty\r\n1,'=SUM\(A1:A9\),-4\r\n2,"Café, ""deluxe""",12\r\n$`))
resp, _ = net.get("/api/addons/artifacts/"+theirs.Handle+"/download", h.bearer("bob"))
Expect(resp.StatusCode).To(Equal(403))
// Withdrawing the grant ends the download of what the viewer exported.
Expect(h.revoke("bob", res, "carol").code).To(Equal(200))
resp, _ = net.get("/api/addons/artifacts/"+theirs.Handle+"/download", h.bearer("carol"))
Expect(resp.StatusCode).To(Equal(403))
resp, _ = net.get("/api/addons/artifacts/"+theirs.Handle, h.bearer("carol"))
Expect(resp.StatusCode).To(Equal(200), "the transfer record stays readable to its principal")
// The owner still downloads their own.
resp, body = net.get("/api/addons/artifacts/"+mine.Handle+"/download", h.bearer("bob"))
Expect(resp.StatusCode).To(Equal(200))
Expect(string(body)).To(Equal(ordersCSV))
})
It("serves the same bytes and headers through a forwarding frontend, and fails the same way for a stranger", func() {
h := ready(false)
res := h.ordersTable("bob", "ex3")
art, e := h.exported("bob", res, `{"tool":"rows-export","table":"Orders"}`)
Expect(e).NotTo(HaveOccurred())
h.complete("bob", art.Handle)
front := h.frontend()
path := "/api/addons/artifacts/" + art.Handle + "/download"
get := func(web http.Handler, user string) *httptest.ResponseRecorder {
r := httptest.NewRequest("GET", path, nil)
h.bearer(user)(r)
w := httptest.NewRecorder()
web.ServeHTTP(w, r)
return w
}
own, fwd := get(h.web, "bob"), get(front, "bob")
Expect(own.Code).To(Equal(200))
Expect(fwd.Code).To(Equal(200))
Expect(fwd.Body.String()).To(Equal(own.Body.String()))
Expect(fwd.Body.String()).To(Equal(ordersCSV))
for _, k := range []string{"Content-Type", "Content-Disposition", "X-Content-Type-Options", "Cache-Control", "Content-Length"} {
Expect(fwd.Header().Get(k)).To(Equal(own.Header().Get(k)), k)
}
ownDenied, fwdDenied := get(h.web, "carol"), get(front, "carol")
Expect(ownDenied.Code).To(Equal(403))
Expect(fwdDenied.Code).To(Equal(403))
Expect(fwdDenied.Body.String()).To(Equal(ownDenied.Body.String()))
// The export itself is requested through the frontend, too.
body := fmt.Sprintf(`{"addonId":"tables","resourceId":%q,"operationId":"via-front-1","generation":1,"action":"read","arguments":{"tool":"rows-export","table":"Orders"}}`, res)
got := serve(front, "POST", "/api/addons/dispatch", body, h.bearer("bob"))
Expect(got.code).To(Equal(200), got.raw)
outs, _ := got.body["outputs"].([]any)
Expect(outs).To(HaveLen(1))
id := outs[0].(map[string]any)["handle"].(string)
h.complete("bob", id)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/api/addons/artifacts/"+id+"/download", nil)
h.bearer("bob")(r)
front.ServeHTTP(w, r)
Expect(w.Code).To(Equal(200))
Expect(w.Body.String()).To(Equal(ordersCSV))
})
It("answers a result past the export limit as data with no file", func() {
h := ready(false)
res := h.ordersTable("bob", "ex4")
out, e := h.app.AddonGateway().Dispatch(context.Background(), h.actor("bob"), contract.Request{
AddonID: "tables", ResourceID: res, OperationID: "exp-big", Generation: 1, Action: "read",
Arguments: json.RawMessage(`{"tool":"rows-export","table":"Orders","maxRows":5000000}`),
})
Expect(e).NotTo(HaveOccurred())
Expect(out.Outputs).To(BeEmpty())
var p map[string]any
Expect(json.Unmarshal(out.Payload, &p)).To(Succeed())
Expect(p["exported"]).To(Equal(false))
Expect(p["error"]).To(Equal("too-large"))
// An unknown table and a mutation argument are plain refusals.
for _, a := range []string{`{"tool":"rows-export","table":"Nope"}`, `{"tool":"rows-export","table":"Orders","rows":[{}]}`, `{"tool":"rows-export","table":"Orders","bogus":1}`} {
_, e = h.read("bob", res, a)
Expect(e).To(HaveOccurred(), a)
}
})
})
// store opens an artifact store over the owner's own policy and runtime, with
// a clock and limits the spec controls.
func (h *host) store(clock *time.Time, limits artifacts.Limits) (*artifacts.Artifacts, string) {
GinkgoHelper()
dir := filepath.Join(h.dir, "artifacts-"+uuid.NewString()[:8])
a, e := artifacts.Open(artifacts.Options{
Dir: dir, Policy: h.app.AddonPolicy(), Runtime: h.app.AddonManager(),
Addon: func(context.Context, string) (artifacts.AddonState, error) {
return artifacts.AddonState{Installed: true, Enabled: true, Generation: 1}, nil
},
Limits: limits, Now: func() time.Time { return *clock },
})
Expect(e).NotTo(HaveOccurred())
DeferCleanup(func() { _ = a.Close() })
return a, dir
}
// stage asks the real Tables executable for an export and returns the output
// description and the request that binds its handle.
func (h *host) stage(user, res, op, args string) (contract.OutputDescription, contract.Request) {
GinkgoHelper()
inst, e := h.app.AddonManager().Runtime(context.Background(), "tables")
Expect(e).NotTo(HaveOccurred())
req := contract.Request{AddonID: "tables", ResourceID: res, OperationID: op, Generation: inst.Generation, Action: "read", Arguments: json.RawMessage(args)}
out, e := inst.Client.Read(context.Background(), contract.Envelope{Request: req, Authority: contract.Authority{Actor: h.actor(user), Scope: "read", ExpiresAt: time.Now().Add(time.Minute)}})
Expect(e).NotTo(HaveOccurred())
Expect(out.Outputs).To(HaveLen(1))
return out.Outputs[0], req
}
func (h *host) staged() []string {
entries, _ := os.ReadDir(filepath.Join(h.owner.Root, "data", "tables", "exports"))
var out []string
for _, e := range entries {
out = append(out, filepath.Join(h.owner.Root, "data", "tables", "exports", e.Name()))
}
return out
}
var _ = Describe("CSV export failures never leave a downloadable file", func() {
It("binds an output handle to its operation, person and database", func() {
h := ready(false)
res := h.ordersTable("bob", "ox1")
other := h.ordersTable("carol", "ox1c")
out, req := h.stage("bob", res, "ox-op-1", `{"tool":"rows-export","table":"Orders"}`)
inst, e := h.app.AddonManager().Runtime(context.Background(), "tables")
Expect(e).NotTo(HaveOccurred())
open := func(user string, r contract.Request) error {
rc, e := inst.Client.OpenOutput(context.Background(), contract.Envelope{Request: r, Authority: contract.Authority{Actor: h.actor(user), Scope: "output", ExpiresAt: time.Now().Add(time.Minute)}}, out.Handle)
if e == nil {
_ = rc.Close()
}
return e
}
Expect(open("carol", req)).To(HaveOccurred(), "another person")
wrong := req
wrong.OperationID = "ox-op-2"
Expect(open("bob", wrong)).To(HaveOccurred(), "another operation")
wrong = req
wrong.ResourceID = other
Expect(open("bob", wrong)).To(HaveOccurred(), "another database")
Expect(h.staged()).To(HaveLen(1), "denied opens leave the file for its owner")
rc, e := inst.Client.OpenOutput(context.Background(), contract.Envelope{Request: req, Authority: contract.Authority{Actor: h.actor("bob"), Scope: "output", ExpiresAt: time.Now().Add(time.Minute)}}, out.Handle)
Expect(e).NotTo(HaveOccurred())
b, e := io.ReadAll(rc)
Expect(e).NotTo(HaveOccurred())
Expect(rc.Close()).To(Succeed())
Expect(string(b)).To(Equal(ordersCSV))
Expect(h.staged()).To(BeEmpty(), "a served export leaves nothing staged")
})
It("fails a truncated, grown or vanished stream and never serves it", func() {
h := ready(false)
res := h.ordersTable("bob", "ox2")
clock := time.Now()
files, _ := h.store(&clock, artifacts.DefaultLimits())
bob := h.actor("bob")
for name, damage := range map[string]func(path string){
"truncated": func(p string) { Expect(os.Truncate(p, 10)).To(Succeed()) },
"grown": func(p string) {
f, e := os.OpenFile(p, os.O_APPEND|os.O_WRONLY, 0)
Expect(e).NotTo(HaveOccurred())
_, _ = f.WriteString("extra,row\r\n")
_ = f.Close()
},
"vanished": func(p string) { Expect(os.Remove(p)).To(Succeed()) },
} {
out, req := h.stage("bob", res, "ox-"+name, `{"tool":"rows-export","table":"Orders"}`)
staged := h.staged()
Expect(staged).To(HaveLen(1), name)
damage(staged[0])
art, e := files.Ingest(context.Background(), bob, req, out)
Expect(e).To(HaveOccurred(), name)
Expect(art.ID).To(BeEmpty(), name)
// Nothing downloadable exists for this principal under any name.
var sink bytes.Buffer
_, e = files.Download(context.Background(), bob, "a"+strings.Repeat("0", 32), &sink)
Expect(e).To(HaveOccurred())
Expect(sink.Len()).To(BeZero())
for _, p := range h.staged() {
_ = os.Remove(p)
}
}
// A healthy export through the same store still works afterwards.
out, req := h.stage("bob", res, "ox-good", `{"tool":"rows-export","table":"Orders"}`)
art, e := files.Ingest(context.Background(), bob, req, out)
Expect(e).NotTo(HaveOccurred())
var sink bytes.Buffer
_, e = files.Download(context.Background(), bob, art.ID, &sink)
Expect(e).NotTo(HaveOccurred())
Expect(sink.String()).To(Equal(ordersCSV))
})
It("fails the transfer when the store cannot write, and when the source is revoked before completion", func() {
h := ready(false)
res := h.ordersTable("bob", "ox3")
Expect(h.grant("bob", res, "carol", "viewer").code).To(Equal(200))
clock := time.Now()
files, root := h.store(&clock, artifacts.DefaultLimits())
// A full or read-only disk: every directory of the store refuses a write.
var dirs []string
_ = filepath.WalkDir(root, func(p string, d fs.DirEntry, e error) error {
if e == nil && d.IsDir() {
dirs = append(dirs, p)
}
return nil
})
for _, d := range dirs {
Expect(os.Chmod(d, 0o500)).To(Succeed())
}
DeferCleanup(func() {
for _, d := range dirs {
_ = os.Chmod(d, 0o700)
}
})
if os.Geteuid() != 0 {
out, req := h.stage("bob", res, "ox3-disk", `{"tool":"rows-export","table":"Orders"}`)
_, e := files.Ingest(context.Background(), h.actor("bob"), req, out)
Expect(e).To(HaveOccurred())
}
for _, d := range dirs {
Expect(os.Chmod(d, 0o700)).To(Succeed())
}
for _, p := range h.staged() {
_ = os.Remove(p)
}
// Revoked before the transfer starts: nothing is staged for the person.
out, req := h.stage("carol", res, "ox3-revoked", `{"tool":"rows-export","table":"Orders"}`)
Expect(h.revoke("bob", res, "carol").code).To(Equal(200))
_, e := files.Ingest(context.Background(), h.actor("carol"), req, out)
Expect(e).To(HaveOccurred())
Expect(gateway.StatusFor(e)).To(Equal(403))
})
It("expires the file only, enforces the quota per person and leaves the database as it was", func() {
h := ready(false)
res := h.ordersTable("bob", "ox4")
clock := time.Now()
limits := artifacts.DefaultLimits()
limits.UserCount, limits.TotalCount = 2, 4
files, _ := h.store(&clock, limits)
bob := h.actor("bob")
ingest := func(op string) (artifacts.Artifact, error) {
out, req := h.stage("bob", res, op, `{"tool":"rows-export","table":"Orders"}`)
return files.Ingest(context.Background(), bob, req, out)
}
first, e := ingest("ox4-1")
Expect(e).NotTo(HaveOccurred())
_, e = ingest("ox4-2")
Expect(e).NotTo(HaveOccurred())
_, e = ingest("ox4-3")
Expect(e).To(HaveOccurred())
Expect(gateway.StatusFor(e)).To(Equal(403))
for _, p := range h.staged() {
_ = os.Remove(p)
}
before, e := h.read("bob", res, `{"tool":"rows-query","table":"Orders"}`)
Expect(e).NotTo(HaveOccurred())
clock = clock.Add(limits.Expiry + time.Minute)
var sink bytes.Buffer
_, e = files.Download(context.Background(), bob, first.ID, &sink)
Expect(e).To(HaveOccurred())
Expect(gateway.StatusFor(e)).To(Equal(403))
Expect(sink.Len()).To(BeZero())
_, e = files.Sweep(context.Background())
Expect(e).NotTo(HaveOccurred())
_, e = files.Status(context.Background(), bob, first.ID)
Expect(e).To(HaveOccurred())
// The database is untouched and exports again; the quota is free.
after, e := h.read("bob", res, `{"tool":"rows-query","table":"Orders"}`)
Expect(e).NotTo(HaveOccurred())
Expect(after["rows"]).To(Equal(before["rows"]))
Expect(after["revision"]).To(Equal(before["revision"]))
again, e := ingest("ox4-4")
Expect(e).NotTo(HaveOccurred())
sink.Reset()
_, e = files.Download(context.Background(), bob, again.ID, &sink)
Expect(e).NotTo(HaveOccurred())
Expect(sink.String()).To(Equal(ordersCSV))
})
})
+485
View File
@@ -0,0 +1,485 @@
// SPDX-License-Identifier: MIT
package tablesadmin_test
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"fmt"
"io/fs"
"math/big"
"net"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
goruntime "runtime"
"testing"
"time"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/config"
httpapi "github.com/mudler/LocalAI/core/http"
"github.com/mudler/LocalAI/core/http/auth"
"github.com/mudler/LocalAI/core/http/routes"
addonsvc "github.com/mudler/LocalAI/core/services/addons"
"github.com/mudler/LocalAI/core/services/addons/catalog"
"github.com/mudler/LocalAI/core/services/addons/gateway"
"github.com/mudler/LocalAI/core/services/addons/policy"
"github.com/mudler/LocalAI/pkg/addons/contract"
"github.com/mudler/LocalAI/pkg/system"
"github.com/mudler/LocalAI/tests/addons/foundation/cluster"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestTablesAdmin(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Tables resource management")
}
var binary string
var _ = BeforeSuite(func() {
binary = filepath.Join(GinkgoT().TempDir(), "tables")
cmd := exec.Command("go", "build", "-o", binary, "../../../addons/tables/cmd/tables")
cmd.Env = append(os.Environ(), "CGO_ENABLED=1")
out, e := cmd.CombinedOutput()
Expect(e).NotTo(HaveOccurred(), string(out))
})
const appSecret = "tables-admin-secret"
// transports builds the transport identities of one owner and one forwarding
// frontend: a CA, two pinned leaves and two signing keys.
func transports(port int) (owner, front *gateway.CarrierConfig) {
GinkgoHelper()
pub, key, e := ed25519.GenerateKey(rand.Reader)
Expect(e).NotTo(HaveOccurred())
ca := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test CA"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(24 * time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign}
der, e := x509.CreateCertificate(rand.Reader, ca, ca, pub, key)
Expect(e).NotTo(HaveOccurred())
ca, e = x509.ParseCertificate(der)
Expect(e).NotTo(HaveOccurred())
pool := x509.NewCertPool()
pool.AddCert(ca)
pins := map[string][32]byte{}
verify := map[string]ed25519.PublicKey{}
pairs := map[string]tls.Certificate{}
signing := map[string]ed25519.PrivateKey{}
for i, id := range []string{"owner", "front-a"} {
lp, lk, e := ed25519.GenerateKey(rand.Reader)
Expect(e).NotTo(HaveOccurred())
leaf := &x509.Certificate{SerialNumber: big.NewInt(int64(i + 2)), Subject: pkix.Name{CommonName: id}, NotBefore: ca.NotBefore, NotAfter: ca.NotAfter, IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}}
d, e := x509.CreateCertificate(rand.Reader, leaf, ca, lp, key)
Expect(e).NotTo(HaveOccurred())
kd, e := x509.MarshalPKCS8PrivateKey(lk)
Expect(e).NotTo(HaveOccurred())
pair, e := tls.X509KeyPair(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: d}), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: kd}))
Expect(e).NotTo(HaveOccurred())
sp, sk, e := ed25519.GenerateKey(rand.Reader)
Expect(e).NotTo(HaveOccurred())
pins[id], verify[id], pairs[id], signing[id] = sha256.Sum256(d), sp, pair, sk
}
address := fmt.Sprintf("https://127.0.0.1:%d", port)
mk := func(id string) *gateway.CarrierConfig {
return &gateway.CarrierConfig{InstanceID: id, OwnerID: "owner", OwnerAddress: address, Certificate: pairs[id], Roots: pool, Pins: pins, SigningKey: signing[id], VerificationKeys: verify}
}
return mk("owner"), mk("front-a")
}
func freePort() int {
GinkgoHelper()
l, e := net.Listen("tcp", "127.0.0.1:0")
Expect(e).NotTo(HaveOccurred())
defer func() { _ = l.Close() }()
return l.Addr().(*net.TCPAddr).Port
}
// person is an account of the fixture.
type person struct {
id, role string
feature bool
status string
}
var people = []person{
{"alice", auth.RoleAdmin, false, auth.StatusActive},
{"bob", auth.RoleUser, true, auth.StatusActive},
{"carol", auth.RoleUser, true, auth.StatusActive},
{"dave", auth.RoleUser, true, auth.StatusActive},
{"erin", auth.RoleUser, false, auth.StatusActive},
{"frank", auth.RoleUser, true, auth.StatusPending},
}
// host is one owner in process: the real application bootstrap over real
// account storage, the real supervisor and the real Tables executable.
type host struct {
dir string
owner config.AddonConfig
front *gateway.CarrierConfig
app *application.Application
web *echo.Echo
cancel context.CancelFunc
local bool
tokens map[string]string
sessions map[string]string
keys map[string]string
}
func newHost(local bool) *host {
GinkgoHelper()
dir := GinkgoT().TempDir()
h := &host{dir: dir, local: local, tokens: map[string]string{}, sessions: map[string]string{}, keys: map[string]string{}}
DeferCleanup(func() {
_ = filepath.WalkDir(dir, func(p string, d fs.DirEntry, e error) error {
if e == nil && d.IsDir() {
_ = os.Chmod(p, 0o700)
}
return nil
})
})
port := freePort()
var owner *gateway.CarrierConfig
owner, h.front = transports(port)
h.owner = config.AddonConfig{
Enabled: true, InstanceID: "owner", OwnerID: "owner", OwnerAddress: owner.OwnerAddress, StorageID: "local",
Root: filepath.Join(dir, "addons"), ListenAddress: fmt.Sprintf("127.0.0.1:%d", port), Transport: owner,
Host: contract.HostCompatibility{Host: 1, API: 1, Protocol: 1, SDK: 1, DataFormat: 1, OS: goruntime.GOOS, Arch: goruntime.GOARCH},
}
if local {
h.owner.OwnershipMode = auth.AddonModeLocalOwner
}
return h
}
func (h *host) mode() string {
if h.local {
return auth.AddonModeLocalOwner
}
return auth.AddonModeNamedUser
}
func (h *host) start() {
GinkgoHelper()
models, backends := filepath.Join(h.dir, "models"), filepath.Join(h.dir, "backends")
Expect(os.MkdirAll(models, 0o750)).To(Succeed())
Expect(os.MkdirAll(backends, 0o750)).To(Succeed())
state, e := system.GetSystemState(system.WithBackendPath(backends), system.WithModelPath(models))
Expect(e).NotTo(HaveOccurred())
ctx, cancel := context.WithCancel(context.Background())
h.cancel = cancel
opts := []config.AppOption{config.WithContext(ctx), config.WithSystemState(state), config.WithDataPath(h.dir), config.WithAddons(h.owner)}
if !h.local {
opts = append(opts, config.WithAuthEnabled(true), config.WithAuthDatabaseURL(filepath.Join(h.dir, "auth.db")), config.WithAuthAPIKeyHMACSecret(appSecret))
}
app, e := application.New(opts...)
Expect(e).NotTo(HaveOccurred())
h.app = app
h.web, e = httpapi.API(app)
Expect(e).NotTo(HaveOccurred())
}
// stop ends the application as a restart does and waits until the owner
// released its durable state.
func (h *host) stop() {
GinkgoHelper()
if h.cancel == nil {
return
}
Expect(h.app.Shutdown()).To(Succeed())
h.cancel()
h.cancel = nil
mode := policy.ModeNamedUser
if h.local {
mode = policy.ModeLocalOwner
}
Eventually(func() error {
s, e := policy.OpenStore(filepath.Join(h.owner.Root, "policy"), mode)
if e == nil {
_ = s.Close()
}
return e
}, 10*time.Second, 50*time.Millisecond).Should(Succeed())
}
func (h *host) actor(user string) contract.Actor {
if h.local {
return contract.Actor{UserID: auth.AddonLocalOwnerID, CredentialID: auth.AddonLocalOwnerID, Mode: auth.AddonModeLocalOwner}
}
return contract.Actor{UserID: user, CredentialID: "key:" + h.keys[user], Mode: auth.AddonModeNamedUser}
}
// seed creates the accounts, installs the real Tables package from a local
// catalog and waits until it runs.
func (h *host) seed(lib *cluster.Library) {
GinkgoHelper()
ctx := context.Background()
if !h.local {
db := h.app.AuthDB()
Expect(db).NotTo(BeNil())
for _, u := range people {
Expect(db.Create(&auth.User{ID: u.id, Email: u.id + "@tables.test", Name: "Name of " + u.id, Provider: auth.ProviderLocal, Role: u.role, Status: u.status}).Error).To(Succeed())
if u.feature {
Expect(db.Create(&auth.UserPermission{ID: uuid.NewString(), UserID: u.id, Permissions: auth.PermissionMap{auth.FeatureAddons: true}}).Error).To(Succeed())
}
token, key, e := auth.CreateAPIKey(db, u.id, "agent", u.role, appSecret, nil)
Expect(e).NotTo(HaveOccurred())
session, e := auth.CreateSession(db, u.id, appSecret)
Expect(e).NotTo(HaveOccurred())
h.tokens[u.id], h.sessions[u.id], h.keys[u.id] = token, session, key.ID
}
}
admin := h.actor("alice")
Expect(h.app.AddonSources().Save(ctx, admin, catalog.Source{ID: "local", Name: "Local", URL: lib.URL(), Enabled: true, AllowLoopbackHTTP: true})).To(Succeed())
_, e := h.app.AddonSources().Refresh(ctx, "local")
Expect(e).NotTo(HaveOccurred())
m := h.app.AddonManager()
_, e = m.Submit(ctx, admin, addonsvc.LifecycleCommand{OperationID: "install-tables", AddonID: "tables", Action: "install", SourceID: "local", Version: "0.1.0"})
Expect(e).NotTo(HaveOccurred())
_, e = m.Submit(ctx, admin, addonsvc.LifecycleCommand{OperationID: "enable-tables", AddonID: "tables", Action: "enable", Generation: 1})
Expect(e).NotTo(HaveOccurred())
h.running()
}
func (h *host) running() {
GinkgoHelper()
Eventually(func() string {
list, e := h.app.AddonManager().Apps(context.Background(), h.actor("alice"))
if e != nil {
return e.Error()
}
for _, a := range list {
if a.ID == "tables" {
return a.State
}
}
return "absent"
}, 30*time.Second, 100*time.Millisecond).Should(Equal("running"))
}
func (h *host) library() *cluster.Library {
GinkgoHelper()
lib, e := cluster.NewLibrary(binary)
Expect(e).NotTo(HaveOccurred())
DeferCleanup(lib.Close)
raw, e := os.ReadFile("../../../addons/tables/manifest.json")
Expect(e).NotTo(HaveOccurred())
var m struct {
Capabilities map[string]any `json:"capabilities"`
}
Expect(json.Unmarshal(raw, &m)).To(Succeed())
// These specs run the tools, not the page: the package carries no UI
// files, so the declaration of them is left out. The UI suite installs
// the whole package.
delete(m.Capabilities, "ui")
exe, e := os.ReadFile(binary)
Expect(e).NotTo(HaveOccurred())
Expect(lib.Add(cluster.Package{ID: "tables", Version: "0.1.0", Capabilities: m.Capabilities, Executable: "bin/tables", Binary: exe}, GinkgoT().TempDir())).To(Succeed())
return lib
}
// ready is the usual start of a spec: a running host with the accounts and
// the package.
func ready(local bool) *host {
GinkgoHelper()
h := newHost(local)
lib := h.library()
h.start()
DeferCleanup(h.stop)
h.seed(lib)
return h
}
// reply is one HTTP answer.
type reply struct {
code int
body map[string]any
raw string
}
func (r reply) errType() string {
e, _ := r.body["error"].(map[string]any)
s, _ := e["type"].(string)
return s
}
func (r reply) errOperation() string {
e, _ := r.body["error"].(map[string]any)
s, _ := e["operationId"].(string)
return s
}
func serve(e *echo.Echo, method, path, body string, headers ...func(*http.Request)) reply {
GinkgoHelper()
r := httptest.NewRequest(method, path, bytes.NewBufferString(body))
r.Header.Set("Content-Type", "application/json")
for _, h := range headers {
h(r)
}
w := httptest.NewRecorder()
e.ServeHTTP(w, r)
out := reply{code: w.Code, body: map[string]any{}, raw: w.Body.String()}
_ = json.Unmarshal(w.Body.Bytes(), &out.body)
return out
}
func (h *host) send(method, path, body string, headers ...func(*http.Request)) reply {
GinkgoHelper()
return serve(h.web, method, path, body, headers...)
}
func (h *host) bearer(user string) func(*http.Request) {
return func(r *http.Request) { r.Header.Set("Authorization", "Bearer "+h.tokens[user]) }
}
// tab is what the host's own pages send: the user's browser session, a
// same-origin fetch and the identifier the action concerns in the header. In
// local-owner mode there is no session.
func (h *host) tab(user, id string) func(*http.Request) {
return func(r *http.Request) {
if !h.local {
r.AddCookie(&http.Cookie{Name: "session", Value: h.sessions[user]})
}
r.Header.Set("Sec-Fetch-Site", "same-origin")
r.Header.Set("Sec-Fetch-Mode", "cors")
r.Header.Set("Sec-Fetch-Dest", "empty")
r.Header.Set("X-LocalAI-Confirm", id)
}
}
// session is a plain same-origin session request with no confirmation header.
func (h *host) session(user string) func(*http.Request) {
return func(r *http.Request) {
r.AddCookie(&http.Cookie{Name: "session", Value: h.sessions[user]})
r.Header.Set("Sec-Fetch-Site", "same-origin")
r.Header.Set("Sec-Fetch-Mode", "cors")
r.Header.Set("Sec-Fetch-Dest", "empty")
}
}
// frontend is a second LocalAI instance that holds no addon state and
// forwards every resource call to the owner over the real carrier.
func (h *host) frontend() *echo.Echo {
GinkgoHelper()
carrier, e := gateway.NewCarrier(*h.front)
Expect(e).NotTo(HaveOccurred())
var db = h.app.AuthDB()
g := &gateway.Gateway{Forward: carrier, Revalidate: auth.AddonRevalidate(db, h.mode())}
web := echo.New()
web.Use(auth.Middleware(db, h.app.ApplicationConfig()))
routes.RegisterAddonRoutesFor(web, g, db, h.mode())
return web
}
// Typed calls of the management routes, as the person's own browser tab.
func (h *host) create(user, op, name string) reply {
GinkgoHelper()
body := fmt.Sprintf(`{"addonId":"tables","operationId":%q,"generation":1,"arguments":{"name":%q}}`, op, name)
return h.send("POST", "/api/addons/resources", body, h.tab(user, op))
}
func (h *host) list(user string) reply {
GinkgoHelper()
return h.send("GET", "/api/addons/resources?addonId=tables", "", h.bearer(user))
}
// resources returns the listed resources by ID.
func resourcesOf(r reply) map[string]map[string]any {
GinkgoHelper()
out := map[string]map[string]any{}
list, _ := r.body["resources"].([]any)
for _, x := range list {
m := x.(map[string]any)
out[m["id"].(string)] = m
}
return out
}
func (h *host) grant(user, res, target, role string) reply {
GinkgoHelper()
return h.send("PUT", "/api/addons/resources/"+res+"/grants/"+target, fmt.Sprintf(`{"role":%q}`, role), h.tab(user, res))
}
func (h *host) revoke(user, res, target string) reply {
GinkgoHelper()
return h.send("DELETE", "/api/addons/resources/"+res+"/grants/"+target, "", h.tab(user, res))
}
func (h *host) grants(user, res string) reply {
GinkgoHelper()
return h.send("GET", "/api/addons/resources/"+res+"/grants", "", h.tab(user, res))
}
func (h *host) users(user, res, q string) reply {
GinkgoHelper()
return h.send("GET", "/api/addons/resources/"+res+"/users?q="+q, "", h.tab(user, res))
}
func (h *host) rename(user, res, op, name string, rev map[string]any) reply {
GinkgoHelper()
b, _ := json.Marshal(map[string]any{"operationId": op, "generation": 1, "revision": rev, "name": name})
return h.send("POST", "/api/addons/resources/"+res+"/rename", string(b), h.tab(user, res))
}
func (h *host) remove(user, res, op, confirm string, rev map[string]any) reply {
GinkgoHelper()
b, _ := json.Marshal(map[string]any{"operationId": op, "generation": 1, "revision": rev, "confirm": confirm})
return h.send("POST", "/api/addons/resources/"+res+"/delete", string(b), h.tab(user, res))
}
// revision reads the revision the owner role is shown for a resource.
func (h *host) revision(user, res string) map[string]any {
GinkgoHelper()
got := resourcesOf(h.list(user))[res]
Expect(got).NotTo(BeNil())
rev, _ := got["revision"].(map[string]any)
Expect(rev).NotTo(BeNil())
return rev
}
// name reads the display name of a resource through the owner's gateway, as
// the person who may read it.
func (h *host) name(user, res string) (string, error) {
out, e := h.app.AddonGateway().Dispatch(context.Background(), h.actor(user), contract.Request{
AddonID: "tables", ResourceID: res, OperationID: "inspect-" + uuid.NewString()[:8], Generation: 1, Action: "read",
Arguments: json.RawMessage(`{"tool":"databases-inspect"}`),
})
if e != nil {
return "", e
}
var v struct {
Name string `json:"name"`
}
Expect(json.Unmarshal(out.Payload, &v)).To(Succeed())
return v.Name, nil
}
// database creates a database for the user and returns its ID.
func (h *host) database(user, op, name string) string {
GinkgoHelper()
got := h.create(user, op, name)
Expect(got.code).To(Equal(200), got.raw)
id, _ := got.body["id"].(string)
Expect(id).NotTo(BeEmpty())
return id
}
func (h *host) key(res string) policy.ResourceKey {
return policy.ResourceKey{AddonID: "tables", ResourceID: res}
}
func (h *host) dbFile(res string) string {
return filepath.Join(h.owner.Root, "data", "tables", "tables", "databases", res+".db")
}
+161
View File
@@ -0,0 +1,161 @@
// SPDX-License-Identifier: MIT
package tablesadmin_test
import (
"fmt"
"net/http"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/http/auth"
"github.com/mudler/LocalAI/core/http/routes"
"github.com/mudler/LocalAI/core/services/addons/gateway"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// deadFrontend is a frontend whose owner address is a port nobody listens on.
func (h *host) deadFrontend() *echo.Echo {
GinkgoHelper()
cfg := *h.front
cfg.OwnerAddress = fmt.Sprintf("https://127.0.0.1:%d", freePort())
carrier, e := gateway.NewCarrier(cfg)
Expect(e).NotTo(HaveOccurred())
db := h.app.AuthDB()
g := &gateway.Gateway{Forward: carrier, Revalidate: auth.AddonRevalidate(db, h.mode())}
web := echo.New()
web.Use(auth.Middleware(db, h.app.ApplicationConfig()))
routes.RegisterAddonRoutesFor(web, g, db, h.mode())
return web
}
var _ = Describe("replicas: a forwarding frontend gives the owner's own answers", func() {
It("answers every typed denial exactly as the owner does and carries every mutation to the owner", func() {
h := ready(false)
front := h.frontend()
res := h.database("bob", "f-1", "Replica")
Expect(h.grant("bob", res, "carol", "viewer").code).To(Equal(200))
rev := h.revision("bob", res)
type call struct {
name, method, path, body string
header func(*http.Request)
}
tab := func(user, id string) func(*http.Request) { return h.tab(user, id) }
calls := []call{
{"list", "GET", "/api/addons/resources?addonId=tables", "", h.bearer("bob")},
{"list of a viewer", "GET", "/api/addons/resources?addonId=tables", "", h.bearer("carol")},
{"list of an administrator", "GET", "/api/addons/resources?addonId=tables", "", h.bearer("alice")},
{"malformed add-on", "GET", "/api/addons/resources?addonId=Bad%20ID", "", h.bearer("bob")},
{"grants", "GET", "/api/addons/resources/" + res + "/grants", "", tab("bob", res)},
{"grants of a viewer", "GET", "/api/addons/resources/" + res + "/grants", "", tab("carol", res)},
{"users", "GET", "/api/addons/resources/" + res + "/users?q=Name%20of%20d", "", tab("bob", res)},
{"short query", "GET", "/api/addons/resources/" + res + "/users?q=d", "", tab("bob", res)},
{"owner role grant", "PUT", "/api/addons/resources/" + res + "/grants/dave", `{"role":"owner"}`, tab("bob", res)},
{"grant by a viewer", "PUT", "/api/addons/resources/" + res + "/grants/dave", `{"role":"viewer"}`, tab("carol", res)},
{"grant to a pending account", "PUT", "/api/addons/resources/" + res + "/grants/frank", `{"role":"viewer"}`, tab("bob", res)},
{"rename stale", "POST", "/api/addons/resources/" + res + "/rename", `{"operationId":"pr-1","generation":1,"revision":{"schema":77,"data":0},"name":"X"}`, tab("bob", res)},
{"rename stale generation", "POST", "/api/addons/resources/" + res + "/rename", fmt.Sprintf(`{"operationId":"pr-2","generation":5,"revision":{"schema":%v,"data":%v},"name":"X"}`, rev["schema"], rev["data"]), tab("bob", res)},
{"rename by a key", "POST", "/api/addons/resources/" + res + "/rename", `{"operationId":"pr-3","generation":1,"revision":{"schema":1,"data":0},"name":"X"}`, h.bearer("bob")},
{"delete wrong confirmation", "POST", "/api/addons/resources/" + res + "/delete", fmt.Sprintf(`{"operationId":"pd-1","generation":1,"revision":{"schema":%v,"data":%v},"confirm":"nope"}`, rev["schema"], rev["data"]), tab("bob", res)},
{"delete by a viewer", "POST", "/api/addons/resources/" + res + "/delete", `{"operationId":"pd-2","generation":1,"revision":{"schema":1,"data":0},"confirm":"Replica"}`, tab("carol", res)},
{"status of a viewer", "GET", "/api/addons/resources/" + res + "/operations/pr-1?kind=rename", "", tab("carol", res)},
{"selection none", "GET", "/api/addons/conversations/chat-1/selection/tables", "", h.bearer("bob")},
{"selection of an unknown database", "PUT", "/api/addons/conversations/chat-1/selection/tables", `{"resourceId":"r0123456789abcdef0123456789abcde"}`, h.bearer("bob")},
{"selection malformed", "PUT", "/api/addons/conversations/bad%20conv/selection/tables", `{"resourceId":"x"}`, h.bearer("bob")},
}
for _, c := range calls {
own := serve(h.web, c.method, c.path, c.body, c.header)
fwd := serve(front, c.method, c.path, c.body, c.header)
Expect(fwd.code).To(Equal(own.code), c.name+" owner "+own.raw+" frontend "+fwd.raw)
Expect(fwd.errType()).To(Equal(own.errType()), c.name)
Expect(fwd.body).To(Equal(own.body), c.name)
}
// Forged forwarding headers change nothing on either.
forged := func(r *http.Request) {
h.bearer("carol")(r)
r.Header.Set("X-Forwarded-Actor", "bob")
r.Header.Set("X-Addon-Actor", "bob")
}
got := serve(front, "GET", "/api/addons/resources?addonId=tables", "", forged)
Expect(got.code).To(Equal(200))
Expect(resourcesOf(got)[res]["role"]).To(Equal("viewer"))
// Mutations made through the frontend are the owner's.
made := serve(front, "POST", "/api/addons/resources", `{"addonId":"tables","operationId":"fr-1","generation":1,"arguments":{"name":"Via frontend"}}`, h.tab("dave", "fr-1"))
Expect(made.code).To(Equal(200), made.raw)
id := made.body["id"].(string)
Expect(resourcesOf(h.list("dave"))).To(HaveKey(id))
owned := h.revision("dave", id)
b := fmt.Sprintf(`{"operationId":"fr-2","generation":1,"revision":{"schema":%v,"data":%v},"name":"Via frontend 2"}`, owned["schema"], owned["data"])
Expect(serve(front, "POST", "/api/addons/resources/"+id+"/rename", b, h.tab("dave", id)).code).To(Equal(200))
name, e := h.name("dave", id)
Expect(e).NotTo(HaveOccurred())
Expect(name).To(Equal("Via frontend 2"))
Expect(serve(front, "PUT", "/api/addons/resources/"+id+"/grants/bob", `{"role":"editor"}`, h.tab("dave", id)).code).To(Equal(200))
Expect(resourcesOf(h.list("bob"))[id]["role"]).To(Equal("editor"))
Expect(serve(front, "DELETE", "/api/addons/resources/"+id+"/grants/bob", "", h.tab("dave", id)).code).To(Equal(200))
Expect(resourcesOf(h.list("bob"))).NotTo(HaveKey(id))
sel := serve(front, "PUT", "/api/addons/conversations/fr-chat/selection/tables", fmt.Sprintf(`{"resourceId":%q}`, id), h.bearer("dave"))
Expect(sel.code).To(Equal(200), sel.raw)
Expect(h.selection("dave", "fr-chat").body["resourceId"]).To(Equal(id))
d := fmt.Sprintf(`{"operationId":"fr-3","generation":1,"revision":{"schema":%v,"data":%v},"confirm":"Via frontend 2"}`, owned["schema"], owned["data"])
del := serve(front, "POST", "/api/addons/resources/"+id+"/delete", d, h.tab("dave", id))
Expect(del.code).To(Equal(200), del.raw)
Expect(resourcesOf(h.list("dave"))).NotTo(HaveKey(id))
})
It("reports an unknown outcome with the original operation ID and leaves reads plainly unavailable when the owner cannot be reached", func() {
h := ready(false)
res := h.database("bob", "u-1", "Lost")
front := h.deadFrontend()
rev := h.revision("bob", res)
for name, c := range map[string]struct {
method, path, body string
header func(*http.Request)
op string
}{
"create": {"POST", "/api/addons/resources", `{"addonId":"tables","operationId":"lost-create","generation":1,"arguments":{"name":"N"}}`, h.tab("bob", "lost-create"), "lost-create"},
"rename": {"POST", "/api/addons/resources/" + res + "/rename", fmt.Sprintf(`{"operationId":"lost-rename","generation":1,"revision":{"schema":%v,"data":%v},"name":"N"}`, rev["schema"], rev["data"]), h.tab("bob", res), "lost-rename"},
"delete": {"POST", "/api/addons/resources/" + res + "/delete", fmt.Sprintf(`{"operationId":"lost-delete","generation":1,"revision":{"schema":%v,"data":%v},"confirm":"Lost"}`, rev["schema"], rev["data"]), h.tab("bob", res), "lost-delete"},
"grant": {"PUT", "/api/addons/resources/" + res + "/grants/carol", `{"role":"viewer","operationId":"lost-grant"}`, h.tab("bob", res), "lost-grant"},
"revoke": {"DELETE", "/api/addons/resources/" + res + "/grants/carol?operationId=lost-revoke", "", h.tab("bob", res), "lost-revoke"},
} {
got := serve(front, c.method, c.path, c.body, c.header)
Expect(got.code).To(Equal(http.StatusServiceUnavailable), name+" "+got.raw)
Expect(got.errType()).To(Equal("outcome_unknown"), name)
Expect(got.errOperation()).To(Equal(c.op), name)
}
// Reads and the idempotent selection are plainly unavailable.
for name, got := range map[string]reply{
"list": serve(front, "GET", "/api/addons/resources?addonId=tables", "", h.bearer("bob")),
"grants": serve(front, "GET", "/api/addons/resources/"+res+"/grants", "", h.tab("bob", res)),
"selection": serve(front, "PUT", "/api/addons/conversations/c-1/selection/tables", fmt.Sprintf(`{"resourceId":%q}`, res), h.bearer("bob")),
} {
Expect(got.code).To(Equal(http.StatusServiceUnavailable), name)
Expect(got.errType()).To(Equal("addon_error"), name)
Expect(got.errOperation()).To(BeEmpty(), name)
}
// The owner did none of it.
Expect(resourcesOf(h.list("bob"))).To(HaveLen(1))
Expect(h.grants("bob", res).body["grants"]).To(BeEmpty())
name, e := h.name("bob", res)
Expect(e).NotTo(HaveOccurred())
Expect(name).To(Equal("Lost"))
})
It("answers the sharing denial of local-owner mode the same from a frontend", func() {
h := ready(true)
front := h.frontend()
res := h.database("local-owner", "lf-1", "Local")
for _, path := range []string{"/grants", "/users?q=ab"} {
own := h.send("GET", "/api/addons/resources/"+res+path, "", h.tab("x", res))
fwd := serve(front, "GET", "/api/addons/resources/"+res+path, "", h.tab("x", res))
Expect(own.code).To(Equal(http.StatusConflict), own.raw)
Expect(fwd.code).To(Equal(own.code), fwd.raw)
Expect(fwd.errType()).To(Equal("sharing_disabled"))
}
})
})
+286
View File
@@ -0,0 +1,286 @@
// SPDX-License-Identifier: MIT
package tablesadmin_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sync"
"github.com/mudler/LocalAI/core/services/addons/policy"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("UI-only resource management of the owner", func() {
It("creates private databases by the person's own action, once per operation, and lists them only for their owner", func() {
h := ready(false)
// A key, an unmarked request and a tool marker are never the person.
body := `{"addonId":"tables","operationId":"make-1","generation":1,"arguments":{"name":"Budget"}}`
Expect(h.send("POST", "/api/addons/resources", body, h.bearer("bob")).code).To(Equal(http.StatusForbidden))
Expect(h.send("POST", "/api/addons/resources", body, h.session("bob")).code).To(Equal(http.StatusForbidden))
marked := func(r *http.Request) { h.tab("bob", "make-1")(r); r.Header.Set("X-LocalAI-Tool-Call", "1") }
Expect(h.send("POST", "/api/addons/resources", body, marked).code).To(Equal(http.StatusForbidden))
Expect(h.send("POST", "/api/addons/resources", body, h.tab("bob", "another-op")).code).To(Equal(http.StatusForbidden))
Expect(resourcesOf(h.list("bob"))).To(BeEmpty())
got := h.create("bob", "make-1", "Budget")
Expect(got.code).To(Equal(http.StatusOK), got.raw)
id := got.body["id"].(string)
Expect(got.body["role"]).To(Equal("owner"))
Expect(got.body["name"]).To(Equal("Budget"))
Expect(got.body["sharedWith"]).To(BeEquivalentTo(0))
name, e := h.name("bob", id)
Expect(e).NotTo(HaveOccurred())
Expect(name).To(Equal("Budget"))
// The same operation is the same database, whoever repeats it.
again := h.create("bob", "make-1", "Budget")
Expect(again.code).To(Equal(http.StatusOK), again.raw)
Expect(again.body["id"]).To(Equal(id))
// The same operation with another request is refused.
other := h.create("bob", "make-1", "Other name")
Expect(other.code).To(Equal(http.StatusForbidden), other.raw)
keys, e := h.app.AddonPolicy().Accessible(context.Background(), h.actor("bob"), "tables")
Expect(e).NotTo(HaveOccurred())
Expect(keys).To(HaveLen(1))
// Another person's operation ID is their own namespace.
other = h.create("carol", "make-1", "Carol's")
Expect(other.code).To(Equal(http.StatusOK), other.raw)
Expect(other.body["id"]).NotTo(Equal(id))
// Private by default: nobody else lists it, installation
// administration included, and the listing shows no other user's data.
for _, user := range []string{"alice", "dave"} {
got := h.list(user)
Expect(got.code).To(Equal(http.StatusOK), got.raw)
Expect(resourcesOf(got)).NotTo(HaveKey(id), user)
}
mine := resourcesOf(h.list("bob"))
Expect(mine).To(HaveLen(1))
Expect(mine[id]["role"]).To(Equal("owner"))
Expect(mine[id]).To(HaveKey("revision"))
// The creation arguments are the tool's own schema: an identity field
// or an unknown one never reaches the add-on.
for _, args := range []string{`{"name":"X","tool":"databases-delete"}`, `{"name":"X","userId":"alice"}`, `{"name":"X","extra":1}`, `{}`} {
b := fmt.Sprintf(`{"addonId":"tables","operationId":"bad-op","generation":1,"arguments":%s}`, args)
Expect(h.send("POST", "/api/addons/resources", b, h.tab("bob", "bad-op")).code).To(Equal(http.StatusBadRequest), args)
}
// A stale generation is a stale view.
stale := `{"addonId":"tables","operationId":"stale-op","generation":9,"arguments":{"name":"X"}}`
got = h.send("POST", "/api/addons/resources", stale, h.tab("bob", "stale-op"))
Expect(got.code).To(Equal(http.StatusConflict))
Expect(got.errType()).To(Equal("stale"))
// A person without the feature has no route at all.
Expect(h.list("erin").code).To(Equal(http.StatusForbidden))
})
It("keeps the owner, editor and viewer matrix on the UI routes and shows each person only their own role", func() {
h := ready(false)
res := h.database("bob", "m-1", "Matrix")
Expect(h.grant("bob", res, "carol", "viewer").code).To(Equal(200))
Expect(h.grant("bob", res, "dave", "editor").code).To(Equal(200))
for user, role := range map[string]string{"bob": "owner", "carol": "viewer", "dave": "editor"} {
listed := resourcesOf(h.list(user))
Expect(listed).To(HaveKey(res), user)
Expect(listed[res]["role"]).To(Equal(role), user)
if role != "owner" {
// Grantees see who shared it and neither the grants nor the
// revision of the owner's controls.
Expect(listed[res]["owner"]).To(Equal("Name of bob"))
Expect(listed[res]).NotTo(HaveKey("sharedWith"))
Expect(listed[res]).NotTo(HaveKey("revision"))
} else {
Expect(listed[res]["sharedWith"]).To(BeEquivalentTo(2))
}
name, e := h.name(user, res)
Expect(e).NotTo(HaveOccurred(), user)
Expect(name).To(Equal("Matrix"))
}
rev := h.revision("bob", res)
// Only the owner shares, renames, searches, reads grants or deletes;
// everyone else gets the answer of an unknown resource.
unknown := h.rename("bob", "r"+"0123456789abcdef0123456789abcde", "op-x", "N", rev)
Expect(unknown.code).To(Equal(http.StatusForbidden))
for _, user := range []string{"carol", "dave", "alice", "erin"} {
for name, got := range map[string]reply{
"rename": h.rename(user, res, "op-"+user, "Hijacked", rev),
"delete": h.remove(user, res, "del-"+user, "Matrix", rev),
"grant": h.grant(user, res, user, "editor"),
"revoke": h.revoke(user, res, "carol"),
"grants": h.grants(user, res),
"users": h.users(user, res, "ca"),
} {
if user == "erin" {
// No feature: the route gate answers first.
Expect(got.code).To(Equal(http.StatusForbidden), user+" "+name)
continue
}
Expect(got.code).To(Equal(unknown.code), user+" "+name+" "+got.raw)
Expect(got.errType()).To(Equal("addon_error"), user+" "+name)
}
}
// Nothing changed.
name, _ := h.name("bob", res)
Expect(name).To(Equal("Matrix"))
Expect(resourcesOf(h.list("carol"))[res]["role"]).To(Equal("viewer"))
// The owner renames by the person's own action: no proposal, a
// receipt, and the name is the add-on's.
done := h.rename("bob", res, "rename-1", "Matrix 2027", rev)
Expect(done.code).To(Equal(http.StatusOK), done.raw)
Expect(done.body["name"]).To(Equal("Matrix 2027"))
name, _ = h.name("carol", res)
Expect(name).To(Equal("Matrix 2027"))
status := h.send("GET", "/api/addons/resources/"+res+"/operations/rename-1?kind=rename", "", h.tab("bob", res))
Expect(status.code).To(Equal(200), status.raw)
Expect(status.body["state"]).To(Equal("committed"))
// A repeated operation replays the receipt and renames nothing twice.
again := h.rename("bob", res, "rename-1", "Matrix 2027", rev)
Expect(again.code).To(Equal(http.StatusOK), again.raw)
// The proposal list stays empty: a UI change is not a chat proposal.
pending := h.send("GET", "/api/addons/proposals?state=pending", "", h.bearer("bob"))
Expect(pending.body["proposals"]).To(BeEmpty())
// The same operation ID of two owners are two operations.
res2 := h.database("carol", "m-2", "Carol's")
rev2 := h.revision("carol", res2)
Expect(h.rename("carol", res2, "rename-1", "Carol's 2027", rev2).code).To(Equal(http.StatusOK))
name, _ = h.name("carol", res2)
Expect(name).To(Equal("Carol's 2027"))
// A rename carries the revision the owner saw; a name the add-on
// refuses changes nothing.
bad := h.rename("bob", res, "rename-bad", "", rev)
Expect(bad.code).To(Equal(http.StatusBadRequest))
moved := map[string]any{"schema": 99, "data": 0}
stale := h.rename("bob", res, "rename-stale", "Never", moved)
Expect(stale.code).To(Equal(http.StatusConflict))
Expect(stale.errType()).To(Equal("stale"))
name, _ = h.name("bob", res)
Expect(name).To(Equal("Matrix 2027"))
})
It("shares whole databases by immutable account ID behind a bounded picker that tells nothing else", func() {
h := ready(false)
res := h.database("bob", "p-1", "Shared")
// The picker: bounded prefix, minimal fields, eligible accounts only.
Expect(h.users("bob", res, "c").code).To(Equal(http.StatusBadRequest))
got := h.users("bob", res, "Name%20of%20c")
Expect(got.code).To(Equal(200), got.raw)
users, _ := got.body["users"].([]any)
Expect(users).To(HaveLen(1))
user := users[0].(map[string]any)
Expect(user).To(HaveLen(2))
Expect(user["id"]).To(Equal("carol"))
Expect(user["displayName"]).To(Equal("Name of carol"))
// Not offered: oneself, accounts without the feature and pending ones.
all := h.users("bob", res, "Name%20of")
var ids []string
for _, x := range all.body["users"].([]any) {
ids = append(ids, x.(map[string]any)["id"].(string))
}
Expect(ids).To(ConsistOf("alice", "carol", "dave"))
Expect(all.raw).NotTo(ContainSubstring("@"))
// The match is a prefix and the wildcard characters mean themselves.
Expect(resourcesOfUsers(h.users("bob", res, "%25%25"))).To(BeEmpty())
Expect(resourcesOfUsers(h.users("bob", res, "ame%20of"))).To(BeEmpty())
// Roles are viewer or editor; the owner role is never granted.
Expect(h.grant("bob", res, "carol", "owner").code).To(Equal(http.StatusBadRequest))
Expect(h.grant("bob", res, "carol", "").code).To(Equal(http.StatusBadRequest))
// Only accounts that exist, are active and hold the feature.
for _, target := range []string{"erin", "frank", "nobody", "legacy-api-key", "local-owner", "bob"} {
Expect(h.grant("bob", res, target, "viewer").code).To(Equal(http.StatusForbidden), target)
}
done := h.grant("bob", res, "carol", "viewer")
Expect(done.code).To(Equal(200), done.raw)
granted := done.body["grants"].([]any)
Expect(granted).To(HaveLen(1))
Expect(granted[0]).To(Equal(map[string]any{"userId": "carol", "displayName": "Name of carol", "role": "viewer"}))
// Changing the role and listing the grants.
Expect(h.grant("bob", res, "carol", "editor").code).To(Equal(200))
listed := h.grants("bob", res)
Expect(listed.code).To(Equal(200))
Expect(listed.body["grants"].([]any)[0].(map[string]any)["role"]).To(Equal("editor"))
// Revoking removes it, and a repeated revoke is the same state.
Expect(h.revoke("bob", res, "carol").code).To(Equal(200))
Expect(h.revoke("bob", res, "carol").code).To(Equal(200))
Expect(resourcesOf(h.list("carol"))).NotTo(HaveKey(res))
_, e := h.name("carol", res)
Expect(e).To(HaveOccurred())
// The sharing routes accept the owner's own session only.
for _, header := range []func(*http.Request){h.bearer("bob"), h.session("bob")} {
Expect(h.send("PUT", "/api/addons/resources/"+res+"/grants/carol", `{"role":"viewer"}`, header).code).To(Equal(http.StatusForbidden))
Expect(h.send("DELETE", "/api/addons/resources/"+res+"/grants/carol", "", header).code).To(Equal(http.StatusForbidden))
Expect(h.send("GET", "/api/addons/resources/"+res+"/grants", "", header).code).To(Equal(http.StatusForbidden))
Expect(h.send("GET", "/api/addons/resources/"+res+"/users?q=ca", "", header).code).To(Equal(http.StatusForbidden))
}
// A cross-site browser request never reaches the handler.
cross := func(r *http.Request) { h.tab("bob", res)(r); r.Header.Set("Sec-Fetch-Site", "cross-site") }
Expect(h.send("PUT", "/api/addons/resources/"+res+"/grants/carol", `{"role":"viewer"}`, cross).code).To(BeElementOf(http.StatusBadRequest, http.StatusForbidden))
Expect(resourcesOf(h.list("carol"))).NotTo(HaveKey(res))
})
It("keeps sharing disabled in local-owner mode with a typed answer and no named users", func() {
h := ready(true)
res := h.database("local-owner", "l-1", "Mine")
listed := h.list("local-owner")
Expect(listed.code).To(Equal(200), listed.raw)
Expect(listed.body["sharing"]).To(Equal(false))
Expect(resourcesOf(listed)).To(HaveKey(res))
Expect(resourcesOf(listed)[res]["role"]).To(Equal("owner"))
for name, got := range map[string]reply{
"grant": h.grant("local-owner", res, "carol", "viewer"),
"revoke": h.revoke("local-owner", res, "carol"),
"grants": h.grants("local-owner", res),
"users": h.users("local-owner", res, "ca"),
} {
Expect(got.code).To(Equal(http.StatusConflict), name+" "+got.raw)
Expect(got.errType()).To(Equal("sharing_disabled"), name)
}
// Everything else works: the mode has one owner, not no owner.
rev := h.revision("local-owner", res)
done := h.rename("local-owner", res, "rl-1", "Mine too", rev)
Expect(done.code).To(Equal(200), done.raw)
// A request that carries an API credential is not the local owner's page.
Expect(h.send("POST", "/api/addons/resources/"+res+"/rename", "{}", h.tab("x", res), func(r *http.Request) { r.Header.Set("Authorization", "Bearer x") }).code).To(Equal(http.StatusForbidden))
})
It("waits for a revoked user's admitted work and cancels it before acknowledging the revocation", func() {
h := ready(false)
res := h.database("bob", "c-1", "Busy")
Expect(h.grant("bob", res, "carol", "editor").code).To(Equal(200))
p := h.app.AddonPolicy()
started, cancelled := make(chan struct{}), make(chan struct{})
var once sync.Once
go func() {
defer GinkgoRecover()
_ = p.Admit(context.Background(), h.actor("carol"), h.key(res), policy.ActionWrite, func(ctx context.Context) error {
once.Do(func() { close(started) })
<-ctx.Done()
close(cancelled)
return ctx.Err()
})
}()
Eventually(started).Should(BeClosed())
done := h.revoke("bob", res, "carol")
Expect(done.code).To(Equal(200), done.raw)
// The acknowledgement came after the admitted work saw its context end.
Expect(cancelled).To(BeClosed())
_, e := h.name("carol", res)
Expect(e).To(HaveOccurred())
Expect(resourcesOf(h.list("carol"))).NotTo(HaveKey(res))
})
})
func resourcesOfUsers(r reply) []any {
GinkgoHelper()
Expect(r.code).To(Equal(200), r.raw)
users, _ := r.body["users"].([]any)
return users
}
var _ = json.Marshal
+278
View File
@@ -0,0 +1,278 @@
// SPDX-License-Identifier: MIT
package tablesadmin_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"time"
mcpTools "github.com/mudler/LocalAI/core/http/endpoints/mcp"
addonsvc "github.com/mudler/LocalAI/core/services/addons"
"github.com/mudler/LocalAI/core/services/addons/gateway"
"github.com/mudler/LocalAI/core/services/addons/tools"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func (h *host) selection(user, conv string) reply {
GinkgoHelper()
return h.send("GET", "/api/addons/conversations/"+conv+"/selection/tables", "", h.bearer(user))
}
func (h *host) choose(user, conv, res string) reply {
GinkgoHelper()
return h.send("PUT", "/api/addons/conversations/"+conv+"/selection/tables", fmt.Sprintf(`{"resourceId":%q}`, res), h.bearer(user))
}
func (h *host) clear(user, conv string) reply {
GinkgoHelper()
return h.send("DELETE", "/api/addons/conversations/"+conv+"/selection/tables", "", h.bearer(user))
}
// say runs one model tool call of a chat conversation as the user's key.
func (h *host) say(user, conv, tool, body string) (tools.ToolResult, error) {
GinkgoHelper()
return tools.NewToolAdapter(h.app.AddonGateway()).ExecuteIn(context.Background(), h.actor(user), conv, tool, json.RawMessage(body))
}
func inspectedName(r tools.ToolResult) string {
GinkgoHelper()
Expect(r.Kind).To(Equal(tools.ResultData))
var v struct {
Name string `json:"name"`
}
Expect(json.Unmarshal(r.Result, &v)).To(Succeed())
return v.Name
}
var _ = Describe("the database a conversation works on", func() {
It("keeps one selection per user, conversation and add-on, across people, conversations and restarts", func() {
h := ready(false)
sales := h.database("bob", "v-1", "Sales")
stock := h.database("bob", "v-2", "Stock")
mine := h.database("carol", "v-3", "Carols")
// Nothing is selected, and no process-wide default exists.
got := h.selection("bob", "chat-1")
Expect(got.code).To(Equal(http.StatusNotFound))
Expect(got.errType()).To(Equal("not_selected"))
done := h.choose("bob", "chat-1", sales)
Expect(done.code).To(Equal(200), done.raw)
Expect(done.body).To(HaveKeyWithValue("resourceId", sales))
Expect(done.body).To(HaveKeyWithValue("role", "owner"))
Expect(done.body).To(HaveKeyWithValue("name", "Sales"))
Expect(h.choose("bob", "chat-2", stock).code).To(Equal(200))
// Two conversations of one user keep two selections.
Expect(h.selection("bob", "chat-1").body["resourceId"]).To(Equal(sales))
Expect(h.selection("bob", "chat-2").body["resourceId"]).To(Equal(stock))
// The same conversation ID of another person is another namespace and
// never shows or inherits this selection.
Expect(h.selection("carol", "chat-1").code).To(Equal(http.StatusNotFound))
Expect(h.choose("carol", "chat-1", mine).code).To(Equal(200))
Expect(h.selection("bob", "chat-1").body["resourceId"]).To(Equal(sales))
Expect(h.selection("carol", "chat-1").body["resourceId"]).To(Equal(mine))
// A database the person cannot read is not selectable, with the answer
// of an unknown database; neither is a database of another add-on or a
// malformed identifier.
Expect(h.choose("carol", "chat-2", stock).code).To(Equal(http.StatusForbidden))
Expect(h.choose("carol", "chat-2", "r0123456789abcdef0123456789abcde").code).To(Equal(http.StatusForbidden))
put := h.send("PUT", "/api/addons/conversations/chat-2/selection/other", fmt.Sprintf(`{"resourceId":%q}`, mine), h.bearer("carol"))
Expect(put.code).To(Equal(http.StatusForbidden))
Expect(h.send("PUT", "/api/addons/conversations/bad%20conv/selection/tables", fmt.Sprintf(`{"resourceId":%q}`, mine), h.bearer("carol")).code).To(Equal(400))
Expect(h.send("PUT", "/api/addons/conversations/chat-2/selection/tables", `{"resourceId":"../x"}`, h.bearer("carol")).code).To(Equal(http.StatusBadRequest))
Expect(h.selection("carol", "chat-2").code).To(Equal(http.StatusNotFound))
// A user without the feature has no selection routes.
Expect(h.selection("erin", "chat-1").code).To(Equal(http.StatusForbidden))
// The selection is durable on the owner.
h.stop()
h.start()
h.running()
Expect(h.selection("bob", "chat-2").body["resourceId"]).To(Equal(stock))
Expect(h.selection("carol", "chat-1").body["resourceId"]).To(Equal(mine))
Expect(h.clear("bob", "chat-2").code).To(Equal(http.StatusNoContent))
Expect(h.clear("bob", "chat-2").code).To(Equal(http.StatusNoContent))
Expect(h.selection("bob", "chat-2").code).To(Equal(http.StatusNotFound))
Expect(h.selection("bob", "chat-1").code).To(Equal(200))
})
It("lets a tool call omit the database in a conversation and resolves it at the owner with typed errors", func() {
h := ready(false)
sales := h.database("bob", "w-1", "Sales")
stock := h.database("bob", "w-2", "Stock")
Expect(h.choose("bob", "chat-1", sales).code).To(Equal(200))
inspect := "addon__tables__databases-inspect"
got, e := h.say("bob", "chat-1", inspect, `{}`)
Expect(e).NotTo(HaveOccurred())
Expect(inspectedName(got)).To(Equal("Sales"))
// An explicit database is the person's own words and still works.
got, e = h.say("bob", "chat-1", inspect, fmt.Sprintf(`{"resourceId":%q}`, stock))
Expect(e).NotTo(HaveOccurred())
Expect(inspectedName(got)).To(Equal("Stock"))
// No selection is a result that asks the person to select one.
got, e = h.say("bob", "chat-2", inspect, `{}`)
Expect(e).NotTo(HaveOccurred())
Expect(got.Kind).To(Equal(tools.ResultError))
Expect(got.Code).To(Equal(tools.CodeSelectResource))
Expect(got.Result).To(BeEmpty())
// A caller without a conversation, or without a selection of its own,
// never inherits another's: another user's same conversation ID, an
// agent without a conversation.
got, e = h.say("carol", "chat-1", inspect, `{}`)
Expect(e).NotTo(HaveOccurred())
Expect(got.Kind).To(Equal(tools.ResultError))
_, e = h.say("bob", "", inspect, `{}`)
Expect(e).To(MatchError(tools.ErrInvalidArguments))
// The conversation is never an argument, and no field of the model
// can name or replace it.
for _, args := range []string{`{"conversationId":"chat-1"}`, `{"conversation":"chat-1"}`, `{"selection":"x"}`} {
_, e = h.say("bob", "chat-9", inspect, args)
Expect(e).To(MatchError(tools.ErrInvalidArguments), args)
}
_, e = h.say("bob", "chat-9", inspect, `{"resourceId":"x","tool":"databases-delete"}`)
Expect(e).To(MatchError(tools.ErrForbiddenArgument))
// A conversation that is not the shape of one is refused.
_, e = h.say("bob", "../chat", inspect, `{}`)
Expect(e).To(MatchError(tools.ErrInvalidArguments))
// Writes use it too: a proposal names the selected database.
created, e := h.say("bob", "chat-1", "addon__tables__tables-create", `{"table":"Orders"}`)
Expect(e).NotTo(HaveOccurred())
Expect(created.Kind).To(Equal(tools.ResultProposal))
Expect(created.Proposal.ResourceID).To(Equal(sales))
// The executor of a chat request binds the conversation once, like the
// actor, and nothing in the arguments changes it.
exec, e := mcpTools.NewAddonToolExecutorFor(context.Background(), tools.NewToolAdapter(h.app.AddonGateway()), h.actor("bob"), "chat-1")
Expect(e).NotTo(HaveOccurred())
out, e := exec.ExecuteTool(context.Background(), inspect, `{}`)
Expect(e).NotTo(HaveOccurred())
Expect(out).To(ContainSubstring(`"Sales"`))
out, e = exec.ExecuteTool(context.Background(), inspect, `{"conversationId":"chat-9"}`)
Expect(e).To(HaveOccurred())
Expect(out).To(BeEmpty())
none, e := mcpTools.NewAddonToolExecutor(context.Background(), tools.NewToolAdapter(h.app.AddonGateway()), h.actor("bob"))
Expect(e).NotTo(HaveOccurred())
_, e = none.ExecuteTool(context.Background(), inspect, `{}`)
Expect(e).To(MatchError(tools.ErrInvalidArguments))
})
It("withdraws the management and the tool path while the add-on is disabled and restores them on enable", func() {
h := ready(false)
sales := h.database("bob", "y-1", "Sales")
Expect(h.choose("bob", "chat-1", sales).code).To(Equal(200))
inspect := "addon__tables__databases-inspect"
_, e := h.say("bob", "chat-1", inspect, `{}`)
Expect(e).NotTo(HaveOccurred())
ctx := context.Background()
_, e = h.app.AddonManager().Submit(ctx, h.actor("alice"), addonsvc.LifecycleCommand{OperationID: "y-disable", AddonID: "tables", Action: "disable", Generation: 1})
Expect(e).NotTo(HaveOccurred())
// No management call answers from a disabled add-on, a stale tab
// cannot create, and the model is not offered the tool.
Expect(h.list("bob").code).To(Equal(http.StatusServiceUnavailable))
rev := map[string]any{"schema": 1, "data": 0}
Expect(h.rename("bob", sales, "y-r", "N", rev).code).To(BeElementOf(503, 403))
Expect(h.create("bob", "y-c", "N").code).To(BeElementOf(503, 403))
_, e = h.say("bob", "chat-1", inspect, `{}`)
Expect(e).To(HaveOccurred())
// The data and the selection are kept; only the display name needs the
// add-on.
kept := h.selection("bob", "chat-1")
Expect(kept.code).To(Equal(200), kept.raw)
Expect(kept.body).To(HaveKeyWithValue("resourceId", sales))
Expect(kept.body).NotTo(HaveKey("name"))
_, e = h.app.AddonManager().Submit(ctx, h.actor("alice"), addonsvc.LifecycleCommand{OperationID: "y-enable", AddonID: "tables", Action: "enable", Generation: 1})
Expect(e).NotTo(HaveOccurred())
h.running()
Expect(resourcesOf(h.list("bob"))).To(HaveKey(sales))
got, e := h.say("bob", "chat-1", inspect, `{}`)
Expect(e).NotTo(HaveOccurred())
Expect(inspectedName(got)).To(Equal("Sales"))
})
It("answers a revoked, deleted or tampered reference like an unknown database and never reuses it", func() {
h := ready(false)
shared := h.database("bob", "x-1", "Shared")
doomed := h.database("bob", "x-2", "Doomed")
private := h.database("bob", "x-3", "Private")
Expect(h.grant("bob", shared, "carol", "viewer").code).To(Equal(200))
Expect(h.grant("bob", doomed, "carol", "viewer").code).To(Equal(200))
Expect(h.choose("carol", "chat-1", shared).code).To(Equal(200))
Expect(h.choose("carol", "chat-2", doomed).code).To(Equal(200))
inspect := "addon__tables__databases-inspect"
_, e := h.say("carol", "chat-1", inspect, `{}`)
Expect(e).NotTo(HaveOccurred())
// The answer for an unknown database, to compare with.
_, unknown := h.say("carol", "chat-1", inspect, `{"resourceId":"r0123456789abcdef0123456789abcde"}`)
Expect(unknown).To(HaveOccurred())
Expect(gateway.StatusFor(unknown)).To(Equal(403))
// Revoked: the same denial. The reference is no authority and stays the
// person's own: a new grant by the owner makes it work again.
Expect(h.revoke("bob", shared, "carol").code).To(Equal(200))
_, e = h.say("carol", "chat-1", inspect, `{}`)
Expect(e).To(HaveOccurred())
Expect(e.Error()).To(Equal(unknown.Error()))
Expect(gateway.StatusFor(e)).To(Equal(403))
Expect(h.selection("carol", "chat-1").code).To(Equal(http.StatusNotFound))
Expect(h.grant("bob", shared, "carol", "viewer").code).To(Equal(200))
got, e := h.say("carol", "chat-1", inspect, `{}`)
Expect(e).NotTo(HaveOccurred())
Expect(inspectedName(got)).To(Equal("Shared"))
// Deleted: the same denial.
rev := h.revision("bob", doomed)
Expect(h.remove("bob", doomed, "x-del", "Doomed", rev).code).To(Equal(200))
_, e = h.say("carol", "chat-2", inspect, `{}`)
Expect(e).To(HaveOccurred())
Expect(gateway.StatusFor(e)).To(Equal(403))
Expect(e.Error()).To(Equal(unknown.Error()))
Expect(h.selection("carol", "chat-2").code).To(Equal(http.StatusNotFound))
// Tampered: the stored file names a database that was never shared.
// The reference is no authority: the owner decides at every use.
Expect(h.choose("carol", "chat-3", shared).code).To(Equal(200))
h.stop()
path := filepath.Join(h.owner.Root, "selections", "selections.json")
raw, err := os.ReadFile(path)
Expect(err).NotTo(HaveOccurred())
var doc struct {
Version int `json:"version"`
Entries []map[string]any `json:"entries"`
}
Expect(json.Unmarshal(raw, &doc)).To(Succeed())
tampered := 0
for _, entry := range doc.Entries {
if entry["user"] == "carol" && entry["conversation"] == "chat-3" {
entry["resourceId"] = private
tampered++
}
}
Expect(tampered).To(Equal(1))
raw, err = json.Marshal(doc)
Expect(err).NotTo(HaveOccurred())
Expect(os.WriteFile(path, raw, 0o600)).To(Succeed())
h.start()
h.running()
_, e = h.say("carol", "chat-3", inspect, `{}`)
Expect(e).To(HaveOccurred())
Expect(gateway.StatusFor(e)).To(Equal(403))
Expect(e.Error()).To(Equal(unknown.Error()))
got2 := h.selection("carol", "chat-3")
Expect(got2.code).To(Equal(http.StatusNotFound))
// The owner of that database is not affected.
name, e := h.name("bob", private)
Expect(e).NotTo(HaveOccurred())
Expect(name).To(Equal("Private"))
_ = time.Second
})
})
+314
View File
@@ -0,0 +1,314 @@
// SPDX-License-Identifier: MIT
package tablesadmin_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/mudler/LocalAI/core/services/addons/policy"
"github.com/mudler/LocalAI/core/services/addons/resources"
"github.com/mudler/LocalAI/pkg/addons/contract"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type clock struct {
mu sync.Mutex
t time.Time
}
func (c *clock) now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.t
}
func (c *clock) advance(d time.Duration) {
c.mu.Lock()
c.t = c.t.Add(d)
c.mu.Unlock()
}
var _ = Describe("the conversation selection store", func() {
open := func(dir string, o resources.SelectionOptions) *resources.SelectionStore {
GinkgoHelper()
s, e := resources.OpenSelections(dir, o)
Expect(e).NotTo(HaveOccurred())
return s
}
It("keys a reference by user, conversation and add-on, bounds it per user and in total and expires what is unused", func() {
dir := GinkgoT().TempDir()
c := &clock{t: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)}
s := open(dir, resources.SelectionOptions{TTL: time.Hour, MaxPerUser: 3, MaxTotal: 5, Now: c.now})
DeferCleanup(s.Close)
Expect(s.Put("u1", "c1", "tables", "rA")).To(Succeed())
Expect(s.Put("u2", "c1", "tables", "rB")).To(Succeed())
c.advance(time.Second)
Expect(s.Put("u1", "c1", "notes", "rC")).To(Succeed())
get := func(u, c, a string) (string, bool) {
GinkgoHelper()
id, ok, e := s.Get(u, c, a)
Expect(e).NotTo(HaveOccurred())
return id, ok
}
for key, want := range map[[3]string]string{{"u1", "c1", "tables"}: "rA", {"u2", "c1", "tables"}: "rB", {"u1", "c1", "notes"}: "rC"} {
got, ok := get(key[0], key[1], key[2])
Expect(ok).To(BeTrue(), fmt.Sprint(key))
Expect(got).To(Equal(want))
}
_, ok := get("u3", "c1", "tables")
Expect(ok).To(BeFalse())
_, ok = get("u1", "c2", "tables")
Expect(ok).To(BeFalse())
// A user over the bound loses the entry used least recently, and the
// entry just stored stays.
c.advance(time.Minute)
Expect(s.Put("u1", "c2", "tables", "rD")).To(Succeed())
c.advance(time.Minute)
Expect(s.Put("u1", "c3", "tables", "rE")).To(Succeed())
_, ok = get("u1", "c1", "tables")
Expect(ok).To(BeFalse(), "the oldest of the user is evicted")
_, ok = get("u1", "c3", "tables")
Expect(ok).To(BeTrue())
_, ok = get("u2", "c1", "tables")
Expect(ok).To(BeTrue(), "another user's entry is never evicted for this user")
// The total bound refuses once it is full of live entries.
Expect(s.Put("u4", "c1", "tables", "r1")).To(Succeed())
err := s.Put("u5", "c1", "tables", "r2")
Expect(errors.Is(err, resources.ErrSelectionStore)).To(BeTrue())
// Unused entries expire, and an expired one never resolves.
c.advance(2 * time.Hour)
_, ok = get("u2", "c1", "tables")
Expect(ok).To(BeFalse())
Expect(s.Put("u6", "c1", "tables", "r3")).To(Succeed())
Expect(s.Len()).To(Equal(1), "expired entries are removed by the next write")
// Delete is idempotent.
Expect(s.Delete("u6", "c1", "tables")).To(Succeed())
Expect(s.Delete("u6", "c1", "tables")).To(Succeed())
_, ok = get("u6", "c1", "tables")
Expect(ok).To(BeFalse())
})
It("is durable, held by one process, never resets a corrupt file and stops writing after a failed sync", func() {
dir := GinkgoT().TempDir()
s := open(dir, resources.SelectionOptions{})
Expect(s.Put("u1", "c1", "tables", "rA")).To(Succeed())
_, e := resources.OpenSelections(dir, resources.SelectionOptions{})
Expect(e).To(HaveOccurred(), "one process holds the store")
Expect(s.Close()).To(Succeed())
Expect(s.Put("u1", "c2", "tables", "rB")).NotTo(Succeed(), "nothing is written after Close")
s = open(dir, resources.SelectionOptions{})
id, ok, e := s.Get("u1", "c1", "tables")
Expect(e).NotTo(HaveOccurred())
Expect(ok).To(BeTrue())
Expect(id).To(Equal("rA"))
Expect(s.Close()).To(Succeed())
// A corrupt file is an error and is left as it is.
file := filepath.Join(dir, "selections.json")
Expect(os.WriteFile(file, []byte(`{"version":1,"entries":[{"user":"u1"`), 0o600)).To(Succeed())
_, e = resources.OpenSelections(dir, resources.SelectionOptions{})
Expect(e).To(HaveOccurred())
raw, _ := os.ReadFile(file)
Expect(string(raw)).To(Equal(`{"version":1,"entries":[{"user":"u1"`))
for _, bad := range []string{`{"version":2,"entries":[]}`, `{"version":1,"entries":[],"extra":1}`, `{"version":1,"entries":[{"user":"","conversation":"c","addon":"a","resourceId":"r","usedAt":"2026-01-01T00:00:00Z"}]}`, `{"version":1,"entries":[]} {"version":1}`} {
Expect(os.WriteFile(file, []byte(bad), 0o600)).To(Succeed())
_, e = resources.OpenSelections(dir, resources.SelectionOptions{})
Expect(e).To(HaveOccurred(), bad)
}
Expect(os.Remove(file)).To(Succeed())
// A change that reached the directory but could not be made durable is
// kept in memory and stops further writes until the store reopens.
fail := errors.New("directory sync failed")
failing := false
s = open(dir, resources.SelectionOptions{SyncDirectory: func(f *os.File) error {
if failing {
return fail
}
return f.Sync()
}})
Expect(s.Put("u1", "c1", "tables", "rA")).To(Succeed())
failing = true
Expect(s.Put("u1", "c2", "tables", "rB")).NotTo(Succeed())
failing = false
Expect(s.Put("u1", "c3", "tables", "rC")).NotTo(Succeed(), "a failed write stops further writes")
Expect(s.Delete("u1", "c1", "tables")).NotTo(Succeed())
Expect(s.Close()).To(Succeed())
s = open(dir, resources.SelectionOptions{})
DeferCleanup(s.Close)
Expect(s.Put("u1", "c3", "tables", "rC")).To(Succeed())
})
It("serves concurrent users without losing a reference", func() {
s := open(GinkgoT().TempDir(), resources.SelectionOptions{})
DeferCleanup(s.Close)
var wg sync.WaitGroup
for u := 0; u < 8; u++ {
wg.Add(1)
go func() {
defer GinkgoRecover()
defer wg.Done()
user := fmt.Sprintf("user-%d", u)
for c := 0; c < 10; c++ {
Expect(s.Put(user, fmt.Sprintf("conv-%d", c), "tables", fmt.Sprintf("r%d-%d", u, c))).To(Succeed())
}
}()
}
wg.Wait()
for u := 0; u < 8; u++ {
for c := 0; c < 10; c++ {
id, ok, e := s.Get(fmt.Sprintf("user-%d", u), fmt.Sprintf("conv-%d", c), "tables")
Expect(e).NotTo(HaveOccurred())
Expect(ok).To(BeTrue())
Expect(id).To(Equal(fmt.Sprintf("r%d-%d", u, c)))
}
}
Expect(s.Len()).To(Equal(80))
})
})
var _ = Describe("the tombstones of finished deletions", func() {
It("keeps the newest bounded number, validates them on load and remaps them with the owner", func() {
dir := GinkgoT().TempDir()
tombstones := map[string]any{}
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
for i := 0; i < policy.MaxTombstones; i++ {
tombstones[fmt.Sprintf("rold%04d", i)] = map[string]any{"addonId": "tables", "owner": "bob", "op": fmt.Sprintf("op-%d", i), "at": base.Add(time.Duration(i) * time.Minute)}
}
write := func(deleted map[string]any) {
GinkgoHelper()
raw, e := json.Marshal(map[string]any{"version": 1, "mode": "named-user", "resources": map[string]any{}, "deleted": deleted})
Expect(e).NotTo(HaveOccurred())
Expect(os.WriteFile(filepath.Join(dir, "policy.json"), raw, 0o600)).To(Succeed())
}
write(tombstones)
st, e := policy.OpenStore(dir, policy.ModeNamedUser)
Expect(e).NotTo(HaveOccurred())
p := policy.New(st, policy.Options{
Revalidate: func(context.Context, contract.Actor) error { return nil },
CreateAuthority: func(context.Context, contract.Actor, string) error { return nil },
})
ctx := context.Background()
bob := contract.Actor{UserID: "bob", CredentialID: "session:bob", Mode: "named-user"}
key, e := p.Register(ctx, bob, "tables")
Expect(e).NotTo(HaveOccurred())
Expect(p.Commit(ctx, bob, key)).To(Succeed())
Expect(p.BeginDelete(ctx, bob, key, "op-new")).To(Succeed())
Expect(p.ConfirmDelete(key)).To(Succeed())
Expect(p.FinishDelete(key)).To(Succeed())
got, e := p.DeletedBy(ctx, bob, key.ResourceID, "op-new")
Expect(e).NotTo(HaveOccurred())
Expect(got).To(BeTrue())
got, _ = p.DeletedBy(ctx, bob, "rold0000", "")
Expect(got).To(BeFalse(), "the oldest tombstone was dropped")
got, _ = p.DeletedBy(ctx, bob, "rold0001", "op-1")
Expect(got).To(BeTrue())
got, _ = p.DeletedBy(ctx, bob, "rold0001", "op-2")
Expect(got).To(BeFalse(), "another operation is not the deletion")
carol := contract.Actor{UserID: "carol", CredentialID: "session:carol", Mode: "named-user"}
got, _ = p.DeletedBy(ctx, carol, key.ResourceID, "")
Expect(got).To(BeFalse(), "only the owner it had learns it")
Expect(st.Close()).To(Succeed())
var doc struct {
Deleted map[string]any `json:"deleted"`
}
raw, e := os.ReadFile(filepath.Join(dir, "policy.json"))
Expect(e).NotTo(HaveOccurred())
Expect(json.Unmarshal(raw, &doc)).To(Succeed())
Expect(doc.Deleted).To(HaveLen(policy.MaxTombstones))
// A corrupt tombstone or one too many is a corrupt store, never reset.
for name, bad := range map[string]map[string]any{
"an empty owner": {"r1": map[string]any{"addonId": "tables", "owner": "", "op": "o", "at": base}},
"no operation": {"r1": map[string]any{"addonId": "tables", "owner": "bob", "op": "", "at": base}},
} {
write(bad)
_, e = policy.OpenStore(dir, policy.ModeNamedUser)
Expect(e).To(HaveOccurred(), name)
}
tombstones["rextra"] = map[string]any{"addonId": "tables", "owner": "bob", "op": "o", "at": base}
write(tombstones)
_, e = policy.OpenStore(dir, policy.ModeNamedUser)
Expect(e).To(HaveOccurred(), "more than the bound")
})
})
var _ = Describe("the manifest declaration of admin tools and resource tools", func() {
manifest := func() map[string]any {
GinkgoHelper()
raw, e := os.ReadFile("../../../addons/tables/manifest.json")
Expect(e).NotTo(HaveOccurred())
var m map[string]any
Expect(json.Unmarshal(raw, &m)).To(Succeed())
return m
}
caps := func(m map[string]any) map[string]any { return m["capabilities"].(map[string]any) }
parse := func(m map[string]any) error {
GinkgoHelper()
b, e := json.Marshal(m)
Expect(e).NotTo(HaveOccurred())
_, e = contract.ParseManifest(b)
return e
}
It("keeps the delete tool out of the model tools and their definitions", func() {
m := manifest()
Expect(parse(m)).To(Succeed())
c := caps(m)
Expect(c["adminTools"]).To(ConsistOf("databases-delete"))
Expect(c["tools"]).NotTo(ContainElement("databases-delete"))
for _, d := range c["toolDefinitions"].([]any) {
Expect(d.(map[string]any)["name"]).NotTo(Equal("databases-delete"))
}
})
It("refuses an admin tool that is also a model tool, has a definition, or is not mapped", func() {
bad := map[string]func(map[string]any){
"an admin tool that is a model tool": func(c map[string]any) {
c["adminTools"] = []any{"databases-rename"}
c["resources"].(map[string]any)["delete"] = "databases-rename"
},
"an admin tool with a model definition": func(c map[string]any) {
defs := c["toolDefinitions"].([]any)
c["toolDefinitions"] = append(defs, map[string]any{"name": "databases-delete", "description": "Delete.", "kind": "schema", "inputSchema": map[string]any{"type": "object", "properties": map[string]any{"resourceId": map[string]any{"type": "string"}}, "required": []any{"resourceId"}, "additionalProperties": false}})
},
"an admin tool nothing maps": func(c map[string]any) { delete(c["resources"].(map[string]any), "delete") },
"admin tools without a mapping": func(c map[string]any) { delete(c, "resources") },
"a delete mapping to a model tool": func(c map[string]any) { c["resources"].(map[string]any)["delete"] = "rows-delete" },
"a delete mapping that is not declared": func(c map[string]any) {
c["adminTools"] = []any{"databases-purge"}
c["resources"].(map[string]any)["delete"] = "databases-delete"
},
"a create mapping to a read tool": func(c map[string]any) { c["resources"].(map[string]any)["create"] = "databases-inspect" },
"a rename mapping to an undeclared tool": func(c map[string]any) { c["resources"].(map[string]any)["rename"] = "databases-copy" },
"an inspect mapping to a write tool": func(c map[string]any) { c["resources"].(map[string]any)["inspect"] = "rows-insert" },
"two admin tools": func(c map[string]any) { c["adminTools"] = []any{"databases-delete", "databases-purge"} },
"a markup noun": func(c map[string]any) { c["resources"].(map[string]any)["noun"] = "<b>database</b>" },
"an empty mapping": func(c map[string]any) { c["resources"] = map[string]any{}; c["adminTools"] = []any{} },
}
for name, mutate := range bad {
m := manifest()
mutate(caps(m))
Expect(parse(m)).NotTo(Succeed(), name)
}
// A manifest with no admin tools and no mapping stays valid.
m := manifest()
delete(caps(m), "adminTools")
delete(caps(m), "resources")
Expect(parse(m)).To(Succeed())
})
})
@@ -0,0 +1,81 @@
// SPDX-License-Identifier: MIT
package tablesexport_test
import (
"io/fs"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
"github.com/mudler/LocalAI/tests/addons/foundation/cluster"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestTablesExport(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Tables CSV export across replicas")
}
var (
paths cluster.Paths
tables []byte
)
var _ = BeforeSuite(func() {
_, file, _, _ := runtime.Caller(0)
root := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", ".."))
scratch, err := os.MkdirTemp("", "tables-export-bin-*")
Expect(err).NotTo(HaveOccurred())
DeferCleanup(func() { _ = os.RemoveAll(scratch) })
build := func(env, out, pkg string, cgo bool, tags ...string) string {
if p := os.Getenv(env); p != "" {
return p
}
target := filepath.Join(scratch, out)
args := []string{"build", "-p", "2"}
if len(tags) > 0 {
args = append(args, "-tags", tags[0])
}
cmd := exec.Command("go", append(args, "-o", target, pkg)...)
cmd.Dir = root
if cgo {
cmd.Env = append(os.Environ(), "CGO_ENABLED=1")
}
b, err := cmd.CombinedOutput()
Expect(err).NotTo(HaveOccurred(), string(b))
return target
}
paths.Fixture = build("FOUNDATION_FIXTURE", "fixture-addon", "./tests/addons/fixture/cmd", false, "auth")
paths.Server = build("FOUNDATION_SERVER", "ui-test-server", "./tests/e2e-ui", false, "auth")
paths.Mock = os.Getenv("FOUNDATION_MOCK")
if paths.Mock == "" {
paths.Mock = filepath.Join(root, "tests/e2e/mock-backend/mock-backend")
}
Expect(paths.Mock).To(BeAnExistingFile(), "build tests/e2e/mock-backend first (scripts/addons/prepare.sh --integration)")
exe := build("TABLES_EXECUTABLE", "tables", "./addons/tables/cmd/tables", true)
tables, err = os.ReadFile(exe)
Expect(err).NotTo(HaveOccurred())
})
// start brings a cluster up in a fresh directory and takes it down at the end
// of the spec.
func start() *cluster.Cluster {
dir, err := os.MkdirTemp("", "tables-export-cluster-*")
Expect(err).NotTo(HaveOccurred())
c, err := cluster.New(dir, paths)
Expect(err).NotTo(HaveOccurred())
DeferCleanup(func() {
c.Down()
_ = filepath.WalkDir(dir, func(p string, d fs.DirEntry, e error) error {
if e == nil && d.IsDir() {
_ = os.Chmod(p, 0o700)
}
return nil
})
_ = os.RemoveAll(dir)
})
return c
}
+209
View File
@@ -0,0 +1,209 @@
// SPDX-License-Identifier: MIT
package tablesexport_test
import (
"encoding/json"
"fmt"
"net/http"
"os"
"time"
"github.com/mudler/LocalAI/tests/addons/foundation/cluster"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
const ordersCSV = "Item,Note,Qty\r\n" +
"'=SUM(A1:A9),\"line one\nline two\",-4\r\n" +
"\"Café, \"\"deluxe\"\"\",日本語,12\r\n" +
"plain,,\r\n"
var seq int
func op(prefix string) string {
seq++
return fmt.Sprintf("%s-%d-%d", prefix, time.Now().UnixNano()%1_000_000, seq)
}
// get is one real HTTP request; the headers are what the response carried.
func get(c cluster.Client, path string) *cluster.Response {
GinkgoHelper()
r, err := c.Do("GET", path, nil, nil)
Expect(err).NotTo(HaveOccurred())
return r
}
var _ = Describe("a CSV export downloaded through the owner and both frontends", func() {
It("serves identical bytes and headers from every replica and denies every other way in", func() {
c := start()
raw, err := os.ReadFile("../../../addons/tables/manifest.json")
Expect(err).NotTo(HaveOccurred())
var m struct {
Capabilities map[string]any `json:"capabilities"`
}
Expect(json.Unmarshal(raw, &m)).To(Succeed())
delete(m.Capabilities, "ui")
Expect(c.Library.Add(cluster.Package{ID: "tables", Version: "0.1.0", Capabilities: m.Capabilities, Executable: "bin/tables", Binary: tables}, c.Effects)).To(Succeed())
Expect(c.Up()).To(Succeed())
admin := c.Token(cluster.FrontA, "admin")
life := func(cl cluster.Client, action string, gen uint64, extra map[string]any) {
GinkgoHelper()
body := map[string]any{"operationId": op("life"), "addonId": "tables", "action": action, "generation": gen, "arguments": extra}
r, err := cl.Do("POST", "/api/addons/dispatch", body, nil)
Expect(err).NotTo(HaveOccurred())
Expect(r.Status).To(Equal(200), r.String())
}
life(admin, "install", 0, map[string]any{"sourceId": "fixture", "version": "0.1.0"})
life(c.Token(cluster.FrontB, "admin"), "enable", 1, map[string]any{})
Eventually(func() string {
r, err := admin.Do("GET", "/api/addons/apps", nil, nil)
if err != nil || r.Status != 200 {
return "unavailable"
}
var out struct {
Apps []struct{ ID, State string } `json:"apps"`
}
_ = r.JSON(&out)
for _, a := range out.Apps {
if a.ID == "tables" {
return a.State
}
}
return "absent"
}, 40*time.Second, 200*time.Millisecond).Should(Equal("running"))
// The member creates the database and its rows through frontend A, by
// the person's own session, as the page does.
member := c.Session(cluster.FrontA, "member")
tab := func(id string) map[string]string { return map[string]string{"X-LocalAI-Confirm": id} }
created := op("create")
r, err := member.Do("POST", "/api/addons/resources", map[string]any{"addonId": "tables", "operationId": created, "generation": 1, "arguments": map[string]any{"name": "Orders data"}}, tab(created))
Expect(err).NotTo(HaveOccurred())
Expect(r.Status).To(Equal(200), r.String())
var db struct {
ID string `json:"id"`
Revision map[string]any `json:"revision"`
}
Expect(r.JSON(&db)).To(Succeed())
rev := db.Revision
change := func(tool string, args map[string]any) {
GinkgoHelper()
r, err := member.Do("POST", "/api/addons/resources/"+db.ID+"/apply", map[string]any{"operationId": op("apply"), "generation": 1, "revision": rev, "tool": tool, "arguments": args}, tab(db.ID))
Expect(err).NotTo(HaveOccurred())
Expect(r.Status).To(Equal(200), r.String())
var out struct {
Revision map[string]any `json:"revision"`
}
Expect(r.JSON(&out)).To(Succeed())
rev = out.Revision
}
change("tables-create", map[string]any{"table": "Orders"})
change("columns-add", map[string]any{"table": "Orders", "column": "Item", "type": "text"})
change("columns-add", map[string]any{"table": "Orders", "column": "Note", "type": "text"})
change("columns-add", map[string]any{"table": "Orders", "column": "Qty", "type": "integer"})
change("rows-insert", map[string]any{"table": "Orders", "rows": []map[string]any{
{"Item": "=SUM(A1:A9)", "Note": "line one\nline two", "Qty": -4},
{"Item": "Café, \"deluxe\"", "Note": "日本語", "Qty": 12},
{"Item": "plain"},
}})
share, err := member.Do("PUT", "/api/addons/resources/"+db.ID+"/grants/fnd-friend", map[string]any{"role": "viewer"}, tab(db.ID))
Expect(err).NotTo(HaveOccurred())
Expect(share.Status).To(Equal(200), share.String())
// The export is asked for through frontend B and lands at the owner.
viaB := c.Token(cluster.FrontB, "member")
exp, err := viaB.Do("POST", "/api/addons/dispatch", map[string]any{
"operationId": op("export"), "addonId": "tables", "resourceId": db.ID, "generation": 1, "action": "read",
"arguments": map[string]any{"tool": "rows-export", "table": "Orders"},
}, nil)
Expect(err).NotTo(HaveOccurred())
Expect(exp.Status).To(Equal(200), exp.String())
var out struct {
Outputs []struct {
Handle string `json:"handle"`
Name string `json:"name"`
MediaType string `json:"mediaType"`
Size int64 `json:"size"`
} `json:"outputs"`
}
Expect(exp.JSON(&out)).To(Succeed())
Expect(out.Outputs).To(HaveLen(1))
id := out.Outputs[0].Handle
Expect(out.Outputs[0].Size).To(Equal(int64(len(ordersCSV))))
Eventually(func() string {
s := get(c.Token(cluster.FrontA, "member"), "/api/addons/artifacts/"+id)
var st struct {
State string `json:"state"`
}
_ = s.JSON(&st)
return st.State
}, 20*time.Second, 100*time.Millisecond).Should(Equal("complete"))
// The same bytes and the same headers from the owner and both frontends.
var first *cluster.Response
for _, node := range []string{cluster.Owner, cluster.FrontA, cluster.FrontB} {
dl := get(c.Token(node, "member"), "/api/addons/artifacts/"+id+"/download")
Expect(dl.Status).To(Equal(200), node+" "+dl.String())
Expect(string(dl.Body)).To(Equal(ordersCSV), node)
Expect(dl.Header.Get("Content-Type")).To(Equal("text/csv"), node)
Expect(dl.Header.Get("Content-Disposition")).To(HavePrefix("attachment;"), node)
Expect(dl.Header.Get("Content-Disposition")).To(ContainSubstring("Orders.csv"), node)
Expect(dl.Header.Get("X-Content-Type-Options")).To(Equal("nosniff"), node)
Expect(dl.Header.Get("Cache-Control")).To(ContainSubstring("no-store"), node)
Expect(dl.Header.Get("Content-Length")).To(Equal(fmt.Sprint(len(ordersCSV))), node)
if first == nil {
first = dl
continue
}
for _, k := range []string{"Content-Type", "Content-Disposition", "X-Content-Type-Options", "Cache-Control", "Content-Length"} {
Expect(dl.Header.Get(k)).To(Equal(first.Header.Get(k)), node+" "+k)
}
}
// A person the database is shared with, and one without the feature,
// are denied the file on every replica exactly like an unknown ID.
unknown := "a0123456789abcdef0123456789abcde"
for _, node := range []string{cluster.Owner, cluster.FrontA, cluster.FrontB} {
base := get(c.Token(node, "friend"), "/api/addons/artifacts/"+unknown+"/download")
Expect(base.Status).To(Equal(http.StatusForbidden), node)
for _, who := range []string{"friend", "admin", "outsider"} {
dl := get(c.Token(node, who), "/api/addons/artifacts/"+id+"/download")
Expect(dl.Status).To(Equal(http.StatusForbidden), node+" "+who)
if who != "outsider" {
Expect(string(dl.Body)).To(Equal(string(base.Body)), node+" "+who)
}
st := get(c.Token(node, who), "/api/addons/artifacts/"+id)
Expect(st.Status).To(Equal(http.StatusForbidden), node+" "+who)
}
}
// The friend exports what they may read; withdrawing the grant ends
// the download on every replica.
fr, err := c.Token(cluster.FrontA, "friend").Do("POST", "/api/addons/dispatch", map[string]any{
"operationId": op("export-friend"), "addonId": "tables", "resourceId": db.ID, "generation": 1, "action": "read",
"arguments": map[string]any{"tool": "rows-export", "table": "Orders", "columns": []string{"Item"}},
}, nil)
Expect(err).NotTo(HaveOccurred())
Expect(fr.Status).To(Equal(200), fr.String())
var fo struct {
Outputs []struct {
Handle string `json:"handle"`
} `json:"outputs"`
}
Expect(fr.JSON(&fo)).To(Succeed())
fid := fo.Outputs[0].Handle
Eventually(func() int {
return get(c.Token(cluster.FrontB, "friend"), "/api/addons/artifacts/"+fid+"/download").Status
}, 20*time.Second, 100*time.Millisecond).Should(Equal(200))
Expect(string(get(c.Token(cluster.FrontB, "friend"), "/api/addons/artifacts/"+fid+"/download").Body)).To(Equal("Item\r\n'=SUM(A1:A9)\r\n\"Café, \"\"deluxe\"\"\"\r\nplain\r\n"))
gone, err := member.Do("DELETE", "/api/addons/resources/"+db.ID+"/grants/fnd-friend", nil, tab(db.ID))
Expect(err).NotTo(HaveOccurred())
Expect(gone.Status).To(Equal(200), gone.String())
for _, node := range []string{cluster.Owner, cluster.FrontA, cluster.FrontB} {
Expect(get(c.Token(node, "friend"), "/api/addons/artifacts/"+fid+"/download").Status).To(Equal(http.StatusForbidden), node)
}
// The member's own file is unaffected.
Expect(string(get(c.Token(cluster.FrontB, "member"), "/api/addons/artifacts/"+id+"/download").Body)).To(Equal(ordersCSV))
})
})
+454
View File
@@ -0,0 +1,454 @@
package main
// The add-ons UI fixture: with --addons-fixture the server runs with
// authentication on and a real add-on owner (lifecycle manager, supervisor,
// gateway and mTLS carrier), and installs the packages of the fixture
// directory through a real catalog served on loopback. A spec then drives the
// shell against real authorization: sessions and API keys are stored keys,
// every asset and call is decided by the owner, and enable, disable and
// upgrade are the real lifecycle routes.
import (
"archive/tar"
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"fmt"
"io/fs"
"math/big"
"net"
"net/http"
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/auth"
addonsvc "github.com/mudler/LocalAI/core/services/addons"
"github.com/mudler/LocalAI/core/services/addons/catalog"
"github.com/mudler/LocalAI/core/services/addons/gateway"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
const fixtureSecret = "addons-ui-fixture-secret" // #nosec G101 -- a fixed secret of the local test fixture, not a credential
type addonsFixture struct {
dataDir string
listener net.Listener
catalog *http.Server
library string
releases []catalog.Release
archives map[string][]byte
owner config.AddonConfig
// localOwner runs the owner without authentication, in local-owner mode.
localOwner bool
// mu guards what a spec moves through the control routes: the step of
// the moving channels and whether the library answers at all.
mu sync.Mutex
step int
offline bool
moving []movingRelease
}
// movingRelease is a release of a package directory that carries
// .fixture-generation: of the releases of one add-on and channel, only the
// one with the highest generation not above the current step is listed. A spec
// moves the channel by raising the step, as a publisher moves a tag.
type movingRelease struct {
catalog.Release
generation int
}
// Credentials is what the spec reads from --fixture-out.
type fixtureCredentials struct {
Users map[string]fixtureUser `json:"users"`
// Packages lists every installable release as "id@version".
Packages []string `json:"packages"`
// Control is the base URL of the fixture library's control routes: POST
// /step moves every moving channel on by one generation, POST /offline and
// /online make the library answer or not. Empty without moving channels.
Control string `json:"control,omitempty"`
}
type fixtureUser struct {
ID string `json:"id"`
Role string `json:"role"`
Token string `json:"token"`
Session string `json:"session"`
}
func newAddonsFixture(dir, executable, dataDir string, localOwner bool) (*addonsFixture, error) {
f := &addonsFixture{dataDir: dataDir, archives: map[string][]byte{}, localOwner: localOwner, step: 1}
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
exe, err := os.ReadFile(executable) // #nosec G304 -- test harness reads files it created in its own work directory
if err != nil {
return nil, fmt.Errorf("fixture executable: %w", err)
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
pkg := filepath.Join(dir, entry.Name())
raw, err := os.ReadFile(filepath.Join(pkg, "manifest.json")) // #nosec G304 -- test harness reads files it created in its own work directory
if err != nil {
continue
}
var manifest map[string]any
if err := json.Unmarshal(raw, &manifest); err != nil {
return nil, fmt.Errorf("%s: %w", entry.Name(), err)
}
// The package is for this machine: the fixture sources are portable.
manifest["os"], manifest["arch"] = runtime.GOOS, runtime.GOARCH
patched, _ := json.Marshal(manifest)
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
add := func(name string, data []byte) error {
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o755, Size: int64(len(data))}); err != nil {
return err
}
_, err := tw.Write(data)
return err
}
if err := add("manifest.json", patched); err != nil {
return nil, err
}
// The executable goes where the manifest says it is. The protocol
// fixture packages all say bin/fixture; a package that names another
// path (the Tables package) gets the supplied executable there.
exePath := "bin/fixture"
if declared, _ := manifest["executable"].(string); declared != "" && !strings.HasPrefix(declared, "/") && !strings.Contains(declared, "..") {
exePath = declared
}
if err := add(exePath, exe); err != nil {
return nil, err
}
var files []string
_ = filepath.WalkDir(pkg, func(p string, d fs.DirEntry, err error) error {
if err == nil && !d.IsDir() {
rel, _ := filepath.Rel(pkg, p)
// The .fixture-* files describe the release to the library and
// are not part of the package.
if rel != "manifest.json" && !strings.HasPrefix(rel, ".fixture-") {
files = append(files, rel)
}
}
return nil
})
sort.Strings(files)
for _, rel := range files {
data, err := os.ReadFile(filepath.Join(pkg, rel)) // #nosec G304 -- test harness reads files it created in its own work directory
if err != nil {
return nil, err
}
if err := add(filepath.ToSlash(rel), data); err != nil {
return nil, err
}
}
if err := tw.Close(); err != nil {
return nil, err
}
id, _ := manifest["id"].(string)
version, _ := manifest["version"].(string)
f.archives[entry.Name()+".tar"] = buf.Bytes()
release := catalog.Release{
ID: id, Name: id, Version: version, OS: runtime.GOOS, Arch: runtime.GOARCH,
Digest: fmt.Sprintf("sha256:%x", sha256.Sum256(buf.Bytes())), Artifact: "/" + entry.Name() + ".tar",
}
if channel, err := os.ReadFile(filepath.Join(pkg, ".fixture-channel")); err == nil { // #nosec G304 -- test harness reads files it created in its own work directory
release.Channel = strings.TrimSpace(string(channel))
}
if raw, err := os.ReadFile(filepath.Join(pkg, ".fixture-requires")); err == nil { // #nosec G304 -- test harness reads files it created in its own work directory
if err := json.Unmarshal(raw, &release.Requires); err != nil {
return nil, fmt.Errorf("%s: .fixture-requires: %w", entry.Name(), err)
}
}
if raw, err := os.ReadFile(filepath.Join(pkg, ".fixture-generation")); err == nil { // #nosec G304 -- test harness reads files it created in its own work directory
n, err := strconv.Atoi(strings.TrimSpace(string(raw)))
if err != nil || n < 1 {
return nil, fmt.Errorf("%s: .fixture-generation must be a positive number", entry.Name())
}
f.moving = append(f.moving, movingRelease{Release: release, generation: n})
continue
}
f.releases = append(f.releases, release)
}
if len(f.releases) == 0 && len(f.moving) == 0 {
return nil, fmt.Errorf("no package under %s", dir)
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, err
}
f.listener = listener
base := "http://" + listener.Addr().String()
for i := range f.releases {
f.releases[i].Artifact = base + f.releases[i].Artifact
}
for i := range f.moving {
f.moving[i].Artifact = base + f.moving[i].Artifact
}
f.library = base + "/library.yaml"
mux := http.NewServeMux()
mux.HandleFunc("/library.yaml", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
offline, listed := f.offline, f.listed()
f.mu.Unlock()
if offline {
http.Error(w, "library offline", http.StatusServiceUnavailable)
return
}
_ = catalog.WriteIndex(w, listed)
})
// The control routes of a spec: move the channels on and take the library
// away. They exist only in this test server.
mux.HandleFunc("/_fixture/step", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
f.step++
n := f.step
f.mu.Unlock()
_ = json.NewEncoder(w).Encode(map[string]int{"step": n})
})
for path, offline := range map[string]bool{"/_fixture/offline": true, "/_fixture/online": false} {
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
f.offline = offline
f.mu.Unlock()
_ = json.NewEncoder(w).Encode(map[string]bool{"offline": offline})
})
}
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
data, ok := f.archives[strings.TrimPrefix(r.URL.Path, "/")]
if !ok {
http.NotFound(w, r)
return
}
_, _ = w.Write(data)
})
f.catalog = &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
go func() { _ = f.catalog.Serve(listener) }()
transport, address, err := fixtureTransport()
if err != nil {
return nil, err
}
f.owner = config.AddonConfig{
Enabled: true, InstanceID: "owner", OwnerID: "owner", OwnerAddress: "https://" + address, StorageID: "disk",
Root: filepath.Join(dataDir, "addons"), ListenAddress: address, Transport: transport,
Host: contract.HostCompatibility{Host: 1, API: 1, Protocol: 1, SDK: 1, DataFormat: 1, OS: runtime.GOOS, Arch: runtime.GOARCH},
// A spec checks again within seconds: the interval of the refresh
// limit is short here and the limit itself stays on.
UpdateRefreshInterval: time.Second,
}
if localOwner {
f.owner.OwnershipMode = auth.AddonModeLocalOwner
}
return f, nil
}
// listed is the library document at the current step: the releases that never
// move and, of each moving add-on and channel, the one the step has reached.
func (f *addonsFixture) listed() []catalog.Release {
out := append([]catalog.Release(nil), f.releases...)
best := map[string]movingRelease{}
for _, m := range f.moving {
if m.generation > f.step {
continue
}
key := m.ID + "/" + m.Channel + "/" + m.Version
if cur, ok := best[key]; !ok || m.generation > cur.generation {
best[key] = m
}
}
keys := make([]string, 0, len(best))
for k := range best {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
out = append(out, best[k].Release)
}
return out
}
// fixtureTransport builds the owner's mTLS carrier identity: one pinned
// certificate and one signing key, as a single-instance deployment has.
func fixtureTransport() (*gateway.CarrierConfig, string, error) {
probe, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, "", err
}
address := probe.Addr().String()
_ = probe.Close()
pub, key, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, "", err
}
ca := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "fixture CA"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(24 * time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign}
der, err := x509.CreateCertificate(rand.Reader, ca, ca, pub, key)
if err != nil {
return nil, "", err
}
if ca, err = x509.ParseCertificate(der); err != nil {
return nil, "", err
}
pool := x509.NewCertPool()
pool.AddCert(ca)
lp, lk, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, "", err
}
leaf := &x509.Certificate{SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: "owner"}, NotBefore: ca.NotBefore, NotAfter: ca.NotAfter, IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}}
ld, err := x509.CreateCertificate(rand.Reader, leaf, ca, lp, key)
if err != nil {
return nil, "", err
}
kd, err := x509.MarshalPKCS8PrivateKey(lk)
if err != nil {
return nil, "", err
}
pair, err := tls.X509KeyPair(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ld}), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: kd}))
if err != nil {
return nil, "", err
}
signPub, signKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, "", err
}
return &gateway.CarrierConfig{
InstanceID: "owner", OwnerID: "owner", OwnerAddress: "https://" + address,
Certificate: pair, Roots: pool,
Pins: map[string][32]byte{"owner": sha256.Sum256(ld)},
SigningKey: signKey,
VerificationKeys: map[string]ed25519.PublicKey{"owner": signPub},
}, address, nil
}
func (f *addonsFixture) options(dataDir string) []config.AppOption {
if f.localOwner {
// One local owner and no accounts: sharing has nobody to share with.
return []config.AppOption{config.WithAddons(f.owner)}
}
return []config.AppOption{
config.WithAuthEnabled(true),
config.WithAuthDatabaseURL(filepath.Join(dataDir, "auth.db")),
config.WithAuthAPIKeyHMACSecret(fixtureSecret),
config.WithAddons(f.owner),
}
}
// seed creates the accounts, saves the library and installs and enables the
// packages named in `enable` ("id@version").
func (f *addonsFixture) seed(ctx context.Context, app *application.Application, out string, enable []string) error {
db := app.AuthDB()
if db == nil && !f.localOwner {
return fmt.Errorf("authentication is not enabled")
}
creds := fixtureCredentials{Users: map[string]fixtureUser{}}
var admin contract.Actor
if f.localOwner {
admin = contract.Actor{UserID: auth.AddonLocalOwnerID, CredentialID: auth.AddonLocalOwnerID, Mode: auth.AddonModeLocalOwner}
}
accounts := []struct {
name, role string
feature bool
}{{"admin", auth.RoleAdmin, true}, {"member", auth.RoleUser, true}, {"outsider", auth.RoleUser, false},
// Two more people with the feature, for the roles of a shared database.
{"editor", auth.RoleUser, true}, {"viewer", auth.RoleUser, true}}
if f.localOwner {
accounts = nil
}
for _, u := range accounts {
user := auth.User{ID: "fixture-" + u.name, Email: u.name + "@fixture.test", Name: u.name, Provider: auth.ProviderLocal, Role: u.role, Status: auth.StatusActive}
if err := db.Create(&user).Error; err != nil {
return err
}
if u.feature && u.role != auth.RoleAdmin {
if err := db.Create(&auth.UserPermission{ID: uuid.NewString(), UserID: user.ID, Permissions: auth.PermissionMap{auth.FeatureAddons: true}}).Error; err != nil {
return err
}
}
token, key, err := auth.CreateAPIKey(db, user.ID, "fixture", u.role, fixtureSecret, nil)
if err != nil {
return err
}
session, err := auth.CreateSession(db, user.ID, fixtureSecret)
if err != nil {
return err
}
creds.Users[u.name] = fixtureUser{ID: user.ID, Role: u.role, Token: token, Session: session}
if u.name == "admin" {
admin = contract.Actor{UserID: user.ID, CredentialID: "key:" + key.ID, Mode: auth.AddonModeNamedUser}
}
}
sources, manager := app.AddonSources(), app.AddonManager()
if sources == nil || manager == nil {
return fmt.Errorf("the add-on owner did not start")
}
if err := sources.Save(ctx, admin, catalog.Source{ID: "fixture", Name: "Fixture", URL: f.library, Enabled: true, AllowLoopbackHTTP: true}); err != nil {
return err
}
if _, err := sources.Refresh(ctx, "fixture"); err != nil {
return err
}
for _, r := range f.listed() {
creds.Packages = append(creds.Packages, r.ID+"@"+r.Version)
}
if len(f.moving) > 0 {
creds.Control = "http://" + f.listener.Addr().String() + "/_fixture"
}
for i, spec := range enable {
id, version, _ := strings.Cut(spec, "@")
if _, err := manager.Submit(ctx, admin, addonsvc.LifecycleCommand{OperationID: fmt.Sprintf("seed-install-%d", i), AddonID: id, Action: "install", SourceID: "fixture", Version: version}); err != nil {
return fmt.Errorf("install %s: %w", spec, err)
}
if _, err := manager.Submit(ctx, admin, addonsvc.LifecycleCommand{OperationID: fmt.Sprintf("seed-enable-%d", i), AddonID: id, Action: "enable", Generation: 1}); err != nil {
return fmt.Errorf("enable %s: %w", spec, err)
}
}
// Wait for every enabled process to be observed running, so a spec starts
// from a settled owner.
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) {
list, err := manager.Apps(ctx, admin)
if err == nil {
running := 0
for _, a := range list {
if a.State == "running" {
running++
}
}
if running >= len(enable) {
break
}
}
time.Sleep(200 * time.Millisecond)
}
b, err := json.MarshalIndent(creds, "", " ")
if err != nil {
return err
}
return os.WriteFile(out, b, 0o600)
}
func (f *addonsFixture) close() {
if f.catalog != nil {
_ = f.catalog.Close()
}
}
+84 -12
View File
@@ -15,6 +15,7 @@ import (
"github.com/mudler/LocalAI/core/config"
httpapi "github.com/mudler/LocalAI/core/http"
"github.com/mudler/LocalAI/pkg/system"
"github.com/mudler/LocalAI/tests/addons/foundation/cluster"
"github.com/mudler/xlog"
"gopkg.in/yaml.v3"
)
@@ -37,6 +38,17 @@ func main() {
// the routing E2E to turn mock-model into a smart-router that
// dispatches to extra-models.
routerYAML := flag.String("router-yaml", "", "optional router: block to merge into mock-model.yaml")
// addonsFixture turns on authentication and a real add-on owner and
// installs the packages of that directory (see addons_fixture.go).
addonsFixtureDir := flag.String("addons-fixture", "", "directory of add-on UI fixture packages; enables auth and a real add-on owner")
addonExecutable := flag.String("addon-executable", "", "protocol fixture executable placed in every fixture package")
fixtureOut := flag.String("fixture-out", "", "file the seeded credentials are written to as JSON")
localOwner := flag.Bool("addons-local-owner", false, "run the add-ons fixture without authentication, in local-owner mode (no named users, no sharing)")
fixtureEnable := flag.String("fixture-enable", "", "comma separated id@version packages to install and enable at startup")
// foundation runs this server as one instance of the foundation
// acceptance cluster (see tests/addons/foundation/cluster).
foundationPath := flag.String("foundation", "", "cluster.json of the add-ons foundation fixture; enables authentication and the add-on carrier")
foundationRole := flag.String("foundation-role", "", "instance of the foundation cluster: owner, front-a or front-b")
flag.Parse()
if *mockBackend == "" {
@@ -55,20 +67,36 @@ func main() {
os.Exit(1)
}
// Create temp dirs
tmpDir, err := os.MkdirTemp("", "ui-e2e-*")
if err != nil {
fmt.Fprintf(os.Stderr, "error creating temp dir: %v\n", err)
os.Exit(1)
// Create temp dirs. A foundation instance keeps its state in the cluster
// directory, which survives a restart of the process.
var found *cluster.Config
var tmpDir string
if *foundationPath != "" {
found, err = cluster.Load(*foundationPath)
if err != nil || found.Nodes[*foundationRole].Dir == "" {
fmt.Fprintf(os.Stderr, "error loading the foundation cluster for %q: %v\n", *foundationRole, err)
os.Exit(1)
}
tmpDir = found.StateDir(*foundationRole)
if err := os.MkdirAll(tmpDir, 0o750); err != nil {
fmt.Fprintf(os.Stderr, "error creating the foundation state dir: %v\n", err)
os.Exit(1)
}
} else {
tmpDir, err = os.MkdirTemp("", "ui-e2e-*")
if err != nil {
fmt.Fprintf(os.Stderr, "error creating temp dir: %v\n", err)
os.Exit(1)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
}
defer os.RemoveAll(tmpDir)
modelsPath := filepath.Join(tmpDir, "models")
backendsPath := filepath.Join(tmpDir, "backends")
generatedDir := filepath.Join(tmpDir, "generated")
dataDir := filepath.Join(tmpDir, "data")
for _, d := range []string{modelsPath, backendsPath, generatedDir, dataDir} {
if err := os.MkdirAll(d, 0755); err != nil {
if err := os.MkdirAll(d, 0750); err != nil {
fmt.Fprintf(os.Stderr, "error creating dir %s: %v\n", d, err)
os.Exit(1)
}
@@ -94,7 +122,7 @@ func main() {
if *routerYAML != "" {
body = append(body, []byte("router:\n "+*routerYAML+"\n")...)
}
if err := os.WriteFile(filepath.Join(modelsPath, "mock-model.yaml"), body, 0644); err != nil {
if err := os.WriteFile(filepath.Join(modelsPath, "mock-model.yaml"), body, 0600); err != nil {
fmt.Fprintf(os.Stderr, "error writing config: %v\n", err)
os.Exit(1)
}
@@ -107,7 +135,7 @@ func main() {
parts := strings.SplitN(*extraModelFlag, "|", 2)
if len(parts) == 2 {
extraPath := filepath.Join(modelsPath, parts[0]+".yaml")
if err := os.WriteFile(extraPath, []byte(parts[1]), 0644); err != nil {
if err := os.WriteFile(extraPath, []byte(parts[1]), 0600); err != nil {
fmt.Fprintf(os.Stderr, "error writing extra model: %v\n", err)
os.Exit(1)
}
@@ -128,7 +156,7 @@ func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
app, err := application.New(
appOptions := []config.AppOption{
config.WithContext(ctx),
config.WithSystemState(systemState),
config.WithDebug(true),
@@ -137,11 +165,55 @@ func main() {
config.WithGeneratedContentDir(generatedDir),
config.EnableTracing,
config.EnableBackendLogging,
)
}
var fixture *addonsFixture
if *addonsFixtureDir != "" {
fixture, err = newAddonsFixture(*addonsFixtureDir, *addonExecutable, dataDir, *localOwner)
if err != nil {
fmt.Fprintf(os.Stderr, "error preparing the add-ons fixture: %v\n", err)
os.Exit(1)
}
defer fixture.close()
appOptions = append(appOptions, fixture.options(dataDir)...)
}
if found != nil {
opts, err := found.AppOptions(*foundationRole)
if err != nil {
fmt.Fprintf(os.Stderr, "error preparing the foundation cluster: %v\n", err)
os.Exit(1)
}
appOptions = append(appOptions, opts...)
}
app, err := application.New(appOptions...)
if err != nil {
fmt.Fprintf(os.Stderr, "error creating application: %v\n", err)
os.Exit(1)
}
if fixture != nil {
var enable []string
for _, spec := range strings.Split(*fixtureEnable, ",") {
if spec = strings.TrimSpace(spec); spec != "" {
enable = append(enable, spec)
}
}
if err := fixture.seed(ctx, app, *fixtureOut, enable); err != nil {
fmt.Fprintf(os.Stderr, "error seeding the add-ons fixture: %v\n", err)
os.Exit(1)
}
}
if found != nil && *foundationRole == cluster.Owner {
if err := found.Seed(ctx, app); err != nil {
fmt.Fprintf(os.Stderr, "error seeding the foundation cluster: %v\n", err)
os.Exit(1)
}
control, err := found.ControlServer(app)
if err != nil {
fmt.Fprintf(os.Stderr, "error starting the foundation control server: %v\n", err)
os.Exit(1)
}
defer func() { _ = control.Close() }()
}
// Register mock backend
app.ModelLoader().SetExternalBackend("mock-backend", absBackend)
@@ -171,5 +243,5 @@ func main() {
fmt.Println("\nShutting down...")
cancel()
e.Close()
_ = e.Close()
}
+75 -1
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"encoding/base64"
"encoding/binary"
"encoding/json"
"flag"
@@ -11,6 +12,7 @@ import (
"net"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
@@ -75,6 +77,59 @@ func checkModelIdentity(in interface{ GetModelIdentity() string }) error {
return grpcerrors.ModelMismatch("mock-backend", opts.Model, in.GetModelIdentity())
}
// addonToolCall reads a scripted add-on tool call from the prompt. A spec puts
// ADDON_TOOL_CALL:<tool name>:<base64url JSON arguments> in the user message;
// the model then answers its first turn with that call, as a model that chose
// the tool would. The arguments are exactly what the spec encoded, so a spec
// can send valid, forged or malformed ones. The second turn, after the host
// put the tool result into the prompt, is plain text.
var addonToolMarker = regexp.MustCompile(`ADDON_TOOL_CALL:([A-Za-z0-9_-]+):([A-Za-z0-9_=-]+)`)
func addonToolCall(prompt string) (name, arguments string, ok bool) {
m := addonToolMarker.FindStringSubmatch(prompt)
if m == nil {
return "", "", false
}
raw, err := base64.URLEncoding.DecodeString(m[2])
if err != nil {
raw, err = base64.RawURLEncoding.DecodeString(m[2])
}
if err != nil {
return "", "", false
}
return m[1], string(raw), true
}
// promptHasAddonFile reports whether the host answered the scripted call with
// private file references (an export).
func promptHasAddonFile(prompt string) bool {
return strings.Contains(prompt, `"kind":"artifact"`) || strings.Contains(prompt, `"kind": "artifact"`)
}
// addonAnswer is the plain text the scripted model gives after the host
// answered its call: a file result is ready to download, anything else is a
// change that waits for the person.
func addonAnswer(prompt string) string {
// A call the add-on refused as invalid ends in an error result with the
// code invalid_request: nothing was changed and the model says so.
if strings.Contains(prompt, `"code":"invalid_request"`) || strings.Contains(prompt, `"code": "invalid_request"`) {
return "The add-on refused the request as invalid."
}
if promptHasAddonFile(prompt) {
return "The file is ready to download."
}
return "The change is waiting for your confirmation."
}
// promptHasAddonResult reports whether the host already answered the scripted
// call: the broker's result documents carry a kind (a proposal, a result or an
// error such as select_resource), and a refusal is prefixed.
func promptHasAddonResult(prompt string) bool {
return strings.Contains(prompt, `"kind":"proposal"`) || strings.Contains(prompt, `"kind":"result"`) || strings.Contains(prompt, `"kind":"error"`) ||
strings.Contains(prompt, `"kind": "proposal"`) || strings.Contains(prompt, `"kind": "result"`) || strings.Contains(prompt, `"kind": "error"`) || strings.Contains(prompt, "Error: ") ||
promptHasAddonFile(prompt)
}
// promptHasToolResults checks if the prompt contains evidence of prior tool
// execution — specifically the output from the mock MCP server's get_weather tool.
func promptHasToolResults(prompt string) bool {
@@ -251,7 +306,13 @@ func (m *MockBackend) Predict(ctx context.Context, in *pb.PredictOptions) (*pb.R
}
var response string
toolName := mockToolNameFromRequest(in)
if toolName != "" && !promptHasToolResults(in.Prompt) {
if name, arguments, ok := addonToolCall(in.Prompt); ok && toolName != "" {
if promptHasAddonResult(in.Prompt) {
response = addonAnswer(in.Prompt)
} else {
response = fmt.Sprintf(`{"name": %q, "arguments": %s}`, name, arguments)
}
} else if toolName != "" && !promptHasToolResults(in.Prompt) {
// First call with tools: return a tool call so the MCP loop executes it.
response = fmt.Sprintf(`{"name": "%s", "arguments": {"location": "San Francisco"}}`, toolName)
} else if toolName != "" {
@@ -403,8 +464,18 @@ func (m *MockBackend) PredictStream(in *pb.PredictOptions, stream pb.Backend_Pre
}
var toStream string
wholeChunk := false
toolName := mockToolNameFromRequest(in)
addonName, addonArgs, addonCall := addonToolCall(in.Prompt)
switch {
case addonCall && toolName != "" && promptHasAddonResult(in.Prompt):
toStream = addonAnswer(in.Prompt)
case addonCall && toolName != "":
// One chunk: the chat handler emits a tool call as soon as the name
// is complete, so a call streamed byte by byte would reach it with
// empty arguments, which no real backend does.
toStream = fmt.Sprintf(`{"name": %q, "arguments": %s}`, addonName, addonArgs)
wholeChunk = true
case toolName != "" && !promptHasToolResults(in.Prompt):
toStream = fmt.Sprintf(`{"name": "%s", "arguments": {"location": "San Francisco"}}`, toolName)
case toolName != "":
@@ -418,6 +489,9 @@ func (m *MockBackend) PredictStream(in *pb.PredictOptions, stream pb.Backend_Pre
default:
toStream = "This is a mocked streaming response."
}
if wholeChunk {
return stream.Send(&pb.Reply{Message: []byte(toStream), Tokens: int32(len(toStream))}) // #nosec G115 -- the length of a short fixed string
}
for i, r := range toStream {
if err := stream.Send(&pb.Reply{
Message: []byte(string(r)),