Zum Inhalt springen

Backend und Sidecar

Der ausführbare Teil eines Pakets läuft nie im Prozess der Anwendung wie eine Bibliothek. Er läuft in einer eigenen Laufzeit, bekommt seine Bytes vom Host gereicht und erreicht die Außenwelt nur über eine vermittelte Oberfläche.

Zwei Arten, ein Feld im Manifest:

// in-process: ein JavaScript-Modul in einer abgeschotteten Laufzeit
"backend": { "entry": "backend.js" }
// Sidecar: ein eigener Prozess, eigene Sprache, eigene Bibliotheken
"backend": {
"entry": "runtime/main.py",
"runtime": "sidecar",
"requires": { "language": "python", "minVersion": "3.11" }
}

Nehmen Sie in-process, solange nichts dagegen spricht. Es ist kleiner, startet schneller und läuft in einer echten Abschottung.

Nehmen Sie einen Sidecar, wenn Sie eine Verbindung offen halten müssen oder wenn die Bibliothek, die Sie brauchen, in JavaScript nicht existiert. Genau das sind industrielle Konnektoren: ein Socket, der offen bleibt und meldet, wenn etwas ankommt — keine Funktion, die eine Frage beantwortet.

Der Preis eines Sidecars ist ehrlich und zweimal lesenswert: es ist ein echter Prozess auf der Maschine des Betreibers. Der Host startet ihn, beaufsichtigt ihn und beendet ihn — er schottet ihn nicht ab.


Ihr Einstiegsmodul exportiert eine Funktion, die die vermittelte Host-Oberfläche bekommt und ein Objekt mit routes und handle zurückgibt.

export default (host) => {
async function query(sql, params) {
const res = await host.request({ method: 'POST', path: 'data/query', body: { sql, params } });
return res && res.body && Array.isArray(res.body.rows) ? res.body.rows : [];
}
return {
routes: [
{ method: 'GET', path: '/reports' },
{ method: 'POST', path: '/reports' }
],
handle: async (env) => {
const segments = String(env.path || '').split('/').filter((s) => s.length > 0);
const caller = env.caller;
// DAS TOR. Serverseitig, vor der Routenwahl. Ein fehlender caller heisst
// "niemand hat etwas nachgewiesen" und wird abgelehnt, nie als vertrauenswuerdig
// behandelt.
const roles = caller && Array.isArray(caller.roles) ? caller.roles : [];
if (env.method !== 'GET' && !roles.includes('tenant-admin')) {
return { status: 403, body: { error: 'not_allowed_to_write' } };
}
if (segments[0] === 'reports' && env.method === 'GET') {
return { status: 200, body: { rows: await query('SELECT * FROM plugin_pkg.acme_reports') } };
}
return { status: 404, body: { error: 'no_such_route' } };
}
};
};

Zwei Regeln, die daraus folgen:

  • routes wird beim Start gemeldet. Der Host kennt die Routentabelle, bevor die erste Anfrage kommt. Was nicht deklariert ist, wird nicht zugestellt.
  • handle muss immer antworten. Ein unbehandelter Wurf wird zu einem 500, ohne den Worker zu töten — aber Ihr Aufrufer wartet dann bis zur Zeitgrenze. Fangen Sie und geben Sie einen Code zurück; die Meldung bleibt intern.

Erreichbar ist Ihre Route unter /api/plugins/<pluginId>/<pfad>.

Was handle bekommt, und was es zurückgeben muss:

// hinein
{ method, path, headers?, body?, caller? }
// heraus
{ status, headers?, body? }

env.caller wird vom Host aus der Sitzung gestempelt: { appUserId, roles, kind }. Ein caller-Schlüssel in env.body ist Text des Aufrufers und bleibt dort — er kann nie an diese Stelle geraten. Ein Backend, das auf dem Rückkanal selbst einen caller behauptet, wird mit caller_not_settable abgewiesen.

caller ist optional: ein Hintergrundaufruf hat keinen. Lesen Sie „kein caller” als „niemand hat etwas nachgewiesen” — fail-closed, nie als vertrauenswürdig.

