Zum Inhalt

Aspire-AppHost

Coworkee.Aspire macht aus dem AppHost wenige Zeilen. Du nennst die App; Coworkee findet ihre Dienste, ergänzt die Infrastruktur, die jeder braucht, und die Einstellungen, die sie verbinden.

MyApp.AppHost/AppHost.cs
var builder = DistributedApplication.CreateBuilder(args);

builder.AddCoworkeeApp("myapp", options =>
{
    options.DisplayName = "MyApp";
    options.LogoUrl = "/coworkee-icon.svg";
    options.UseKeycloak(keycloak => keycloak.Users.Add(new KeycloakUser("info@coworkee.de", "Administrator", "MyApp") { Password = "Dev-Passw0rd!" }));
}).AddProjects();

builder.Build().Run();

LogoUrl ist das Logo der Anmeldeseiten, solange das Theme keines hat: Ein Pfad wird auf der Web-App genommen ({web}/coworkee-icon.svg), eine absolute Adresse so, wie sie ist.

options.SignIn setzt Layout, Medium und Seite des Mediums der Anmeldeseiten (Coworkee:Auth:Design des Auth-Servers); ein Pfad zum Medium wird wie LogoUrl auf der Web-App aufgelöst:

options.SignIn.Layout = SignInLayouts.Split;
options.SignIn.MediaUrl = "/media/welcome.mp4";
options.SignIn.MediaSide = SignInMediaSide.Right;

Sharemee ergänzt danach einen Worker, dessen Name keiner Konvention folgt:

var app = builder.AddCoworkeeApp("shareme", ...).AddProjects();
app.AddWorker<Projects.Shareme_Processing_Host>("processing");

Projekte nach Konvention

AddProjects() schaut sich die Projects.*-Typen an, die Aspire für die referenzierten Projekte des AppHosts erzeugt, und fügt sie nach Namen in dieser Reihenfolge hinzu:

Typname Hinzugefügt mit Ressource
*_Migrations AddMigrations {name}-migrations
*_Auth AddAuthServer {name}-auth
*_Api AddApi {name}-api
*_Worker*, *Worker AddWorker {name}-worker
*_Web AddWeb {name}-web
*_AirWorks AddAirWorks {name}-airworks
*_ElsaStudio AddElsaStudio (in einem Deployment nur, solange das Tool elsastudio an ist) {name}-elsastudio

Das Suffix ist der Teil nach dem ersten Unterstrich, kleingeschrieben mit Bindestrichen: Aus MyApp_Jobs_Worker wird myapp-jobs-worker. Andere Projekte bleiben unberührt.

builder.AddCoworkeeApp("myapp", options =>
{
    options.Configure("api", api => api.WithReplicas(2)); // nach Suffix, auch für explizit hinzugefügte Dienste
    options.Skip("web", "MyApp_Admin_Api");                // nach Suffix oder Typname
}).AddProjects();

Explizite Aufrufe funktionieren weiter und haben Vorrang: Ein Projekt, das vor AddProjects() hinzugefügt wurde, kommt nicht doppelt hinzu. Soll ein gefundenes Projekt eine andere Rolle oder ein anderes Suffix bekommen, überspringst du es und fügst es danach selbst hinzu.

Rollen

Methode Ressource Verdrahtung
AddMigrations<T>() {name}-migrations Datenbank, wartet auf den Datenbankserver, die referenzierten Module wie jeder Dienst (Blob-Speicher für geseedete Dateien, ...), Dashboard-Befehle
AddAuthServer<T>() {name}-auth externe Endpunkte, kein Job-Server, öffentliche Auth-URL, Entwicklungszertifikate in Development, Keycloak-Anbieter
AddApi<T>() {name}-api Token-Authority und Audience {name}_api, API-Scope am Auth-Server, Setup-Token (lokal: dasselbe für alle Dienste, in Konsole und Dashboard sichtbar, solange das System nicht eingerichtet ist, siehe Setup-Assistent)
AddWorker<T>(suffix) {name}-{suffix} nur Infrastruktur, keine öffentlichen Endpunkte
AddWeb<T>() {name}-web BFF-Einstellungen, OIDC-Client am Auth-Server mit Redirect-URIs, Referenz auf die API, Links zu Swagger und zum Job-Dashboard
AddAirWorks<T>() {name}-airworks wie AddWeb: BFF-Einstellungen, der nächste OIDC-Client am Auth-Server, ein eigener Name des Sitzungs-Cookies; für den AirWorks-Client
AddElsaStudio<T>() {name}-elsastudio der nächste OIDC-Client am Auth-Server, ein eigenes Sitzungscookie, die Elsa-API der ersten API, Coworkee:Workflows:Elsa:StudioUrl der APIs; für den Workflow-Designer

