Altinn / Altinn/app-lib-dotnet

Analyse: Kjøre flere Altinn-apper i samme .NET-prosess

Open
#1,844 1 comment 0 reactions 0 assignees View on GitHub
status/triage
Dominant language
C#
Stars
8
Forks
27
Avg merge
1h 21m
Merged PRs (30d)
7

Description

# Analyse: Kjøre flere Altinn-apper i samme .NET-prosess

## Sammendrag

I dag er Altinn.App-runtime bygget rundt antakelsen **én .NET-prosess/pod = én Altinn-app**. Det gir god prosess- og ressursisolasjon, men er kostbart for apper med svært lav trafikk.

Denne issuen analyserer hva som må til for å støtte flere digitale tjenester i samme .NET-prosess, der URL-en (`/{org}/{app}/...`) velger riktig:

- appimplementasjon av Altinn-interfaces
- konfigurasjon og secrets
- `applicationmetadata.json`, BPMN, UI, tekster og øvrige ressurser
- datamodellassembly og serializer/runtime-state
- app-spesifikke singleton- og scoped services

**Anbefalt hovedretning:** Én felles ASP.NET/Kestrel-host, men én separat `AppRuntime` med egen DI-container, konfigurasjon, ressurser og singleton-livsløp per digital tjeneste/config-sett.

Dette er gjennomførbart, men det er en ny hostingmodell og ikke bare en endring i controller-routing.

## Motivasjon

- Mange Altinn-apper brukes sjelden, men krever likevel en egen pod.
- En pool av lavtrafikkapper kan redusere tomgangsressurser og operasjonell overhead.
- Samme kodeimplementasjon bør kunne gjenbrukes med flere sett av konfigurasjons- og ressursfiler.
- Eksisterende URL-er og controllerkontrakter bør i størst mulig grad beholdes.

## Funn i dagens kodebase