host.request({ method, path, body }) ist der einzige Weg nach draußen. Die Pfade sind eine geschlossene Menge:

Pfad Wofür Braucht
data/query Eine parametrisierte Abfrage, mandantengebunden data:read / data:write
data/batch Mehrere Anweisungen atomar innerhalb eines Aufrufs wie oben
audit Eine Audit-Zeile. Der Akteur wird host-seitig gesetzt, nie von Ihnen —
events Ein Echtzeit-Ereignis über den vermittelten Bus —
log Eine Protokollzeile in das zentrale Protokoll —
net/request Eine ausgehende HTTP(S)-Anfrage, vom Host vermittelt net:request
capability/invoke Eine Fähigkeit des Hosts aufrufen, z. B. tag.write capability:invoke plus das Recht der Fähigkeit
host/resolve Ein fachlicher Name → eine Kern-Kennung (tag, recordType, field, role) —
host/describe Die Gegenrichtung: eine Kennung, die Ihnen schon gehört → ihr Name —

Für die letzten beiden gibt es Zucker, der bei einem Fehlschlag wirft, statt eine Nicht-200-Antwort zurückzugeben:

const tagId = await host.resolve('tag', 'Press1.PartCount');
const tagPath = await host.describe('tag', tagId);

host/describe ist kein Stöberloch: eine Kennung muss Ihnen bereits gehören — in Ihrer eigenen Zeile stehen —, um beschrieben zu werden. Beschreiben ist nie der Weg, an eine Kennung zu kommen, die Sie nicht schon hatten.

Was data/query erreichen darf, entscheidet nicht ein Textfilter über Ihr SQL, sondern die Rolle, unter der es läuft: sie kann den mandantengebundenen Bereich nicht verlassen. Es gibt deshalb ausdrücklich keine Verbotsliste für Anweisungen.

Grenze Wert
Start des Backends 10 s
Ein Aufruf 30 s
Auslaufen beim Wechsel 30 s
Neustartversuche nach Absturz 3, mit 1 s / 5 s / 30 s Wartezeit

Ein Sidecar ist ein eigener Prozess. Der Host startet ihn, prüft vorher den Interpreter und redet über drei Kanäle mit ihm:

fd Richtung Inhalt
0 (stdin) Host → Sidecar gerahmte Frames
1 (stdout) Sidecar → Host gerahmte Frames
2 (stderr) Sidecar → Host freier Text, zeilenweise, nie gerahmt

stderr bleibt ungerahmt, weil ein Traceback beim Start entsteht, bevor die Rahmung steht. Der Host protokolliert jede Zeile als Log Ihres Plugins auf Stufe warn.

Ihr Sidecar darf stdout für nichts anderes benutzen. Ein print() zerstört den Strom. Leiten Sie sys.stdout beim Start auf sys.stderr um und behalten Sie den echten Deskriptor für sich.

+--------+------------------------+
| 4 Byte | N Byte |
| Länge | UTF-8-JSON |
+--------+------------------------+

Länge ist big endian, vorzeichenlos, und zählt sich selbst nicht mit. Obergrenze 8 MiB je Frame. UTF-8 wird streng dekodiert — ein verstümmeltes Byte darf nicht still zu U+FFFD werden, sonst landet es als heiler Wert in einem Tag.

Fehlernamen, damit beide Seiten dasselbe protokollieren: frame_too_large, invalid_frame_length (Länge 0), incomplete_frame, invalid_frame_json, unknown_frame.

Nach dem ersten Rahmungsfehler ist der Leser vergiftet: er richtet sich nie neu aus, sondern wirft bei jedem weiteren Byte denselben Fehler. Ein verrutschter Strom lässt sich nicht wieder einfangen; jeder Versuch wäre Raten.

Host Sidecar
|-- spawn(python, entry) ---------->|
|-- load -------------------------> |
| | Digest über den Baum
| <------------------------ ready -|
|-- request (POST /connections) --> |
| <--------------------- response -| Socket geht auf
| <------------------------ event -| Telegramm empfangen
| <------------------------- host -| capability/invoke tag.write
|-- host-response ----------------> |
|-- shutdown ---------------------> |