Ruf sie in dieser Reihenfolge auf (AddProjects() tut das). AddWeb vor AddAuthServer wirft eine Exception, die genau das sagt.

Infrastruktur aus den Paketen

Bevor Coworkee einen Dienst verdrahtet, liest es dessen obj/project.assets.json und schaut, welche Pakete und Projekte er nutzt, direkt oder über andere Projekte:

Paket im Dienst Er bekommt
Coworkee.Infrastructure Connection-String der App-Datenbank und Coworkee:Database:Provider, wartet, bis die Migrationen fertig sind
Coworkee.Realtime Redis für die SignalR-Backplane, Anwesenheit, Cache-Invalidierung und Sitzungsänderungen zwischen Instanzen (Skalieren mit Redis); options.WithoutRedis() lässt es weg
Coworkee.Mailing Mailpit als Standard-SMTP-Server
Coworkee.Storage den gemeinsamen Blob-Ordner .data/blobs (nur lokal; veröffentlichte Container behalten ihren Standard)
Coworkee.Search.Elasticsearch Elasticsearch
Coworkee.Notifications Links in Zusammenfassungsmails zeigen auf die Web-App

Grafana (mit Prometheus, Loki, Tempo und Collector) und WebDataStudio kommen mit options.UseGrafana() und options.UseWebDataStudio() dazu, siehe Grafana und WebDataStudio. Kibana kommt mit options.UseKibana(), siehe Kibana.

Container entstehen nur, wenn ein Dienst sie braucht. Postgres, SQL Server, Redis, Elasticsearch und Keycloak halten ihre Daten in Volumes; --Coworkee:EphemeralInfrastructure=true startet sie leer, das nutzen die Tests.

Kibana

options.UseKibana() stellt Kibana neben den Elasticsearch-Container (nicht bei UseSearch: ein eigenes Elasticsearch bringt sein eigenes Kibana mit). Kibanas Single Sign-on (OpenID Connect, SAML) braucht eine kostenpflichtige Elastic-Lizenz, deshalb setzt der AppHost oauth2-proxy davor:

  • Die Ressource kibana ist der Proxy und die Adresse, die du öffnest. Er meldet dich am Auth-Server der App an (Client <app>-kibana); ist Keycloak an, schickt der Auth-Server dich wie bei der App zu Keycloak weiter.
  • Der Auth-Server lässt nur Administratoren der System-Organisation durch (AuthClientOptions.SystemAdministratorsOnly). Alle anderen bekommen access_denied.
  • kibana-server ist Kibana selbst. Es lässt jede Anfrage als Elasticsearch-Benutzer elastic herein (sein Anonymous-Provider, Teil der freien Lizenz), deshalb darf nur der Proxy es erreichen: Das Dashboard zeigt keinen Link darauf, ein Deployment veröffentlicht keinen Port davon (ein lokaler Lauf behält seinen Port auf der Loopback-Adresse). kibana-setup setzt das Passwort des eingebauten Benutzers kibana_system, sobald Elasticsearch antwortet.
  • Kibana nimmt die Image-Version des Elasticsearch-Containers. Seine gespeicherten Objekte liegen in Elasticsearch und damit in dessen Volume.

Ohne Auth-Server behält Kibana sein eigenes Login: Benutzer elastic mit dem Passwort von Elasticsearch (Parameter elasticsearch-password). coworkee new --elasticsearch --kibana (Template-Option kibana, standardmäßig aus) fügt den Aufruf hinzu.

Eigene Module

app.Modules enthält diese Verdrahtung nach Paket- oder Projektname. App-Module melden ihren Bedarf genauso an; jeder Dienst, der das Projekt direkt oder transitiv referenziert, bekommt ihn:

var app = builder.AddCoworkeeApp("myapp");
var stripe = builder.AddConnectionString("stripe");
app.Modules.Add("MyApp.Billing", (app, service) => service.WithReference(stripe));
app.AddProjects();

Registriere Module, bevor du die Dienste hinzufügst. app.Modules[CoworkeeModules.Search] = ... ersetzt eine eingebaute Verdrahtung, app.Modules.Remove(...) entfernt sie.

Eigene Infrastruktur

Jedes Teil lässt sich konfigurieren, ersetzen oder abschalten:

builder.AddCoworkeeApp("myapp", options =>
{
    options.ConfigurePostgres(postgres => postgres.WithPgAdmin())
        .ConfigureRedis(redis => redis.WithRedisInsight())
        .ConfigureMail(mail => mail.WithLifetime(ContainerLifetime.Persistent))
        .ConfigureSearch(search => search.WithEnvironment("ES_JAVA_OPTS", "-Xmx1g"))
        .ConfigureKeycloak(keycloak => keycloak.WithLifetime(ContainerLifetime.Persistent));

    // Dienste mit Coworkee.Search.Elasticsearch bekommen kein Elasticsearch
    options.Without(CoworkeeModules.Search);
}).AddProjects();
Option Statt
UseDatabase(resource) des Datenbank-Containers; jede Ressource mit Connection-String, z. B. builder.AddConnectionString("myapp")
UseSqlServer(configure) von Postgres: ein SQL-Server-Container (app.SqlServer), oder mit UseDatabase ein eigener SQL Server
UseSqlite(path) von Postgres: eine SQLite-Datei, ohne Angabe .data/<name>.db neben src; kein Container
UseRedis(resource) des Redis-Containers
UseSearch(resource) des Elasticsearch-Containers
UseMail(endpoint) von Mailpit; der SMTP-Endpunkt einer anderen Ressource
UseKeycloak(resource, ...) des Keycloak-Containers; dein Keycloak bekommt den Realm

UseSqlServer und UseSqlite setzen bei den Diensten auch Coworkee:Database:Provider; die Migrationen der App müssen für diesen Provider erstellt sein (siehe Datenbank-Provider).

Die Dienste bekommen eigene Ressourcen unter den Namen, die sie erwarten (ConnectionStrings:myapp, redis, elasticsearch). Coworkee wartet nicht auf sie; ergänze bei Bedarf WaitFor über options.Configure.

// Produktion: der Connection-String kommt aus der Konfiguration (ConnectionStrings:myapp) oder wird beim Deployment abgefragt
var database = builder.AddConnectionString("myapp");
builder.AddCoworkeeApp("myapp", options => options.UseDatabase(database)).AddProjects();

Dashboard

Läuft der AppHost lokal in Development, hat die Migrations-Ressource zwei Befehle:

  • Re-run migrations startet das Migrationsprojekt erneut: neue Migrationen und das Seeding der Demodaten.
  • Reset database löscht nach einer Rückfrage die App-Datenbank und führt die Migrationen erneut aus. Den Befehl gibt es nur für den eingebauten Postgres-Container, nie für SQL Server, SQLite oder eine eigene Datenbank und nie außerhalb von Development.

Die Web-Ressource verlinkt Swagger (/swagger) und, wenn eine API Coworkee.BackgroundJobs nutzt, das Hangfire-Dashboard (Jobs, /admin/jobs). Mailpit zeigt seinen Posteingang als Mailpit UI.

Dienstadressen

Jeder Dienst der App bekommt die Adressen der anderen unter Coworkee:Services:<Ressource>: das Aspire-Dashboard (dashboard), das Hangfire-Dashboard (jobs), jedes Projekt (myapp-api, myapp-auth, myapp-web, mit HealthPath /health) und jeden Container mit http-Endpunkt (mail, pgadmin, keycloak, ...), auch Ressourcen, die nach AddCoworkeeApp hinzukommen. Injiziert werden sie als IOptionsMonitor<CoworkeeServicesOptions>; die Admin-Seite Dienste zeigt sie mit ihrem aktuellen Zustand (siehe Einstellungen). Ein Deployment trägt dort die öffentlichen Adressen seiner Tools ein; weitere kommen in appsettings.