Analysen er gjort mot commit [`87f40f47`](https://github.com/Altinn/app-lib-dotnet/tree/87f40f47b0ead723ca15e094b9b32aaf8f46620f).

### 1. App-id er prosessglobal

App-id leses direkte fra én hardkodet fil, `config/applicationmetadata.json`, både i API-laget og ved DI-registrering:

- [`StartupHelper.GetApplicationId`](https://github.com/Altinn/app-lib-dotnet/blob/87f40f47b0ead723ca15e094b9b32aaf8f46620f/src/Altinn.App.Api/Helpers/StartupHelper.cs#L43-L49)
- [`AddApplicationIdentifier`](https://github.com/Altinn/app-lib-dotnet/blob/87f40f47b0ead723ca15e094b9b32aaf8f46620f/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs#L142-L162)

App-id brukes deretter ved konfigurasjon av statiske filer, Swagger og telemetri. Dette gjør at én prosess bare kan representere én app.

### 2. URL-strukturen er allerede egnet

De fleste controllerne har allerede `{org}/{app}` som de første routesegmentene, for eksempel:

- [`InstancesController`](https://github.com/Altinn/app-lib-dotnet/blob/87f40f47b0ead723ca15e094b9b32aaf8f46620f/src/Altinn.App.Api/Controllers/InstancesController.cs#L49-L55)
- [`DataController`](https://github.com/Altinn/app-lib-dotnet/blob/87f40f47b0ead723ca15e094b9b32aaf8f46620f/src/Altinn.App.Api/Controllers/DataController.cs#L34-L40)
- [`ProcessController`](https://github.com/Altinn/app-lib-dotnet/blob/87f40f47b0ead723ca15e094b9b32aaf8f46620f/src/Altinn.App.Api/Controllers/ProcessController.cs#L21-L27)

Det betyr at appvalg kan gjøres tidlig i request-pipelinen uten å endre hoveddelen av API-kontrakten.

### 3. DI og options antar én app

`AppSettings`, `GeneralSettings`, `PlatformSettings`, `CacheSettings` og andre options bindes til default options-navn i én global service collection:

- [`AddPlatformServices`](https://github.com/Altinn/app-lib-dotnet/blob/87f40f47b0ead723ca15e094b9b32aaf8f46620f/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs#L94-L109)

I denne revisjonen finnes det omtrent:

- 112 forekomster av `IOptions`/`IOptionsMonitor`
- fordelt på 77 filer i Core og API

Flere tjenester leser og lagrer `.Value` i konstruktøren. En ambient app-id alene er derfor ikke nok; tjenesten må enten være app-lokal eller hente config kontekstuelt ved bruk.

### 4. Flere app-spesifikke tjenester er singletons

Blant annet følgende er singleton per prosess i dag:

- `IAppResources`
- `IAppMetadata`
- `IFrontendFeatures`
- `ITranslationService`
- `ModelSerializationService`
- `IProcessReader`
- `Telemetry`
- `IAppConfigurationCache`
- `ScopeAuthorizationService`

Se blant annet [`AddAppServices`](https://github.com/Altinn/app-lib-dotnet/blob/87f40f47b0ead723ca15e094b9b32aaf8f46620f/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs#L169-L210) og [`AddProcessServices`](https://github.com/Altinn/app-lib-dotnet/blob/87f40f47b0ead723ca15e094b9b32aaf8f46620f/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs#L375-L395).

Disse må bli singleton **per app-runtime**, ikke per hostprosess.

### 5. AppImplementationFactory er et godt utgangspunkt

Det finnes 36 interfaces merket `ImplementableByApps`, og `AppImplementationFactory` brukes til sent oppslag av appimplementasjoner. Factoryen foretrekker requestens service provider dersom en `HttpContext` finnes:

- [`AppImplementationFactory`](https://github.com/Altinn/app-lib-dotnet/blob/87f40f47b0ead723ca15e094b9b32aaf8f46620f/src/Altinn.App.Core/AppImplementationFactory.cs#L21-L53)

Dette er et godt fundament. I en multi-app-host må factoryen imidlertid aldri falle tilbake til en tvetydig global provider. Den må resolve fra valgt app-runtime eller feile tydelig når appkontekst mangler.

### 6. Ressurser leses fra ett filsystemområde

Det finnes omtrent 57 direkte `File.*`/`Directory.*`-operasjoner fordelt på 11 filer. Mesteparten ligger i:

- [`AppResourcesSI`](https://github.com/Altinn/app-lib-dotnet/blob/87f40f47b0ead723ca15e094b9b32aaf8f46620f/src/Altinn.App.Core/Implementation/AppResourcesSI.cs)
- [`AppMetadata`](https://github.com/Altinn/app-lib-dotnet/blob/87f40f47b0ead723ca15e094b9b32aaf8f46620f/src/Altinn.App.Core/Internal/App/AppMetadata.cs)
- [`ProcessClient`](https://github.com/Altinn/app-lib-dotnet/blob/87f40f47b0ead723ca15e094b9b32aaf8f46620f/src/Altinn.App.Core/Infrastructure/Clients/Storage/ProcessClient.cs#L50-L76)
- [`AppOptionsFileHandler`](https://github.com/Altinn/app-lib-dotnet/blob/87f40f47b0ead723ca15e094b9b32aaf8f46620f/src/Altinn.App.Core/Features/Options/AppOptionsFileHandler.cs#L26-L46)

På kort sikt kan hver app-runtime få en absolutt `AppBasePath`. På lengre sikt bør dette skjules bak en app-lokal `IFileProvider`/`IAppContent`.

### 7. Datamodeller kommer fra entry assembly

`DefaultAppModel` bruker `Assembly.GetEntryAssembly()` for å finne modelltyper:

- [`DefaultAppModel.GetModelType`](https://github.com/Altinn/app-lib-dotnet/blob/87f40f47b0ead723ca15e094b9b32aaf8f46620f/src/Altinn.App.Core/Internal/AppModel/DefaultAppModel.cs#L28-L36)

I en delt host må modellresolveren i stedet få appens modul-/modellassembly eksplisitt.

### 8. Hosted services og cacher mangler appkontekst

`AppConfigurationCache` og `ScopeAuthorizationService` initialiseres én gang for én app:

- [`AppConfigurationCache`](https://github.com/Altinn/app-lib-dotnet/blob/87f40f47b0ead723ca15e094b9b32aaf8f46620f/src/Altinn.App.Core/Features/Cache/AppConfigurationCache.cs#L11-L29)
- [`ScopeAuthorizationService`](https://github.com/Altinn/app-lib-dotnet/blob/87f40f47b0ead723ca15e094b9b32aaf8f46620f/src/Altinn.App.Api/Infrastructure/Middleware/ScopeAuthorizationMiddleware.cs#L143-L189)

Andre hosted services, for eksempel eFormidling-startup, injiserer én `AppIdentifier`.

Cache keys må også gjennomgås. Maskinporten-cachen bruker i dag variant og scopes, men ikke app-id eller `ClientId`:

- [`MaskinportenClient.GetCacheKey`](https://github.com/Altinn/app-lib-dotnet/blob/87f40f47b0ead723ca15e094b9b32aaf8f46620f/src/Altinn.App.Core/Features/Maskinporten/MaskinportenClient.cs#L377-L390)

En delt cache kan derfor ikke brukes uendret mellom apper med forskjellige credentials.

## Foreslått målmodell

```text
HTTP request /{org}/{app}/...
|
v
AppRuntimeResolver
|
org/app -> AppRuntime
|
v
app execution context + app DI-scope
|
auth/authorization/controller
|
appens config, filer, modeller og
interfaceimplementasjoner
```

En `AppRuntime` bør minst inneholde:

```csharp
public sealed record AppRuntimeDescriptor(
AppIdentifier PublicAppId,
string ModuleId,
string ModuleVersion,
string ConfigSetId,
IConfigurationRoot Configuration,
IFileProvider ContentProvider,
Assembly ModelAssembly,
IServiceProvider Services
);
```

Registeret over aktive runtimes bør være immutable og validert før hosten rapporteres ready.

## Hvorfor separat service provider per app

En mulig løsning er keyed services i én felles DI-container. Det fungerer for et begrenset sett kjente interfaces, men appene kan også registrere vilkårlige egne tjenester og injisere blant annet `IConfiguration`, `IOptions`, egne interfaces og egne `HttpClient`-registreringer.

Keyed DI propagerer ikke automatisk gjeldende appnøkkel gjennom slike dependency graphs. Det ville krevd omfattende endringer i appkode og registreringsmønster.

Med en separat service provider per app-runtime kan eksisterende appkode i stor grad fortsette å bruke vanlig DI:

```csharp
services.AddTransient();
services.Configure(configuration.GetSection("MySettings"));
```

Registreringene havner da i appens provider. Utvalgte host-tjenester, eksempelvis logging og host lifetime, kan deles eksplisitt.

Eksisterende registrering bør splittes i:

- `AddAltinnAppHostServices`: Kestrel, MVC discovery/routing, host health, host lifetime og telemetry exporters.
- `AddAltinnAppRuntimeServices`: config/options, platformklienter, metadata, ressurser, modeller, prosess, appimplementasjoner og app-cacher.
- Dagens `AddAltinnAppServices` beholdes som kompatibilitetsmetode for standalone-hosting.

## Request-flyt

En middleware bør plasseres etter routing og før app-sensitive middleware/controlleraktivering:

1. Les og normaliser `{org}` og `{app}`.
2. Slå opp eksakt runtime i `IAppRuntimeRegistry`.
3. Returner `404` for ukjent app og `503` for kjent app som ikke er ready.
4. Opprett et scope fra appens service provider.
5. Etabler `IAppExecutionContext`.
6. Kjør auth, authorization, filtre og controller med appens request services.
7. Nullstill kontekst og disposer scopet i `finally`.

Bytte av `HttpContext.RequestServices` er en realistisk implementasjon, men samspillet med MVC controller activation, authorization, filtre og scope-disposal bør bevises i en liten teknisk spike først. En app-bevisst `IControllerActivator` er et mulig fallback.

## Flere config-sett

Tre identiteter bør skilles:

- `ImplementationId`: kode/modul.
- `AppIdentifier`: offentlig digital tjeneste, URL og Storage-app-id.
- `ConfigSetId`: konkret konfigurasjons- og ressurssett.

Eksempel:

```text
Implementation: tax-form
├─ org/tax-2025 -> config set 2025
├─ org/tax-2026 -> config set 2026
└─ org/tax-pilot -> config set pilot
```

Mange offentlige apper kan dermed dele kodeimplementasjon.

Samme offentlige URL kan derimot ikke velge mellom flere samtidige config-sett uten en ekstra stabil discriminator. Det må i så fall være et eget URL-segment/hostname eller en `ConfigSetId` lagret permanent på instansen. Query-parameter/header kontrollert av klienten bør ikke kunne velge policy, prosess eller secrets.

**Anbefaling for første versjon:** Ett offentlig `org/app` peker til nøyaktig én immutable config-snapshot om gangen, mens flere `org/app` kan dele samme kodeimplementasjon.

## Konfigurasjon og innhold

Hver runtime bør få sin egen `IConfigurationRoot`, for eksempel med følgende prioritet:

1. Felles runtime-defaults.
2. Modulens defaults.
3. Miljøspesifikk modulkonfigurasjon.
4. Config-settets settings.
5. Config-settets miljøfil.
6. App-spesifikke secrets.
7. Namespaced environment overrides.

Hostkonfigurasjon, som Kestrel, shutdown og telemetry exporter, må holdes separat fra appkonfigurasjon.

I produksjon bør app- og config-bundles være immutable. Oppdatering kan i første omgang skje ved vanlig rolling deployment. Senere kan registeret byttes atomisk til en ferdig validert runtime-snapshot mens eksisterende requests dreneres på gammel snapshot.

## Bakgrunnsarbeid

HTTP-URL gir bare appkontekst for requests. Hosted services og durable executions trenger en eksplisitt runner:

```csharp
await appExecutionRunner.Run(
runtimeId,
cancellationToken,
appServices => Execute(appServices)
);
```

Work items må minst bære runtime/app-id, config-versjon, instance-id og trace-kontekst. `AppImplementationFactory` må aldri gjette app dersom denne konteksten mangler.

Feil i startup for én app bør markere denne appen unhealthy, og ikke automatisk stoppe hele hosten.

## Sikkerhet og operasjonelle konsekvenser

Separate DI-containere gir logisk isolasjon, men samme prosess er **ikke en sikkerhetsgrense**:

- Appkode kan lese prosessminne, miljøvariabler og filer/secrets som prosessen har tilgang til.
- Statisk state deles.
- OOM, fatal exception eller `Environment.Exit` påvirker alle appene.
- Én app kan bruke opp thread pool, CPU eller heap.
- `AssemblyLoadContext` er ikke en sandbox.

Pooling bør derfor begrenses til apper med kompatibelt tillitsnivå, tjenesteeier, sikkerhetsklassifisering og runtime-versjon. Høytrafikkapper, apper med spesielle secrets eller apper med streng SLA bør fortsatt kunne få egne pods.

Det bør innføres:

- app-id/config-sett på alle logs, traces og metrics
- per-app concurrency limits/bulkheads
- app-spesifikk readiness
- eksplisitt kontroll av at hentet `Instance.AppId` samsvarer med aktiv runtime
- namespacing eller app-lokale caches
- app-bevisst antiforgery/scope authorization

OpenTelemetry `Resource` er prosessglobal. En shared host bør trolig bruke `service.name=altinn-app-host` og app-id som span-/metric-attributt. Dette må behandles som en kompatibilitets-/ADR-beslutning siden telemetri regnes som offentlig kontrakt.

## Pakking av appkode

Første versjon bør bruke statisk sammensetning:

- Appene bygges som moduler/class libraries.
- En kjent samling moduler refereres av host-buildet.
- Poolen deployes samlet med rolling deployment.
- Alle moduler bruker kompatibel Altinn.App runtime/major-versjon.

Appmodulen trenger en kontrakt omtrent som:

```csharp
public interface IAltinnAppModule
{
void ConfigureServices(
IServiceCollection services,
IConfiguration configuration,
IHostEnvironment environment
);

void MapEndpoints(IEndpointRouteBuilder endpoints);
}
```

Eksisterende top-level `Program.cs` kan ikke lastes flere ganger. `RegisterCustomAppServices` og eventuelle custom endpoints/middleware må derfor flyttes til modulkontrakten. Standalone-hosting kan bruke samme modul med én runtimeoppføring.

Dynamisk lasting/hot unloading via `AssemblyLoadContext` bør være utenfor første leveranse. Statiske type-, serializer- og source-generator-cacher gjør korrekt unloading komplisert.

## Foreslått gjennomføring

### Fase 0: ADR og teknisk spike

- [ ] Kjør to testapper i én host.
- [ ] Bruk forskjellige implementasjoner av samme Altinn-interface.
- [ ] Bruk forskjellige options, metadata, BPMN, modeller og statiske filer.
- [ ] Verifiser parallell trafikk uten context/config-lekkasje.
- [ ] Verifiser MVC/auth/filter-livsløp ved bytte av request services.
- [ ] Avklar sikkerhets-/poolingmodell.

### Fase 1: Host/runtime-splitt

- [ ] Innfør `AppRuntimeId`, `AppRuntimeDescriptor` og `IAppRuntimeRegistry`.
- [ ] Splitt host- og app-runtime-registreringer.
- [ ] Innfør appvalg og request scope/context.
- [ ] Behold standalone-modus som én registrert runtime.

### Fase 2: Config, innhold og modeller

- [ ] Per-app `IConfigurationRoot` og config-sett.
- [ ] App-lokal content root/static files.
- [ ] Fjern hardkodet global app-id.
- [ ] Gjør modellresolver assembly-bevisst.
- [ ] Gjør metadata, oversettelser, resources og process reader app-lokale.

### Fase 3: Sikkerhetskritiske tjenester

- [ ] App-lokal scope authorization.
- [ ] Verifiser request-app mot `Instance.AppId`.
- [ ] Isoler secrets, Maskinporten og cache keys.
- [ ] Avklar authentication/antiforgery/cookie-scope.
- [ ] Eksplisitt appkontekst for hosted/background work.

### Fase 4: Operasjonalisering

- [ ] Per-app readiness og failure isolation.
- [ ] Telemetri/logging med app-dimensjon.
- [ ] Concurrency limits og lasttest/noisy-neighbour-test.
- [ ] CI-validering av moduler og config-bundles.
- [ ] Kubernetes pool-deployment og pilot med lavtrafikkapper.

## Akseptansekriterier for en produksjonsklar første versjon

- [ ] Parallelle requests til to apper får alltid korrekt implementasjon og config.
- [ ] Singleton-state deles innen én app-runtime, men aldri mellom runtimes.
- [ ] Samme kodeassembly kan brukes med flere config-sett.
- [ ] Ukjent eller mismatchet `org/app` når aldri appkode.
- [ ] En instance fra app A kan ikke behandles av app B.
- [ ] Statisk innhold, metadata, modeller og prosessdefinisjoner isoleres.
- [ ] Hosted work kjører med eksplisitt appkontekst.
- [ ] Tokens, secrets og caches kan ikke kollidere mellom apper.
- [ ] Feil i én app kan markere appen unhealthy uten å stoppe hele hosten.
- [ ] Eksisterende standalone-hosting fortsetter å fungere.

## Beslutninger som må tas i ADR

1. Er pooling bare tillatt innen samme tjenesteeier/tillitsdomene?
2. Er host-/platform-/authentication-konfigurasjon felles for alle apper i en pool?
3. Skal én offentlig app-id kunne ha flere samtidige config-sett, og hvordan lagres i så fall valget?
4. Skal appmoduler linkes statisk eller lastes dynamisk?
5. Hvilke singleton-/cache-/HTTP-tjenester kan deles trygt?
6. Hvordan skal telemetrikontrakten se ut i en prosess med flere apper?
7. Hvordan håndteres deploy, rollback og versjonskompatibilitet i en app-pool?

## Foreløpig anbefaling

Bygg dette som en ny, opt-in hostingmodell ved siden av dagens standalone-modell. Start med en vertikal spike og en liten pool av lavtrafikkapper fra samme tillitsdomene. Behold eksisterende public app-interfaces uendret, og legg appvalg, config-snapshot og DI-isolasjon i hostinglaget.

Contributor guide

Open the contributing guide

Research direction

Start by reading StartupHelper.GetApplicationId, AddApplicationIdentifier, AddPlatformServices, AddAppServices, AddProcessServices, and the listed controllers and runtime services. Trace how app identity, DI, resources, model assemblies, and request services are currently selected, then use a small middleware/controller-activation spike to validate the proposed AppRuntime flow; done means a documented, validated hosting design with identified migration boundaries.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp
Domain
backend-api-design
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Needs clarification
Newbie friendliness
28/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.