Der erste Frame ist load und nennt rootPath, entry, archiveDigest und die Grenzen. rootPath zeigt auf das bereits entpackte und geprüfte Paket — Ihr Sidecar entpackt nichts und prüft keine Signatur.

Ihre Antwort ist ready mit routes, archiveDigest und runtimeVersion. Den Digest rechnen Sie selbst über den Baum, und der Host vergleicht ihn mit dem aus load. Ein Unterschied ist entry_digest_mismatch, und der Prozess wird beendet.

So wird der Baum-Digest gebildet, damit beide Seiten dasselbe rechnen: alle Dateien relativ zu rootPath, Pfade mit /, sortiert nach ihren UTF-8-Bytes; je Datei sha256(pfad-als-utf8) || sha256(inhalt) als rohe 32 Byte aneinandergehängt; über das Ganze nochmals sha256. Verzeichnisse zählen nicht. Das Ergebnis trägt das Präfix sha256:.

  • request / response — über correlationId gepaart, envelope ist derselbe Umschlag wie oben.
  • host / host-response — Ihr Rückkanal. Dieselben Operationen wie beim in-process-Backend, plus capability/invoke.
  • event — Sidecar → Host, unaufgefordert, ohne correlationId. Das ist der Frame, wegen dem es diese Bauart gibt: ein gehaltener Socket meldet, wenn etwas ankommt, nicht wenn jemand fragt.
  • shutdown — die höfliche Bitte. Durchgesetzt wird sie durch Schließen von stdin, danach Gnadenfrist, danach Töten.

Kein Entwurfsdetail, sondern eine Zwangsläufigkeit: ein host-Aufruf wartet auf einen Frame, den nur die Leseschleife lesen kann. Wer aus der Leseschleife heraus den Host ruft, verklemmt sich selbst.

Also: die Leseschleife liest nur. Eingehende request-Frames gehen an einen Arbeiter-Faden, host-response wird in der Leseschleife aufgelöst. Ein host-Aufruf aus dem Lese-Faden heraus wird verweigert — mit einem Fehler, nicht mit einer Verklemmung.

Es gibt kein net/listen, net/connect oder net/send. Ein host-vermittelter Socket sähe sicherer aus, wäre es aber nicht: ein Python-Prozess hat ambiente Netzrechte und könnte die Vermittlung ohnehin umgehen. Wir bauten eine Durchsetzung, die nichts durchsetzt, und zahlten jedes empfangene Byte zweimal über die Rahmung.

Also die Trennung: Daten (der Socket) hält der Sidecar selbst; Steuerung (Tags, Logs, Audit, Datenbank) ist host-vermittelt, denn dort ist die Durchsetzung echt — der Sidecar hat keinen anderen Weg dorthin.

net:socket im Manifest ist damit eine Angabe, kein Käfig: sie wird geprüft, protokolliert und auditiert, und ein Paket ohne sie wird gar nicht erst als Konnektor gestartet. Sie hält keinen bösartigen Sidecar auf. Das kann erst eine Grenze auf Betriebssystemebene, und die gibt es hier nicht.

  • Keine Datenbankverbindung und keine Zugangsdaten dorthin.
  • Keine Umgebungsvariablen außer der Erlaubnisliste.
  • Keine offenen Dateideskriptoren außer 0, 1, 2.
  • Keinen Schreibzugriff außerhalb des eigenen Arbeitsverzeichnisses.

Ein Konnektor-Plugin ist ein Paket mit einer connector-Contribution und einem Sidecar-Backend. Nur der Typ ist ein Formular ohne Wirkung; nur der Sidecar ist ein Prozess ohne Konfiguration.

Es bedient fünf Routen, mehr nicht:

Route Was sie tut
POST /connections Verbindung anlegen oder ändern. { id, settings }
GET /connections Was der Sidecar gerade wirklich hält
GET /connections/:id Zustand einer Verbindung; unbekannt → 404
DELETE /connections/:id Schließen und vergessen. Zweimal ist kein Fehler
POST /connections/:id/send Hinausschicken. { payload } → { sent: n }