Ein AspireUI im AppHost bringt die Apps und Stacks, die du selbst damit hostest, auf dieselbe Seite. builder.AddAspireUI().WithRetrieveApi() genügt: Der AppHost gibt die Adresse (und den Schlüssel aus WithRetrieveApi(...): Ein Parameter bleibt eine Referenz, nie ein aufgelöster String; ein Klartext-Schlüssel wird als Text weitergegeben) als Coworkee:AspireUI:Url und ApiKey an jeden Dienst weiter. Das erste AspireUI mit Retrieve-API wird verwendet; eines ohne wird ignoriert, und eine veröffentlichte App bekommt nichts (Coworkee:AspireUI dann in appsettings setzen). Dienste finden sie über IServiceDirectory (siehe Einstellungen).

Typisierte Einstellungen

Einstellungen werden über den Konfigurationsbaum gesetzt, nicht über Zeichenketten:

api.WithSetting(s => s.Coworkee.Storage.Provider, "S3")
   .WithSetting(s => s.Coworkee.Settings.Defaults["Mail.Smtp.Host"], "smtp.example.com")
   .WithSettings(s => s.Coworkee.Bff.ForwardedPrefixes, "/admin/jobs", "/hubs");

Aus s => s.Coworkee.Bff.Scopes[0] wird die Umgebungsvariable Coworkee__Bff__Scopes__0. Werte können Zeichenketten, Zahlen, Wahrheitswerte, Endpunkt-Referenzen, Referenzausdrücke oder Parameter sein. CoworkeeSettings spiegelt die echten Options-Klassen aus Coworkee.Contracts.Configuration; eine umbenannte Eigenschaft bricht also den Build des AppHosts, nicht die laufende App.

Keycloak

UseKeycloak fügt einen Keycloak-Container mit einem Realm hinzu, der wie die App heißt:

  • ein vertraulicher Client {name}-auth, dessen Secret ein generierter Parameter ist ({name}-keycloak-client-secret),
  • die angegebenen Benutzer, jeder mit seinem Password; Benutzer ohne teilen sich ein generiertes Passwort ({name}-keycloak-user-password, im Dashboard sichtbar und in den User-Secrets des AppHosts gespeichert).

Der Realm wird bei jedem Start auf den aktuellen Stand gebracht, sobald Keycloak bereit ist und bevor der Auth-Server startet: Ein fehlender Realm wird angelegt, ein vorhandener (im Daten-Volume) bekommt über die Admin-API die aktuellen Client-Secrets, Redirect-Adressen, Benutzer und Passwörter. Ein geänderter Secret-Parameter oder ein geändertes Passwort gilt also ab dem nächsten Start, und Benutzer oder Clients, die du in der Keycloak-Konsole angelegt hast, bleiben erhalten. Das Ressourcen-Log von Keycloak meldet Realm myapp is up to date: clients ..., users ....

Der Auth-Server bekommt die Anbieter-Einstellungen und bietet Sign in with Keycloak an. LoginMode schaltet zwischen beidem, nur extern und nur intern um und ist der Standard der Einstellung Anmeldemethoden (Auth.LoginMode), die Administratoren zur Laufzeit ändern (siehe Anmelderegeln); Port legt den Host-Port fest, wenn du eine feste URL willst.

Die Api-Hosts liefern die Einstellungen, also bekommen auch sie Coworkee:Auth:External:Mode und den Anzeigenamen des Anbieters, damit die Einstellung dort definiert ist; das Client-Secret und die übrigen Anbieter-Einstellungen bleiben beim Auth-Server.

Browser schicken die Cookies von localhost an jeden Port, also erreichen die Cookies aller lokalen Apps auch Keycloak. Über HTTPS spricht Keycloak HTTP/2 und nimmt dabei standardmäßig nur 8 KB Request-Header an; darüber antwortet es mit 431 Request Header Fields Too Large. Der Container bekommt deshalb QUARKUS_HTTP_LIMITS_MAX_HEADER_LIST_SIZE=65536, die 64 KB, die er über HTTP/1.1 annimmt. Zeigt der Browser trotzdem 431, lösch die localhost-Cookies anderer Apps; Auth-Server und Web-App (Kestrel) nehmen 32 KB an.

