Run the pod provider from images with make start, and fix what a fresh stack exposed

- docker-compose-dev.yml: the redis host port is overridable (REDIS_PORT in the root
  .env, 6379 by default as in the sibling apps), and the pod provider, in host network
  mode, follows it for its queue and OIDC redis. All of ./data/fuseki is mounted at the
  image's SEMAPPS_FUSEKI_BASE, so account deletion removes the backups and, 24 h later,
  the secure dataset's files (it answered 500 without the mount).
- backend: GET /app served the app actor snapshot the mixin takes before it creates or
  replaces the access-need group, so a dataset's first boot lacked the link until a
  restart; the app service refreshes the snapshot from the store at the end of started().
- e2e smoke: the post-install poll sends Accept: application/ld+json; the pod answers 500
  to a container read in any other format.
- Docs: cookbook (Redis port, mounts, hot reload, dataset-named accounts, fresh-boot
  note), deployment note, env examples; claims about a parallel race are no longer stated
  as fact.
This commit is contained in:
Sylvain Duchesne
2026-11-02 21:46:37 +01:00
parent 5e3e821e2a
commit 223059c629
9 changed files with 156 additions and 28 deletions
+1
View File
@@ -16,5 +16,6 @@ SEMAPPS_MAIN_DATASET=festipod
SEMAPPS_AUTH_ACCOUNTS_DATASET_NAME=settings-festipod
# Redis (format: redis://localhost:6379/0) — no cache locally; queue db 15 (the sibling apps use 12, 13, 14)
# The port is the one `make start` publishes: REDIS_PORT of the root .env when set, else 6379.
SEMAPPS_REDIS_CACHE_URL=
SEMAPPS_QUEUE_SERVICE_URL=redis://localhost:6379/15
+26 -18
View File
@@ -71,8 +71,8 @@ module.exports = {
},
actions: {
// Overrides the mixin's `get` (which returns `this.appActor` for every request) to
// answer the Solid-OIDC Client ID Document fetch as well. `this.appActor` is set by the
// mixin's `started()`, i.e. long before any request is served.
// answer the Solid-OIDC Client ID Document fetch as well. `this.appActor` is the mixin's
// snapshot of the actor, refreshed by this service's own started() (see there).
get: {
async handler(ctx) {
// moleculer-web exposes the raw request only when the route alias sets
@@ -84,11 +84,9 @@ module.exports = {
const isOidcClientIdDocument =
jsonLdContextKey !== undefined && String(headers[jsonLdContextKey]).includes('solid/oidc-context.jsonld');
// `this.appActor` is set at the very END of the mixin's started(). During boot,
// this action is already routable (the api gateway is up before the services
// finish starting) and `@activitypods/app`'s own `awaitCreateComplete` polls the
// actor over HTTP through this very route — waiting on an action that waits on the
// boot it blocks. Read the actor straight from the triplestore in that window.
// Moleculer registers this action only once every started() handler of the service has
// finished, by which time the mixin has set `this.appActor`: reading the actor from the
// triplestore when it is unset is a guard, not a boot path.
let actor = this.appActor;
if (!actor) {
try {
@@ -124,22 +122,22 @@ module.exports = {
},
async started() {
// Heal the actor ↔ access-need-group link. `@activitypods/app` attaches the group to the
// actor in an after.post hook of the group's creation, but the hook races the actor's own
// creation on a cold start (both run inside the broker's parallel started() phase): the
// group then exists while the actor never gets `interop:hasAccessNeedGroup`, and both the
// pod frontend (consent screen) and the pod backend (requirements discovery) read the
// needs from that link — an empty consent registers the app with zero grants and the
// register flow loops. The heal itself can ALSO lose the race: it may run before the
// mixin has created the group (observed 2026-10-26 on a fresh dataset — the one-shot
// heal found no group and silently did nothing), so it polls for the group instead of
// actor in an after.post hook of the group's creation. On a fresh dataset (2026-10-26)
// consents registered zero grants and a one-shot version of this heal found no group; the
// cause was not established — the mixin's started() creates the actor, then the group,
// before this handler runs. Both the pod frontend (consent screen)
// and the pod backend (requirements discovery) read the needs from that link — an empty
// consent registers the app with zero grants and the register flow loops — so the link
// is checked in the store and attached when missing, polling for the group rather than
// checking once. Idempotent: only patches when the link is missing.
const actorUri = urlJoin(CONFIG.HOME_URL, 'as/actor/app');
const actorHasLink = async () => {
const actor = await this.broker.call('ldp.resource.get', {
const readActor = () =>
this.broker.call('ldp.resource.get', {
resourceUri: actorUri,
accept: 'application/ld+json',
webId: 'system'
});
const linksGroup = actor => {
const link = actor && actor['interop:hasAccessNeedGroup'];
return Array.isArray(link) ? link.length > 0 : Boolean(link);
};
@@ -158,7 +156,7 @@ module.exports = {
const deadline = Date.now() + 60000;
for (;;) {
try {
if (await actorHasLink()) break;
if (linksGroup(await readActor())) break;
const group = await findGroup();
if (group) {
await this.broker.call('actors.attachAccessNeedGroup', {
@@ -176,5 +174,15 @@ module.exports = {
}
await new Promise(r => setTimeout(r, 2000));
}
// `get` serves `this.appActor`, the snapshot the mixin's started() takes BEFORE it creates
// or replaces the group: on a dataset's first boot that snapshot lacks the link, and on a
// boot that changes the access needs it names the group just deleted. `GET /app` kept
// that until the next restart (observed 2026-11-02), so refresh it from the store.
try {
const actor = await readActor();
if (linksGroup(actor)) this.appActor = actor;
} catch (e) {
this.logger.warn(`Could not refresh the app actor snapshot: ${e.message}`);
}
}
};
+96
View File
@@ -0,0 +1,96 @@
const { test, expect, describe, afterEach, setSystemTime } = require('bun:test');
const app = require('./app.service');
const ACTOR = 'http://localhost:3005/as/actor/app';
const GROUP = 'http://localhost:3005/interop/access-need-group/g1';
const actor = (extra = {}) => ({ id: ACTOR, type: ['Application', 'interop:Application'], ...extra });
const linked = () => actor({ 'interop:hasAccessNeedGroup': GROUP });
/**
* The app service as moleculer runs its own started(), after the mixin's: `snapshot` is the
* `this.appActor` the mixin left, `store` the actor the triplestore holds, `groups` what the
* group listing answers. Attaching a group writes the link into the store, as the mixin does.
* `failReadsFrom` makes the n-th actor read (1-based) and every later one throw.
*/
function service({ snapshot, store, groups = [], failReadsFrom = Infinity }) {
const calls = [];
const logs = { info: [], warn: [], error: [] };
let reads = 0;
const svc = {
appActor: snapshot,
logger: { info: m => logs.info.push(m), warn: m => logs.warn.push(m), error: m => logs.error.push(m) },
broker: {
call: async (action, params) => {
calls.push(action);
if (action === 'ldp.resource.get') {
reads += 1;
if (reads >= failReadsFrom) throw new Error('triplestore down');
return store;
}
if (action === 'access-needs-groups.list') return { 'ldp:contains': groups };
if (action === 'actors.attachAccessNeedGroup') {
store = { ...store, 'interop:hasAccessNeedGroup': params.accessNeedGroupUri };
return undefined;
}
throw new Error(`unexpected call ${action}`);
}
}
};
return { svc, calls, logs };
}
describe('app actor snapshot — GET /app serves the access-need group once the store links it', () => {
// The deadline test moves the clock; reset it even when a test times out mid-way.
afterEach(() => setSystemTime());
test("a dataset's first boot: the snapshot predates the link the store holds, so it is refreshed", async () => {
const { svc, calls } = service({ snapshot: actor(), store: linked() });
await app.started.call(svc);
expect(svc.appActor['interop:hasAccessNeedGroup']).toBe(GROUP);
expect(calls).not.toContain('actors.attachAccessNeedGroup');
});
test('the cold-start race: the store lacks the link too, the heal attaches the group, then the snapshot follows', async () => {
const { svc, calls, logs } = service({ snapshot: actor(), store: actor(), groups: [{ id: GROUP }] });
await app.started.call(svc);
expect(calls).toContain('actors.attachAccessNeedGroup');
expect(logs.info.some(m => m.includes(GROUP))).toBe(true);
expect(svc.appActor['interop:hasAccessNeedGroup']).toBe(GROUP);
});
test('a later boot: the snapshot already carries the link and keeps it', async () => {
const { svc } = service({ snapshot: linked(), store: linked() });
await app.started.call(svc);
expect(svc.appActor['interop:hasAccessNeedGroup']).toBe(GROUP);
});
test('a boot that changes the access needs: the snapshot names the deleted group, the store the new one', async () => {
const { svc } = service({ snapshot: actor({ 'interop:hasAccessNeedGroup': `${GROUP}-old` }), store: linked() });
await app.started.call(svc);
expect(svc.appActor['interop:hasAccessNeedGroup']).toBe(GROUP);
});
test('no group ever appears: the heal gives up and the snapshot is not replaced by an unlinked actor', async () => {
const snapshot = actor();
const { svc, logs } = service({ snapshot, store: actor({ name: 'from the store' }) });
// The group listing answers empty; jump the clock past the heal's 60 s deadline at that
// point so the loop gives up without sleeping.
const call = svc.broker.call;
svc.broker.call = async (action, params) => {
if (action === 'access-needs-groups.list') setSystemTime(new Date(Date.now() + 61000));
return call(action, params);
};
await app.started.call(svc);
expect(logs.error.some(m => m.includes('No access-need group appeared'))).toBe(true);
expect(svc.appActor).toBe(snapshot);
});
test('a failed refresh read leaves the snapshot as it was and says so', async () => {
const snapshot = actor();
const { svc, logs } = service({ snapshot, store: linked(), failReadsFrom: 2 });
await app.started.call(svc);
expect(svc.appActor).toBe(snapshot);
expect(logs.warn.some(m => m.includes('refresh the app actor snapshot'))).toBe(true);
});
});