Erneut mit derselben id aufgerufen heißt ÄNDERN, nicht ein zweites Mal öffnen. Der Host ruft das nach jeder Änderung und nach jedem Sidecar-Neustart. Ein Sidecar, das dabei einen zweiten Socket öffnet, hält am Ende so viele offen, wie der Betreiber gespeichert hat.

sent: 0 ist ein FEHLER, kein Erfolg. Wer nach außen schreibt, bekommt eine Quittung. Ein Server ohne verbundene Gegenstelle hat nichts gesendet, und ein Tag, das daraufhin „geschrieben” anzeigt, lügt den Betreiber an.

Und es sendet zwei Ereignisse:

{ "kind": "event", "type": "connector/received",
"payload": { "connectionId": "c1", "payload": "OK", "bytes": 2,
"terminator": "delimiter", "peer": "10.0.0.5:51234" } }

terminator ist delimiter | silence | eof. Bei silence wurde das Telegramm nicht durch ein Steuerzeichen beendet, sondern weil es still wurde — ein Schnitt an möglicherweise falscher Stelle, sichtbar nur, wenn er mitgeteilt wird.

{ "kind": "event", "type": "connector/state",
"payload": { "connectionId": "c1", "connected": true, "lastError": "",
"rxCount": 17, "txCount": 3 } }

Die Schlüssel dieses Payloads sind die Kanalnamen. Der Host schreibt jeden in das Tag, das diesen channel gewählt hat, und kennt die Namen nicht selbst. Ein Konnektor mit einem fünften Zustandswert deklariert ihn in seinem channel-Feld, sendet ihn hier, und der Host trägt ihn ein — ohne eine Zeile Kerncode.

Recht Wofür
net:socket Der gehaltene Socket. Ohne das ist es kein Konnektor.
capability:invoke Öffnet den Rückkanal.
data:write Liegt dahinter und wird leicht vergessen: tag.write hängt unter diesem Recht. Fehlt es, endet jedes empfangene Telegramm als permission_denied statt als Tag-Wert.

data:read nur, wenn Ihr Konnektor wirklich Tags liest.

Beim Schreiben in ein Tag setzen Sie inbound: true, wenn der Wert von der Gegenstelle kam:

{ "op": "capability/invoke",
"payload": { "name": "tag.write",
"input": { "path": "Line1.Rx", "value": "OK", "inbound": true } } }

Ohne dieses Feld schickt ein zweiseitiges Tag das gerade empfangene Telegramm postwendend wieder hinaus. Es ist Herkunft, keine Befugnis: die einzige Wirkung ist, den ausgehenden Versand zu überspringen. Alle Tore bleiben, wo sie waren. Den Mandanten setzt der Host aus dem Aufrufkontext, nie aus Ihrer Nutzlast.

  • Er hält den Sidecar am Leben und startet ihn nach einem Absturz neu.
  • Er ruft nach jedem Start POST /connections für jede gespeicherte Verbindung.
  • Er macht aus connector/received und connector/state Tag-Schreibvorgänge — unter dem Akteur machine, mit Audit-Eintrag.
  • Er macht aus einem Schreibvorgang auf ein Ausgangs-Tag ein POST /connections/:id/send.
  • Er lehnt einen Schreibvorgang auf ein Zustands-Tag ab. connected ist ein Messwert, kein Schalter.
  • Ein Sidecar je Paket. Mehrere Prozesse aus einem Paket sind nicht verboten, nur nicht bedacht.
  • Ein Konnektor-Typ je Paket. Ebenso.
  • Kein Anfrage/Antwort-Paar auf dem Socket. Senden und auf die Antwort warten gibt es als Route nicht; mit zwei Tags und einem Skript-Auslöser ist es nachbaubar.
  • Keine mitgebrachten Abhängigkeiten. Der Interpreter ist eine Voraussetzung der Installation, kein Paketinhalt.
  • Kein Durchsatzversprechen. Bei hoher Telegrammrate ist die Rahmung des Kanals die erste Stelle, die weh tut: jedes Telegramm ist ein Frame.