Anmeldeseite im Design der App

Die Anmeldeseite von Keycloak sieht aus wie die App: die Farben des Standard-Themes (hell und dunkel), sein Logo (oder LogoUrl), der DisplayName der App über dem Formular sowie Layout und Medien aus SignIn (Karte, geteilt oder Slide). Coworkee schreibt dafür ein Login-Theme coworkee-{name}, ein Kind des Keycloak-Themes keycloak.v2 mit einem Stylesheet, einem kleinen Skript und der Titel-Meldung, ohne eigene Templates, damit es auch mit neueren Keycloak-Versionen funktioniert.

  • Der AppHost mountet src/MyApp.AppHost/.coworkee/keycloak-themes als /opt/keycloak/themes in den Keycloak-Container (die .gitignore des Templates lässt .coworkee/ aus) und schreibt das Theme beim ersten Start dorthin (ein nicht beschreibbarer Ordner wird als Warnung geloggt und hält den Realm nicht auf), mit den Ocean-Farben und den Optionen aus AddCoworkeeApp; Pfade wie /logo.svg oder /media/welcome.mp4 werden zu Adressen der Web-App.
  • Der Realm-Abgleich setzt bei jedem Start loginTheme, displayName und displayNameHtml des Realms, für einen neuen Realm und für einen aus dem Daten-Volume.
  • Die Api-Hosts bekommen Coworkee:Keycloak:ThemeDirectory, ThemeName, AppName, LogoUrl und BaseUrl (die Web-App) und schreiben das Theme neu: beim Start, eine Sekunde nachdem eine Theme-Änderung gespeichert ist (Theme speichern, anderes Theme zum Standard machen) oder ein Administrator das Design der Anmeldeseite speichert, und mit Nach Keycloak übernehmen (siehe Theming).
  • start-dev cacht keine Themes, eine Änderung erscheint also beim nächsten Seitenaufruf.

Abschalten mit options.UseKeycloak(k => k.LoginTheme = false): kein Mount, kein Theme, der Realm behält sein Theme (fehlt der Ordner, nimmt Keycloak sein eigenes). Ein eigener Keycloak (UseKeycloak(resource, ...)) bekommt den Anzeigenamen des Realms, aber kein Theme; mounte den Ordner selbst, wenn du es willst.

Weitere Clients und die Benutzer

Andere Ressourcen des AppHosts können sich über denselben Realm anmelden und seine Benutzer wiederverwenden, zum Beispiel ein Dashboard mit OpenID Connect:

var app = builder.AddCoworkeeApp("myapp", options => options.UseKeycloak(k => k.Users.AddRange(users))).AddProjects();
var grafana = builder.AddContainer("grafana", "grafana/grafana").WithHttpEndpoint(targetPort: 3000);
var client = app.AddKeycloakClient("myapp-grafana", [ReferenceExpression.Create($"{grafana.GetEndpoint("http")}/login/generic_oauth")]);
grafana.WithEnvironment("GF_AUTH_GENERIC_OAUTH_CLIENT_ID", client.ClientId)
    .WithEnvironment("GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET", client.Secret)
    .WithEnvironment("GF_AUTH_GENERIC_OAUTH_AUTH_URL", ReferenceExpression.Create($"{app.KeycloakAuthority}/protocol/openid-connect/auth"));
Member von CoworkeeApp
AddKeycloakClient(clientId, redirectUris?, secret?) ein weiterer vertraulicher Client des Realms; ohne Secret ein generiertes ({clientId}-secret), ohne Redirect-Adressen jede lokale Adresse
KeycloakClients die Clients des Realms, der des Auth-Servers zuerst
KeycloakAuthority Issuer-Adresse des Realms, {keycloak}/realms/{realm}
KeycloakUsers, KeycloakPassword(user) die Benutzer und das Passwort jedes Benutzers als Reference Expression, etwa für den lokalen Login einer anderen Ressource

Ruf sie nach UseKeycloak und AddAuthServer (oder AddProjects) auf.

AspireUI

AspireUI meldet sich mit einem Aufruf am Auth-Server der App an; die Administratoren der Systemorganisation werden AspireUI-Admins:

using Nextended.Aspire.Hosting.AspireUI;

var app = builder.AddCoworkeeApp("myapp").AddProjects();
builder.AddAspireUI().WithCoworkeeSignOn(app);

Der Aufruf registriert den Client {app}-{resource} am Auth-Server mit der Redirect-Adresse /api/auth/sso/callback und setzt den Claim role als Gruppen. Ist Keycloak an, schickt der Auth-Server weiter zu Keycloak wie bei der App. AspireUI übernimmt Einstellungen nur, solange sie leer sind; deshalb erzwingt der Aufruf die Einstellungen des App-Hosts (ASPIREUI_SET_FORCE), sonst behielte ein Daten-Volume aus einem früheren Client die alte Anmeldung.

Tools melden sich am Auth-Server an

Grafana, WebDataStudio, AspireUI, Open WebUI und der Proxy von Kibana melden sich am Auth-Server der App an, mit oder ohne Keycloak. Jedes bekommt einen vertraulichen Client {app}-{tool}, der nicht im Launcher steht und nur die Administratoren der Systemorganisation hereinlässt (SystemAdministratorsOnly); sie sind Admins des Tools. Die lokale Administrator-Anmeldung jedes Tools bleibt. Solange der App-Host läuft, nennt sich der Auth-Server nach der Adresse des Browsers (Coworkee:Auth:Issuer) und gibt Containern mit Coworkee:Auth:DynamicBackchannel Token, Benutzerinfo und Schlüssel unter der Adresse, unter der sie gefragt haben: Die Container erreichen ihn über das Container-Netz, der Browser über den Host. Ein Deployment nutzt für beides die öffentliche Adresse.

Open WebUI

Open WebUI ist ein Chat im Browser über die Modelle eines Ollama-Containers:

var ollama = builder.AddOllama("ollama");
app.AddOpenWebUI(ollama);

Der Administrator der App meldet sich lokal an (WEBUI_ADMIN_EMAIL, über das Formular registriert sich niemand), und mit dem Auth-Server melden sich die Administratoren der Systemorganisation darüber an, als Admins von Open WebUI (Client {app}-openwebui, PKCE). Die Einstellungen gehören dem App-Host (ENABLE_PERSISTENT_CONFIG=false): Sonst behielte Open WebUI die Adressen seines ersten Starts in seiner Datenbank. Daten im Volume {app}-openwebui-data. coworkee new --ai-provider ollama --open-web-ui (Template-Option openWebUI) fügt den Aufruf ein; coworkee deploy --without openwebui lässt es im Deployment weg.

AspireUI in einer generierten App

coworkee new --aspire-ui (Template-Option aspireUI, standardmäßig aus) fügt dieselben Zeilen in src/MyApp.AppHost/AppHost.cs ein, dazu den Paketverweis Nextended.Aspire.Hosting.AspireUI im AppHost und einen Abschnitt in LOGIN.md: Der Administrator meldet sich lokal mit E-Mail und Passwort der App an oder am Auth-Server der App (WithCoworkee(app)). Das Studio zeigt die Option neben Grafana und WebDataStudio; die Vorlage Everything schaltet sie ein.

builder.AddAspireUI()
    .WithAdminUser(administrator.Email, administrator.Password)
    .WithSeedFromDirectory(builder.AppHostDirectory)
    .WithCoworkeeSignOn(app)
    .WithRetrieveApi();

Veröffentlichen

coworkee deploy compose|azure|k8s veröffentlicht den AppHost für Docker Compose, Azure Container Apps oder Kubernetes: öffentliche Adressen, Token-Zertifikate, der erste Administrator und der Mailserver als Parameter, keine Demo-Benutzer, und die Tools des AppHosts (Keycloak, Grafana, WebDataStudio, LibreTranslate, Ollama, AspireUI) kommen mit, außer du schaltest sie mit DeployTool oder coworkee deploy --without ab. PublishToDockerCompose(configure) konfiguriert weiterhin die Compose-Umgebung (compose => compose.WithDashboard(false)); eine Umgebung, die der AppHost vor AddCoworkeeApp selbst hinzufügt, ersetzt die von Coworkee:Deploy:Target. Alles Weitere, auch was geprüft ist, steht unter Deployment.