diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/ASPIRE_PUBLISH_AND_DEPLOY.md b/src/HotChocolate/Fusion/src/Fusion.Aspire/ASPIRE_PUBLISH_AND_DEPLOY.md new file mode 100644 index 00000000000..a7d9b0c6566 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/ASPIRE_PUBLISH_AND_DEPLOY.md @@ -0,0 +1,637 @@ +# Aspire publish and deploy + +Research date: 2026-07-30. Version reviewed: Aspire 13.4.6, the latest stable +release on the research date. + +## Executive summary + +Aspire deployment is driven by the application model in the AppHost. A hosting +integration provides the API for adding a compute-environment resource, such as +Docker Compose, Kubernetes, Azure Kubernetes Service (AKS), Azure Container +Apps, or Azure App Service. That resource contributes target-specific steps to +Aspire's deployment pipeline. + +- `aspire publish` runs the pipeline's publish entry point and writes + target-specific artifacts for another tool, CI stage, GitOps system, or person + to apply later. In the documented built-in targets, unresolved parameter + requirements, including secrets, remain represented as target-specific + placeholders. +- `aspire deploy` runs the deploy entry point and its dependencies. Aspire + resolves parameters, generates any target output it needs, and applies the + deployment itself. +- `aspire deploy` does **not** consume artifacts from an earlier + `aspire publish`. They are separate pipeline entry points. +- `aspire do ` is the lower-level option for workflows that need to split + building, pushing, publishing, and deployment across stages. + +These semantics and the no-op behavior when no target contributes a matching +step are documented in [How Aspire deployment works](https://aspire.dev/deployment/deploy-with-aspire/). + +## Version and stability status + +The official release page identifies +[Aspire 13.4.6](https://github.com/microsoft/aspire/releases/tag/v13.4.6) as the +latest stable release. It was released on 2026-06-20. + +There are two distinct stability statements to keep in mind: + +1. Aspire 13.4.6 is a stable Aspire release. +2. Aspire 13.4 made `aspire publish` and `aspire deploy` generally available, + supported workflows, according to the + [Aspire 13.4 release notes](https://aspire.dev/whats-new/aspire-13-4/#deployment-commands-are-generally-available). + Some current reference navigation still displays "Preview" badges for these + commands. The lower-level `aspire do` command and pipeline extension APIs + remain preview/experimental, and the pipeline system is described as + experimental in Aspire 13 by the + [pipeline documentation](https://aspire.dev/deployment/pipelines/). + +Production automation should pin and test the CLI/SDK versions and treat custom +pipeline APIs, in particular, as capable of changing in a future release. + +Target integrations have their own stability and package versions, independent +of the core commands. On the research date, the official packages for +[Docker](https://www.nuget.org/packages/Aspire.Hosting.Docker/13.4.6) and +[Azure Container Apps](https://www.nuget.org/packages/Aspire.Hosting.Azure.AppContainers/13.4.6) +were stable 13.4.6 packages. The +[Kubernetes](https://www.nuget.org/packages/Aspire.Hosting.Kubernetes/13.4.6-preview.1.26319.6) +and +[AKS](https://www.nuget.org/packages/Aspire.Hosting.Azure.Kubernetes/13.4.6-preview.1.26319.6) +packages were prerelease `13.4.6-preview.1.26319.6` packages. The +[App Service deployment guide](https://aspire.dev/deployment/azure/app-service/) +explicitly marks that target as Preview, even though its package version is +13.4.6. + +### Current terminology: pipelines and compute environments + +Older Aspire 9.x articles refer to top-level "publishers" and APIs such as +`AddDockerComposePublisher` or `IDistributedApplicationPublisher`. Those are not +the current Aspire 13 model. Aspire 13.0 replaced publishing callbacks and the +publisher interface with pipeline steps. Current applications add a hosting +integration and a compute-environment resource, and resources contribute +pipeline steps. See the official +[Aspire 13 migration section](https://aspire.dev/deployment/pipelines/#migrating-from-publishing-callbacks). + +Methods whose names start with `PublishAs`, such as +`PublishAsDockerComposeService` or `PublishAsAzureContainerApp`, still exist for +target-specific customization. They customize a generated resource; they do not +replace adding the target environment itself. + +## How the pipeline works + +The AppHost is both the local orchestration model and the source of deployment +behavior. For a publish or deploy operation, the CLI: + +1. Finds the AppHost from `--apphost`, a rooted `aspire.config.json`, or a search + below the current directory, in that order. +2. Records the selected AppHost in the rooted `aspire.config.json`. +3. Verifies Aspire's local hosting certificates. +4. Builds and starts the AppHost and its resources. +5. Runs the requested pipeline entry point and its dependency graph. +6. Prints a hierarchical step summary with status and duration. + +The exact command lifecycle is in the +[`aspire publish` reference](https://aspire.dev/reference/cli/commands/aspire-publish/) +and +[`aspire deploy` reference](https://aspire.dev/reference/cli/commands/aspire-deploy/). + +Independent pipeline steps may run concurrently, while declared dependencies +determine ordering. Hosting integrations typically contribute the steps for +infrastructure provisioning, container builds and pushes, artifact generation, +and applying the target deployment. Applications can add their own steps, but +the pipeline API is experimental. + +### No target means no work + +If the AppHost has no resource that contributes work to the selected entry +point, the current CLI completes successfully as a no-op. A successful +`aspire publish` or `aspire deploy` with an almost empty step summary therefore +does not prove that anything was generated or deployed. Add the appropriate +target integration and environment, then inspect the plan: + +```shell +aspire publish --list-steps +aspire deploy --list-steps +``` + +## `aspire publish` versus `aspire deploy` + +| Concern | `aspire publish` | `aspire deploy` | +| --- | --- | --- | +| Intent | One-way artifact handoff | Aspire-managed end-to-end deployment | +| Pipeline entry point | `publish` | `deploy` plus its dependent steps, which may include `publish` | +| Parameters | Preserves requirements as target-specific placeholders in emitted artifacts | Resolves required values before applying changes | +| Side effects on target | Does not apply the emitted artifacts | Provisions or updates infrastructure and workloads as the target integration defines | +| Reuse of prior output | Output is for external consumers | Does not read an earlier publish output as its input | +| Default artifact directory | `/aspire-output` | `/aspire-output` when the target emits deployment artifacts | +| Default Aspire environment | `Production` | `Production` | +| Best fit | Reviewable/promotable artifacts, GitOps, separate approval/apply stages | Local or CI stage has target credentials and Aspire should own the complete operation | + +The +[deployment model](https://aspire.dev/deployment/deploy-with-aspire/#pipeline-entry-points) +defines the command distinction. The command references document +`--output-path`, `--environment`, `--apphost`, `--list-steps`, `--no-build`, +logging options, and `--non-interactive`. + +Useful current command forms are: + +```shell +aspire publish --apphost ./MyApp.AppHost/MyApp.AppHost.csproj \ + --environment Staging \ + --output-path ./artifacts + +aspire deploy --apphost ./MyApp.AppHost/MyApp.AppHost.csproj \ + --environment Production \ + --non-interactive +``` + +Arguments after `--` are passed to the AppHost: + +```shell +aspire publish --apphost ./MyApp.AppHost/MyApp.AppHost.csproj -- --my-apphost-option +``` + +`--apphost` is the current documented option name. The +[tagged 13.4.6 CLI source](https://github.com/microsoft/aspire/blob/v13.4.6/src/Aspire.Cli/Commands/PipelineCommandBase.cs) +retains `--project` as a legacy alias, but new scripts should use `--apphost`. +`--publisher` belongs to the removed top-level publisher workflow and should not +be used in an Aspire 13.4 workflow. + +## Built-in deployment targets + +The current built-in capability matrix in +[How Aspire deployment works](https://aspire.dev/deployment/deploy-with-aspire/#built-in-target-capabilities) +is: + +| Hosting integration | Reviewed version/status | AppHost environment | Publish result | Deploy behavior | +| --- | --- | --- | --- | --- | +| `Aspire.Hosting.Docker` | 13.4.6 | `AddDockerComposeEnvironment` | Docker Compose files and parameter placeholders | Builds images and starts the generated Compose application | +| `Aspire.Hosting.Kubernetes` | 13.4.6 prerelease | `AddKubernetesEnvironment` | Helm chart for an existing Kubernetes cluster | Installs through Helm into the current `kubectl` context | +| `Aspire.Hosting.Azure.Kubernetes` | 13.4.6 prerelease | `AddAzureKubernetesEnvironment` | Helm chart plus Azure Bicep infrastructure | Provisions AKS/ACR and Azure dependencies, pushes images, and installs the chart | +| `Aspire.Hosting.Azure.AppContainers` | 13.4.6 | `AddAzureContainerAppEnvironment` | Azure target artifacts, including generated provisioning resources | Provisions or attaches the Container Apps environment and ACR, pushes images, and deploys Container Apps | +| `Aspire.Hosting.Azure.AppService` | Preview target, 13.4.6 package | `AddAzureAppServiceEnvironment` | Azure target artifacts | Provisions or attaches the App Service plan and ACR, pushes images, and deploys supported websites | + +Compute resources automatically attach when exactly one compatible environment +exists. If multiple compatible compute environments exist, use +`WithComputeEnvironment` to make the target explicit. This also enables hybrid +deployments in which resources from one AppHost go to different targets. + +### Generated artifact details + +Artifact contents are target-specific: + +- Docker Compose writes `docker-compose.yaml`, an unfilled `.env` from + `aspire publish`, an environment-specific `.env.{environment}` from prepare or + deploy, and per-resource Dockerfiles when the resource uses an existing or + generated Dockerfile build context. See + [Docker Compose output artifacts](https://aspire.dev/deployment/docker-compose/#output-artifacts). +- Kubernetes turns projects and containers into Deployments or StatefulSets, + endpoints into Services, configuration into ConfigMaps and Secrets, and + volumes into persistent-volume resources. Publish produces a Helm chart. See + [Deploy to Kubernetes](https://aspire.dev/deployment/kubernetes/). +- AKS publish produces Helm and Bicep artifacts. Direct deploy additionally + provisions the cluster, ACR, managed identity, and referenced Azure services. + See + [Publish AKS artifacts](https://aspire.dev/deployment/kubernetes/aks/#publish-aks-artifacts). +- Azure integrations generate provisioning resources from the AppHost. For + example, the default Container Apps environment model generates Bicep for the + managed environment, ACR, managed identity, Log Analytics, role assignments, + and Aspire Dashboard. See + [Configure Azure Container Apps environments](https://aspire.dev/integrations/cloud/azure/configure-container-apps/). + +Generated output should be reviewed as target configuration, not assumed to be a +portable intermediate representation. Each integration owns its output shape. + +## Prerequisites + +### Base prerequisites + +For a C# AppHost in Aspire 13.4, the official prerequisites require: + +- .NET 10 SDK for the AppHost. Aspire can still orchestrate applications that + target .NET 8 or later. +- The Aspire CLI. +- An OCI-compliant container runtime where the selected workflow builds or runs + containers. Docker Desktop is the recommended default; Podman is supported. + +The current details, including TypeScript AppHost runtime requirements, are in +[Aspire prerequisites](https://aspire.dev/get-started/prerequisites/). + +### Target-specific prerequisites + +- Docker Compose: Docker or Podman. Podman must be 5.0 or later for the current + Compose deployment support. Aspire can auto-detect the runtime or use + `ASPIRE_CONTAINER_RUNTIME=docker|podman`. +- Existing Kubernetes cluster: `kubectl` on `PATH`, Helm 4.2.0 or later, and a + valid current `kubectl` context. +- AKS: the Kubernetes tools above, Azure CLI, an Azure account, and an active + subscription. +- Azure Container Apps or App Service: an Azure account/subscription and, for the + default local credential source, Azure CLI on `PATH` followed by `az login`. + +Use +[Deploy to Docker Compose](https://aspire.dev/deployment/docker-compose/), +[Deploy to Kubernetes](https://aspire.dev/deployment/kubernetes/), and +[Deploy to Azure](https://aspire.dev/deployment/azure/) for the target-specific +checks. The deployment identity also needs the target-specific permissions +required for resources that the deployment provisions, updates, or attaches. + +## Configuration, parameters, and secrets + +### Parameter declaration and resolution + +An external value is modeled as a parameter resource: + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +var apiKey = builder.AddParameter("apiKey", secret: true); + +builder.AddDockerComposeEnvironment("compose"); + +builder.AddProject("api") + .WithEnvironment("API_KEY", apiKey); + +builder.Build().Run(); +``` + +The official +[external parameters guide](https://aspire.dev/fundamentals/external-parameters/) +documents this resolution order: + +1. Environment variables named `Parameters__*`. +2. AppHost configuration sources, including `appsettings.json` and .NET user + secrets. +3. An interactive prompt when no value is available. + +For example, `apiKey` can be supplied in automation as: + +```shell +export Parameters__apiKey='value-from-the-ci-secret-store' +``` + +For a parameter containing dashes, the documented environment-variable mapping +uses a single underscore for each dash. `registry-endpoint` therefore becomes +`Parameters__registry_endpoint`. + +`secret: true` is a hint to deployment integrations that the value should be +treated as sensitive; it is not a guarantee that every storage path is +encrypted. In the publish handoff, the value remains a placeholder. For the +Docker Compose example above, the generated shape is conceptually: + +```yaml +# docker-compose.yaml +services: + api: + environment: + API_KEY: ${APIKEY} +``` + +```dotenv +# .env +APIKEY= +``` + +`API_KEY` is the variable received by the application. `APIKEY` is the +target-generated placeholder in the Compose artifacts. Placeholder naming is +target-specific and should not be inferred from the application variable name. + +### Aspire environment versus compute environment + +`--environment Staging` sets the Aspire environment used while evaluating the +AppHost and scopes deployment state. It is not the compute environment resource. +It also does not automatically set `DOTNET_ENVIRONMENT`, `ASPNETCORE_ENVIRONMENT`, +or `NODE_ENV` in child applications. Set those on the child resources when +needed. See [Aspire environments](https://aspire.dev/deployment/environments/). + +### Deployment state and its secret-handling consequence + +`aspire deploy` manages environment-scoped deployment state and can cache +provisioning inputs and parameter values per AppHost and environment under: + +```text +~/.aspire/deployments/{AppHostSha}/{environment}.json +``` + +The official +[deployment state caching guide](https://aspire.dev/deployment/deployment-state-caching/) +warns that these files can include secrets and are stored in plain text outside +the repository, following the same security model as .NET user secrets. Any +process running as the same OS user can read them. + +Consequences: + +- Do not commit, publish, or indiscriminately cache this directory. +- Protect CI caches and restrict who can restore them. +- Prefer the CI platform's secret store plus `Parameters__*` variables for + non-interactive deployments. +- Use `aspire deploy --clear-cache` when values need to be re-entered. This + clears the selected environment's cache and does not save the newly prompted + values after that deployment. + +### Azure authentication and settings + +Local Azure deploys use Azure CLI credentials by default: + +```shell +az login +``` + +The shared Azure inputs can be supplied using configuration or environment +variables: + +| Setting | Environment variable | +| --- | --- | +| `Azure:SubscriptionId` | `Azure__SubscriptionId` | +| `Azure:Location` | `Azure__Location` | +| `Azure:ResourceGroup` | `Azure__ResourceGroup` | +| `Azure:CredentialSource` | `Azure__CredentialSource` | + +Supported credential sources and CI guidance are in +[Deploy to Azure](https://aspire.dev/deployment/azure/). For GitHub Actions and +Azure DevOps, the official guidance prefers workload identity federation over +long-lived client secrets. + +Before using `--non-interactive`, provide all required target settings and +`Parameters__*` values. The flag disables prompts; it does not invent defaults +for unresolved inputs. + +## Practical examples from the official guides + +The examples below use the current C# AppHost APIs and Aspire 13.4 CLI forms. + +### 1. Publish or directly deploy Docker Compose + +Add a Compose environment. With a single compatible environment, compute +resources are included automatically: + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +builder.AddDockerComposeEnvironment("compose"); +builder.AddProject("api"); + +builder.Build().Run(); +``` + +Generate handoff artifacts without building container images: + +```shell +aspire publish --output-path ./aspire-output +``` + +The result includes `docker-compose.yaml` and an unfilled `.env`. A later stage +fills the placeholders and applies the Compose definition. Alternatively, let +Aspire perform the entire local Compose deployment: + +```shell +aspire deploy --environment Staging +``` + +For this target, direct deploy generates the Compose and filled +`.env.Staging`, builds images, and runs +`docker compose up -d --remove-orphans`. The complete progressive workflow is in +the official +[Docker Compose deployment guide](https://aspire.dev/deployment/docker-compose/#publishing-and-deployment-workflow). + +### 2. Publish a Helm chart or deploy to an existing Kubernetes cluster + +Install the integration and add a Kubernetes environment: + +```shell +aspire add kubernetes +``` + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +#pragma warning disable ASPIRECOMPUTE003 +var registry = builder.AddContainerRegistry( + "registry", + "myregistry.example.com:5000"); + +var k8s = builder.AddKubernetesEnvironment("k8s") + .WithContainerRegistry(registry); +#pragma warning restore ASPIRECOMPUTE003 + +builder.AddProject("api") + .WithComputeEnvironment(k8s) + .WithExternalHttpEndpoints(); + +builder.Build().Run(); +``` + +For an artifact/GitOps workflow: + +```shell +aspire publish --output-path ./k8s-artifacts +``` + +For direct deployment to the current `kubectl` context: + +```shell +aspire deploy --environment Production +``` + +The registry must be reachable from both the machine running Aspire and the +cluster, and the deployment environment must have the required registry +credentials. The container-registry API is Preview and currently requires the +`ASPIRECOMPUTE003` warning suppression shown above. The Kubernetes target +publishes a Helm chart; direct deploy builds and pushes project images, then +installs the chart with Helm. Verify the current context before invoking deploy. +Also configure the target's ingress or Gateway API support if external endpoints +must be reachable from outside the cluster. See the official +[cluster deployment guide](https://aspire.dev/deployment/kubernetes/clusters/) +for registry, Helm, context, and exposure details. + +### 3. Directly deploy to Azure Container Apps + +Install the target integration: + +```shell +aspire add azure-appcontainers +``` + +Add the environment and an externally reachable workload: + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +builder.AddAzureContainerAppEnvironment("aca-env"); + +builder.AddDockerfile("web", "./web") + .WithHttpEndpoint(port: 8080, targetPort: 8080, name: "http") + .WithExternalHttpEndpoints(); + +builder.Build().Run(); +``` + +Authenticate and deploy interactively: + +```shell +az login +aspire deploy +``` + +Aspire provisions or attaches the Container Apps environment, ACR, identity and +supporting Azure resources, builds and pushes the image, and deploys the +Container App. This example and the target's endpoint constraints are in +[Deploy to Azure Container Apps](https://aspire.dev/deployment/azure/container-apps/). + +A CI deployment supplies Azure authentication, the three shared Azure settings, +and all AppHost parameters before running: + +```shell +aspire deploy \ + --apphost ./MyApp.AppHost/MyApp.AppHost.csproj \ + --environment Production \ + --non-interactive +``` + +### 4. Publish reviewable AKS infrastructure and workload artifacts + +The current Kubernetes and AKS hosting integrations are prerelease packages, +even though the core `aspire publish` and `aspire deploy` commands are GA. + +Configure an AKS environment: + +```shell +aspire add azure-kubernetes +``` + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +builder.AddAzureKubernetesEnvironment("aks"); +builder.AddProject("api"); + +builder.Build().Run(); +``` + +Publish without applying: + +```shell +aspire publish --output-path ./aks-artifacts +``` + +The result contains Helm charts and Bicep infrastructure templates for review +or use by a separate CI/CD or GitOps workflow. Running `aspire deploy` instead +provisions AKS, ACR, managed identity and referenced Azure resources, pushes the +images, and installs the Helm chart. See the official +[AKS deployment guide](https://aspire.dev/deployment/kubernetes/aks/). + +### 5. Target Azure App Service (Preview) + +The official guide marks this deployment target as Preview. For public websites +and APIs that fit App Service's single-public-endpoint model: + +```shell +aspire add azure-appservice +``` + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +builder.AddAzureAppServiceEnvironment("app-service-env"); + +builder.AddDockerfile("web", "./web") + .WithHttpEndpoint(port: 8080, targetPort: 8080, name: "http") + .WithExternalHttpEndpoints(); + +builder.Build().Run(); +``` + +```shell +az login +aspire deploy +``` + +Aspire provisions the App Service plan, ACR and managed identity, then builds, +pushes and deploys supported website images. Arbitrary infrastructure containers +and internal-only or multi-port workloads do not fit this target; use managed +backing services or a different compute environment. See +[Deploy to Azure App Service](https://aspire.dev/deployment/azure/app-service/). + +## CI/CD workflow choices + +The +[Aspire CI/CD overview](https://aspire.dev/deployment/ci-cd/) recommends keeping +application topology and target behavior in the AppHost while the CI system owns +checkout, tests, approvals, credentials, artifact retention, and promotion. + +- Artifact-first: run `aspire publish`, retain the output, review or promote it, + then use the target's normal tool in a later stage. +- Direct deployment: authenticate the runner and run `aspire deploy + --environment --non-interactive`. +- Split workflow: use the Preview `aspire do` command for `build`, `push`, named + target steps, or custom AppHost steps when build, push, and apply must cross + stage boundaries. The command and custom pipeline APIs are not GA. + +Useful safeguards: + +```shell +# Show the resolved step graph without executing it. +aspire deploy --list-steps + +# Skip the AppHost project build and restore if that project is already built. +# Deployment steps can still build workload images. +aspire deploy --no-build --non-interactive + +# Increase pipeline diagnostics while troubleshooting. +aspire deploy --pipeline-log-level debug --include-exception-details +``` + +The Aspire environment should be passed explicitly in automation because it +affects AppHost evaluation and deployment-state scope: + +```shell +aspire publish --environment Staging --output-path ./artifacts +aspire deploy --environment Production --non-interactive +``` + +## Common mistakes + +1. **Running a successful no-op.** A target environment must contribute publish + or deploy steps. Use `--list-steps` before trusting the command. +2. **Treating deploy as "apply my published folder."** Deploy regenerates what it + needs and does not consume a previous publish result. +3. **Using Aspire 9.x publisher examples.** Current Aspire uses compute + environments and pipeline steps, not `Add*Publisher`, + `IDistributedApplicationPublisher`, `--publisher`, or publishing callbacks. +4. **Assuming placeholders and application variables have identical names.** + Placeholder naming belongs to the target integration. +5. **Assuming `--environment` propagates to child processes.** Explicitly set + framework runtime environment variables on child resources. +6. **Using `--non-interactive` without complete inputs.** Supply credentials, + target settings, and `Parameters__*` variables first. +7. **Treating the deployment cache as encrypted secret storage.** It can contain + plain-text parameter values and needs OS- and CI-level protection. +8. **Deploying to an unintended Kubernetes cluster.** The non-Azure Kubernetes + target uses the current `kubectl` context. +9. **Omitting a registry for direct Kubernetes deployment.** Project images + need a registry that is reachable from the deployment machine and cluster. +10. **Copying old CLI flags.** Current AppHost selection uses `--apphost`. + Current parameter input uses AppHost configuration or `Parameters__*`; + current command references do not list old flags such as `--parameter` or + `--deployment-params-file`. + +## Primary official sources + +- [Aspire 13.4.6 release](https://github.com/microsoft/aspire/releases/tag/v13.4.6) +- [What's new in Aspire 13.4](https://aspire.dev/whats-new/aspire-13-4/) +- [Aspire prerequisites](https://aspire.dev/get-started/prerequisites/) +- [Aspire deployment overview](https://aspire.dev/deployment/) +- [How Aspire deployment works](https://aspire.dev/deployment/deploy-with-aspire/) +- [Pipelines and app topology](https://aspire.dev/deployment/pipelines/) +- [`aspire publish` command](https://aspire.dev/reference/cli/commands/aspire-publish/) +- [`aspire deploy` command](https://aspire.dev/reference/cli/commands/aspire-deploy/) +- [External parameters](https://aspire.dev/fundamentals/external-parameters/) +- [Environments](https://aspire.dev/deployment/environments/) +- [Deployment state caching](https://aspire.dev/deployment/deployment-state-caching/) +- [CI/CD overview](https://aspire.dev/deployment/ci-cd/) +- [Docker Compose deployment](https://aspire.dev/deployment/docker-compose/) +- [Kubernetes deployment](https://aspire.dev/deployment/kubernetes/) +- [Kubernetes cluster deployment](https://aspire.dev/deployment/kubernetes/clusters/) +- [AKS deployment](https://aspire.dev/deployment/kubernetes/aks/) +- [Azure deployment overview](https://aspire.dev/deployment/azure/) +- [Azure Container Apps deployment](https://aspire.dev/deployment/azure/container-apps/) +- [Azure App Service deployment](https://aspire.dev/deployment/azure/app-service/) diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/AspireCompositionHelper.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/AspireCompositionHelper.cs index 66a99a2120d..d92de3d5559 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Aspire/AspireCompositionHelper.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/AspireCompositionHelper.cs @@ -2,8 +2,10 @@ using System.Collections.Immutable; using System.Text; using System.Text.Json; +using HotChocolate.Buffers; using HotChocolate.Fusion.Logging; using HotChocolate.Fusion.Packaging; +using HotChocolate.Fusion.SourceSchema.Packaging; using Microsoft.Extensions.Logging; namespace HotChocolate.Fusion.Aspire; @@ -12,6 +14,182 @@ internal static class AspireCompositionHelper { private const string DefaultGraphQLPath = "/graphql"; + /// + /// Composes the given source schema archives into a fusion archive, resolving the source + /// schema settings against the environment of the distributed application. + /// + public static Task TryComposeArchivesAsync( + string fusionArchivePath, + IReadOnlyList archives, + GraphQLCompositionSettings settings, + ILogger logger, + CancellationToken cancellationToken) + => TryComposeArchivesAsync( + fusionArchivePath, + archives, + settings.EnvironmentName ?? "Aspire", + settings, + logger, + cancellationToken); + + /// + /// Composes the given source schema archives into a fusion archive, resolving the source + /// schema settings against . + /// + public static async Task TryComposeArchivesAsync( + string fusionArchivePath, + IReadOnlyList archives, + string environmentName, + GraphQLCompositionSettings settings, + ILogger logger, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(environmentName); + + var sourceSchemas = await ReadSourceSchemasAsync( + archives, + cancellationToken); + + try + { + // the archives are composed for a deployment, so the configured URLs are composed as + // they are, without local overrides and without a preference for development URLs. + return await ComposeAsync( + fusionArchivePath, + seedArchivePath: null, + [.. sourceSchemas], + environmentName, + settings, + stageSettings: null, + SettingsComposerOptions.Default, + logger, + cancellationToken); + } + finally + { + foreach (var sourceSchema in sourceSchemas) + { + sourceSchema.SchemaSettings.Dispose(); + } + } + } + + /// + /// Composes the given source schema archives into a fusion archive, resolving the source + /// schema settings against . + /// + /// + /// The stream that the composed fusion archive is written to. + /// + /// + /// The source schema archives that are composed. + /// + /// + /// The environment that the settings of the source schemas resolve against. + /// + /// + /// The composition settings of the gateway. + /// + /// + /// The composition settings that the deployment target declares. They only fill values that + /// leaves unset. + /// + /// + /// The logger that receives the composition diagnostics. + /// + /// + /// The cancellation token. + /// + internal static async Task TryComposeArchivesAsync( + Stream fusionArchive, + IReadOnlyList archives, + string environmentName, + GraphQLCompositionSettings settings, + CompositionSettings? stageSettings, + ILogger logger, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(fusionArchive); + ArgumentException.ThrowIfNullOrWhiteSpace(environmentName); + + var sourceSchemas = await ReadSourceSchemasAsync( + archives, + cancellationToken); + + try + { + using var archive = FusionArchive.Create( + fusionArchive, + leaveOpen: true); + return await ComposeAsync( + archive, + [.. sourceSchemas], + environmentName, + settings, + stageSettings, + SettingsComposerOptions.Default, + logger, + cancellationToken); + } + finally + { + foreach (var sourceSchema in sourceSchemas) + { + sourceSchema.SchemaSettings.Dispose(); + } + } + } + + private static async Task> ReadSourceSchemasAsync( + IReadOnlyList archives, + CancellationToken cancellationToken) + { + var sourceSchemas = new List(archives.Count); + + try + { + foreach (var archiveInfo in archives) + { + await using var archiveStream = archiveInfo.OpenRead(); + using var archive = FusionSourceSchemaArchive.Open( + archiveStream); + var schema = await archive.TryGetSchemaAsync(cancellationToken) + ?? throw new InvalidOperationException( + $"Fusion source archive '{archiveInfo.Name}' has no schema."); + var sourceSettings = + await archive.TryGetSettingsAsync(cancellationToken) + ?? throw new InvalidOperationException( + $"Fusion source archive '{archiveInfo.Name}' has no settings."); + var extensions = + await archive.TryGetSchemaExtensionsAsync(cancellationToken); + + sourceSchemas.Add( + new SourceSchemaInfo + { + Name = archiveInfo.Name, + Schema = new SourceSchemaText( + archiveInfo.Name, + Encoding.UTF8.GetString(schema.Span), + extensions is null + ? null + : Encoding.UTF8.GetString(extensions.Value.Span)), + SchemaSettings = sourceSettings + }); + } + + return sourceSchemas; + } + catch + { + foreach (var sourceSchema in sourceSchemas) + { + sourceSchema.SchemaSettings.Dispose(); + } + + throw; + } + } + /// /// Composes the source schemas of the distributed application into a fusion archive. /// @@ -41,7 +219,7 @@ internal static class AspireCompositionHelper /// /// The cancellation token. /// - public static async Task TryComposeAsync( + public static Task TryComposeAsync( string fusionArchivePath, string? seedArchivePath, ImmutableArray newSourceSchemas, @@ -50,23 +228,73 @@ public static async Task TryComposeAsync( ILogger logger, CancellationToken cancellationToken) { - using var archive = OpenArchive(fusionArchivePath, seedArchivePath); - - var compositionLog = new CompositionLog(); var environment = settings.EnvironmentName ?? "Aspire"; - var compositionSettings = CreateCompositionSettings(settings); - var sourceSchemas = newSourceSchemas.ToDictionary( - s => s.Name, - s => (s.Schema, s.SchemaSettings)); - var settingsComposerOptions = new SettingsComposerOptions { LocalUrlOverrides = BuildLocalUrlOverrides(newSourceSchemas, environment, logger), PreferDevUrls = true, - LocalSourceSchemas = sourceSchemas.Keys.ToFrozenSet(StringComparer.Ordinal), + LocalSourceSchemas = newSourceSchemas + .Select(s => s.Name) + .ToFrozenSet(StringComparer.Ordinal), ExternalEnvironment = externalEnvironment }; + return ComposeAsync( + fusionArchivePath, + seedArchivePath, + newSourceSchemas, + environment, + settings, + stageSettings: null, + settingsComposerOptions, + logger, + cancellationToken); + } + + private static async Task ComposeAsync( + string fusionArchivePath, + string? seedArchivePath, + ImmutableArray newSourceSchemas, + string environment, + GraphQLCompositionSettings settings, + CompositionSettings? stageSettings, + SettingsComposerOptions settingsComposerOptions, + ILogger logger, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(environment); + + using var archive = OpenArchive(fusionArchivePath, seedArchivePath); + + return await ComposeAsync( + archive, + newSourceSchemas, + environment, + settings, + stageSettings, + settingsComposerOptions, + logger, + cancellationToken); + } + + private static async Task ComposeAsync( + FusionArchive archive, + ImmutableArray newSourceSchemas, + string environment, + GraphQLCompositionSettings settings, + CompositionSettings? stageSettings, + SettingsComposerOptions settingsComposerOptions, + ILogger logger, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(environment); + + var compositionLog = new CompositionLog(); + var compositionSettings = CreateCompositionSettings(settings, stageSettings); + var sourceSchemas = newSourceSchemas.ToDictionary( + s => s.Name, + s => (s.Schema, s.SchemaSettings)); + var result = await CompositionHelper.ComposeAsync( compositionLog, sourceSchemas, @@ -193,10 +421,45 @@ internal static Dictionary BuildLocalUrlOverrides( return uri.AbsolutePath; } + /// + /// Resolves the settings of a single source schema against the given environment, so that the + /// resulting document no longer carries environment specific overrides. + /// + internal static JsonDocument ResolveSourceSchemaSettings( + JsonDocument sourceSchemaSettings, + string environmentName) + { + ArgumentNullException.ThrowIfNull(sourceSchemaSettings); + ArgumentException.ThrowIfNullOrWhiteSpace(environmentName); + + using var buffer = new PooledArrayWriter(); + new SettingsComposer().Compose( + buffer, + [sourceSchemaSettings.RootElement], + environmentName, + SettingsComposerOptions.Default, + new CompositionLog()); + using var gatewaySettings = JsonDocument.Parse(buffer.WrittenMemory); + var sourceSchemas = gatewaySettings.RootElement + .GetProperty("sourceSchemas"); + var resolvedSettings = sourceSchemas + .EnumerateObject() + .Single() + .Value; + + return JsonSerializer.SerializeToDocument(resolvedSettings); + } + + /// + /// Creates the composition settings of a composition. The settings that the distributed + /// application declares win, only fills the values that they + /// leave unset. + /// internal static CompositionSettings CreateCompositionSettings( - GraphQLCompositionSettings settings) + GraphQLCompositionSettings settings, + CompositionSettings? stageSettings) { - return new CompositionSettings + var localSettings = new CompositionSettings { Merger = { @@ -213,5 +476,36 @@ internal static CompositionSettings CreateCompositionSettings( }, Preprocessor = { ExcludeByTag = settings.ExcludeByTag?.ToHashSet() } }; + + return stageSettings is null + ? localSettings + : localSettings.MergeInto(stageSettings); + } +} + +internal readonly record struct SourceSchemaArchiveInfo +{ + private readonly string? _archivePath; + private readonly byte[]? _archive; + + public SourceSchemaArchiveInfo(string name, string archivePath) + { + Name = name; + _archivePath = archivePath; + } + + public SourceSchemaArchiveInfo( + string name, + byte[] archive) + { + Name = name; + _archive = archive; } + + public string Name { get; } + + public Stream OpenRead() + => _archivePath is null + ? new MemoryStream(_archive!, writable: false) + : File.OpenRead(_archivePath); } diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/CommandLineSchemaExporter.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/CommandLineSchemaExporter.cs new file mode 100644 index 00000000000..f38db0480be --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/CommandLineSchemaExporter.cs @@ -0,0 +1,458 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using Aspire.Hosting.ApplicationModel; +using IOPath = System.IO.Path; + +namespace HotChocolate.Fusion.Aspire; + +internal static class CommandLineSchemaExporter +{ + private const int MaxCapturedOutputLength = 32 * 1024; + private static readonly TimeSpan s_processTerminationTimeout = TimeSpan.FromSeconds(10); + + public static async Task ExportAsync( + IResource resource, + GraphQLSourceSchemaAnnotation declaration, + string outputDirectory, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(declaration); + ArgumentException.ThrowIfNullOrWhiteSpace(outputDirectory); + + if (declaration is not + { + Location: SourceSchemaLocationType.CommandLineExport, + ExportSchemaName: { } schemaName, + ExportConfiguration: { } configuration, + ExportTargetFramework: { } targetFramework, + ExportTimeout: { } timeout + } + || string.IsNullOrWhiteSpace(schemaName) + || string.IsNullOrWhiteSpace(configuration) + || string.IsNullOrWhiteSpace(targetFramework) + || timeout <= TimeSpan.Zero + || declaration.ExportRuntimeIdentifier is { } runtimeIdentifier + && string.IsNullOrWhiteSpace(runtimeIdentifier)) + { + throw new InvalidOperationException( + $"Resource '{resource.Name}' does not have a complete command-line export declaration."); + } + + cancellationToken.ThrowIfCancellationRequested(); + + var projectPath = IOPath.GetFullPath(GraphQLResourceModel.GetProjectPath(resource)); + outputDirectory = IOPath.GetFullPath(outputDirectory); + Directory.CreateDirectory(outputDirectory); + + var schemaPath = IOPath.Combine(outputDirectory, "schema.graphqls"); + var settingsPath = IOPath.Combine(outputDirectory, "schema-settings.json"); + + DeleteExistingArtifact(schemaPath); + DeleteExistingArtifact(settingsPath); + + var startInfo = CreateStartInfo( + projectPath, + schemaPath, + schemaName, + configuration, + targetFramework, + declaration.ExportRuntimeIdentifier); + var processResult = await RunProcessAsync( + startInfo, + timeout, + resource.Name, + cancellationToken); + + if (processResult.ExitCode != 0) + { + throw new InvalidOperationException( + $"Schema export for resource '{resource.Name}' exited with code " + + $"{processResult.ExitCode}. Child-process output was suppressed because it may " + + "contain sensitive data."); + } + + await ValidateArtifactsAsync( + resource.Name, + schemaName, + schemaPath, + settingsPath, + cancellationToken); + + return new( + schemaPath, + settingsPath, + projectPath, + configuration, + targetFramework, + declaration.ExportRuntimeIdentifier); + } + + internal static ProcessStartInfo CreateStartInfo( + string projectPath, + string schemaPath, + string schemaName, + string configuration, + string targetFramework, + string? runtimeIdentifier) + { + ArgumentException.ThrowIfNullOrWhiteSpace(projectPath); + ArgumentException.ThrowIfNullOrWhiteSpace(schemaPath); + ArgumentException.ThrowIfNullOrWhiteSpace(schemaName); + ArgumentException.ThrowIfNullOrWhiteSpace(configuration); + ArgumentException.ThrowIfNullOrWhiteSpace(targetFramework); + + if (runtimeIdentifier is not null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(runtimeIdentifier); + } + + projectPath = IOPath.GetFullPath(projectPath); + schemaPath = IOPath.GetFullPath(schemaPath); + + var startInfo = new ProcessStartInfo + { + FileName = "dotnet", + WorkingDirectory = IOPath.GetDirectoryName(projectPath)!, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + startInfo.ArgumentList.Add("run"); + startInfo.ArgumentList.Add("--project"); + startInfo.ArgumentList.Add(projectPath); + startInfo.ArgumentList.Add("--configuration"); + startInfo.ArgumentList.Add(configuration); + startInfo.ArgumentList.Add("--framework"); + startInfo.ArgumentList.Add(targetFramework); + + if (runtimeIdentifier is not null) + { + startInfo.ArgumentList.Add("--runtime"); + startInfo.ArgumentList.Add(runtimeIdentifier); + } + + startInfo.ArgumentList.Add("--no-launch-profile"); + startInfo.ArgumentList.Add("--"); + startInfo.ArgumentList.Add("schema"); + startInfo.ArgumentList.Add("export"); + startInfo.ArgumentList.Add("--output"); + startInfo.ArgumentList.Add(schemaPath); + startInfo.ArgumentList.Add("--schema-name"); + startInfo.ArgumentList.Add(schemaName); + + ApplyEnvironmentAllowList(startInfo); + return startInfo; + } + + internal static async Task RunProcessAsync( + ProcessStartInfo startInfo, + TimeSpan timeout, + string resourceName, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(startInfo); + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(timeout, TimeSpan.Zero); + ArgumentException.ThrowIfNullOrWhiteSpace(resourceName); + cancellationToken.ThrowIfCancellationRequested(); + + using var process = new Process { StartInfo = startInfo }; + using var timeoutSource = new CancellationTokenSource(timeout); + using var linkedSource = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeoutSource.Token); + using var outputSource = new CancellationTokenSource(); + + if (!process.Start()) + { + throw new InvalidOperationException( + $"Could not start schema export for resource '{resourceName}'."); + } + + var stdout = CaptureAsync( + process.StandardOutput, + retainOutput: true, + outputSource.Token); + var stderr = CaptureAsync( + process.StandardError, + retainOutput: false, + outputSource.Token); + + try + { + try + { + await process.WaitForExitAsync(linkedSource.Token); + } + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested + || timeoutSource.IsCancellationRequested) + { + await TerminateProcessAsync( + process, + stdout, + stderr, + outputSource); + + if (cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException(cancellationToken); + } + + throw new TimeoutException( + $"Schema export for resource '{resourceName}' exceeded {timeout}."); + } + catch + { + await TerminateProcessAsync( + process, + stdout, + stderr, + outputSource); + throw; + } + + var captured = await Task.WhenAll(stdout, stderr); + return new( + process.ExitCode, + captured[0].Output, + captured[0].WasTruncated, + captured[1].CharacterCount); + } + finally + { + if (!HasExited(process)) + { + await TerminateProcessAsync( + process, + stdout, + stderr, + outputSource); + } + } + } + + internal static void ApplyEnvironmentAllowList(ProcessStartInfo startInfo) + { + ArgumentNullException.ThrowIfNull(startInfo); + + var inheritedEnvironment = startInfo.Environment.ToArray(); + var comparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + + startInfo.Environment.Clear(); + + CopyIfPresent("PATH"); + CopyIfPresent("DOTNET_ROOT"); + CopyIfPresent("DOTNET_ROOT_X64"); + CopyIfPresent("DOTNET_ROOT_X86"); + CopyIfPresent("DOTNET_ROOT_ARM64"); + CopyIfPresent("DOTNET_CLI_HOME"); + CopyIfPresent("NUGET_PACKAGES"); + + if (OperatingSystem.IsWindows()) + { + CopyIfPresent("SystemRoot"); + CopyIfPresent("SystemDrive"); + CopyIfPresent("USERPROFILE"); + CopyIfPresent("LOCALAPPDATA"); + } + else + { + CopyIfPresent("HOME"); + } + + CopyIfPresent("TMPDIR"); + CopyIfPresent("TMP"); + CopyIfPresent("TEMP"); + + startInfo.Environment["DOTNET_NOLOGO"] = "1"; + startInfo.Environment["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1"; + + void CopyIfPresent(string name) + { + foreach (var pair in inheritedEnvironment) + { + if (pair.Key.Equals(name, comparison) + && !string.IsNullOrEmpty(pair.Value)) + { + startInfo.Environment[name] = pair.Value; + return; + } + } + } + } + + internal static async Task ValidateArtifactsAsync( + string resourceName, + string schemaName, + string schemaPath, + string settingsPath, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(resourceName); + ArgumentException.ThrowIfNullOrWhiteSpace(schemaName); + ArgumentException.ThrowIfNullOrWhiteSpace(schemaPath); + ArgumentException.ThrowIfNullOrWhiteSpace(settingsPath); + + if (!File.Exists(schemaPath) || !File.Exists(settingsPath)) + { + throw new InvalidOperationException( + $"Schema export for resource '{resourceName}' did not produce both " + + "schema.graphqls and schema-settings.json."); + } + + var schemaText = await File.ReadAllTextAsync(schemaPath, cancellationToken); + var settingsText = await File.ReadAllTextAsync(settingsPath, cancellationToken); + + if (string.IsNullOrWhiteSpace(settingsText)) + { + throw new InvalidOperationException( + $"Schema export for resource '{resourceName}' produced empty schema-settings.json."); + } + + using var settings = JsonDocument.Parse(settingsText); + var configuration = SchemaComposition.ReadEndpointConfiguration( + resourceName, + schemaName, + settings); + GraphQLSourceSchemaValidator.Validate( + resourceName, + configuration, + schemaText); + } + + private static async Task CaptureAsync( + StreamReader reader, + bool retainOutput, + CancellationToken cancellationToken) + { + StringBuilder? output = retainOutput + ? new StringBuilder(MaxCapturedOutputLength) + : null; + var buffer = new char[4096]; + var characterCount = 0L; + + while (true) + { + var count = await reader.ReadAsync(buffer.AsMemory(), cancellationToken); + if (count == 0) + { + break; + } + + characterCount += count; + + if (output?.Length is < MaxCapturedOutputLength) + { + output.Append( + buffer, + 0, + Math.Min(count, MaxCapturedOutputLength - output.Length)); + } + } + + return new( + output?.ToString() ?? string.Empty, + characterCount, + characterCount > MaxCapturedOutputLength); + } + + private static async Task TerminateProcessAsync( + Process process, + Task stdout, + Task stderr, + CancellationTokenSource outputSource) + { + TryKillProcess(process, entireProcessTree: true); + + if (!HasExited(process)) + { + TryKillProcess(process, entireProcessTree: false); + } + + using var cleanupSource = new CancellationTokenSource(s_processTerminationTimeout); + + try + { + await process.WaitForExitAsync(cleanupSource.Token); + await Task.WhenAll(stdout, stderr).WaitAsync(cleanupSource.Token); + } + catch (OperationCanceledException) when (cleanupSource.IsCancellationRequested) + { + outputSource.Cancel(); + } + catch (IOException) + { + outputSource.Cancel(); + } + catch (ObjectDisposedException) + { + outputSource.Cancel(); + } + } + + private static bool HasExited(Process process) + { + try + { + return process.HasExited; + } + catch (InvalidOperationException) + { + return true; + } + } + + private static void TryKillProcess(Process process, bool entireProcessTree) + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree); + } + } + catch (InvalidOperationException) + { + } + catch (NotSupportedException) + { + } + catch (Win32Exception) + { + } + } + + private static void DeleteExistingArtifact(string path) + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + + private readonly record struct CapturedOutput( + string Output, + long CharacterCount, + bool WasTruncated); +} + +internal sealed record CommandLineSchemaExportResult( + string SchemaPath, + string SettingsPath, + string ProjectPath, + string Configuration, + string TargetFramework, + string? RuntimeIdentifier); + +internal sealed record CommandLineProcessResult( + int ExitCode, + string StandardOutput, + bool StandardOutputWasTruncated, + long StandardErrorCharacterCount); diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/FUSION_PUBLISHING.md b/src/HotChocolate/Fusion/src/Fusion.Aspire/FUSION_PUBLISHING.md new file mode 100644 index 00000000000..f5c7c6b499b --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/FUSION_PUBLISHING.md @@ -0,0 +1,204 @@ +# Publishing Fusion with Aspire + +Fusion releases use Nitro itself as the handoff between the build job and deployment jobs. The +public commands are: + +```shell +aspire do fusion-upload +aspire do fusion-publish +``` + +There is no release manifest parameter and no artifact upload or download between these commands. +Both invocations must evaluate the same AppHost composition and use the same `tag` value. + +## AppHost declaration + +Each api declares the stages it publishes to. An invocation names one of them: + +```csharp +var stage = builder.AddParameter("stage"); +var tag = builder.AddParameter("tag"); +var nitroApiKey = builder.AddParameter("nitroApiKey", secret: true); + +var nitro = builder.AddNitroPublishTarget("nitro") + .WithNitroCloudUrl("https://api.chillicream.com") + .WithNitroApiId("products-fusion") + .WithNitroApiKey(nitroApiKey) + .WithStageParameter(stage) + .WithConfigurationTag(tag); + +nitro.AddStage("development"); +nitro.AddStage("production") + .WithApproval(waitForApproval: true) + .WithForce(false) + .WithTimeouts( + operation: TimeSpan.FromMinutes(15), + approval: TimeSpan.FromHours(2)); +``` + +`AddStage` declares a stage of the api. `WithStageParameter` sets the parameter that names the +stage an invocation publishes to, and the publication fails when that parameter names a stage the +api does not declare. `WithConfigurationTag` supplies both the source version and the Fusion +configuration tag, and belongs to the api because one release carries the same tag to every stage. +`WithCompositionEnvironment` selects the environment block in each source's `schema-settings.json` +and defaults to the composition environment of the AppHost followed by the stage name. `WithApproval`, +`WithForce`, and `WithTimeouts` configure the publication of a single stage. + +Each command needs only the parameters it uses, so `fusion-upload` needs a tag and `fusion-publish` +needs a tag and a stage. Values reach the AppHost through configuration, either as environment +variables or as arguments forwarded after `--`: + +```shell +Parameters__tag=v1 aspire do fusion-upload +Parameters__stage=production Parameters__tag=v1 aspire do fusion-publish + +aspire do fusion-publish -- --Parameters:stage=production --Parameters:tag=v1 +``` + +Prefer the environment for secrets such as the Nitro API key, because a command line is visible in +the process list. + +`WithNitroCloudUrl` and `WithNitroApiId` default to the `Nitro:CloudUrl` and `Nitro:ApiId` +configuration values, or to the `NITRO_CLOUD_URL` and `NITRO_API_ID` environment variables. When +`WithNitroApiKey` is not configured, the credential is resolved from `NITRO_API_KEY` or the Nitro +CLI session. + +The effective source name is `SourceSchemaName` when explicitly declared, otherwise the Aspire +resource name. Effective names must be unique and portable path segments. + +## Upload + +The build job checks out the source and uploads once per Nitro api: + +```shell +export Parameters__tag="$RELEASE_TAG" +export Parameters__nitroApiKey="$NITRO_API_KEY" + +aspire do fusion-upload \ + --apphost ./src/AppHost/AppHost.csproj \ + --non-interactive +``` + +`fusion-upload` depends on `fusion-artifacts`. For every source in the current AppHost composition +it: + +1. acquires and validates the schema and settings; +2. creates the portable source archive; +3. assigns the exact version `name@tag`; +4. rejects loopback endpoints for the composition environment of every declared stage; and +5. reconciles that immutable version on the Nitro API. + +An immutable source version serves every stage of its api, so upload takes no stage. Each declared +api is uploaded once. + +`aspire publish` remains an artifact-only root. It can create local Fusion source artifacts but it +does not resolve a Nitro credential or mutate Nitro. Automation normally invokes `fusion-upload` +directly because the artifact step is already its dependency. + +## Publish + +Deployment jobs check out the same AppHost revision and use the same tag, but they do not need the +source schema files or a build-job artifact: + +```shell +export Parameters__stage=development +export Parameters__tag="$RELEASE_TAG" +export Parameters__nitroApiKey="$NITRO_API_KEY" + +aspire do fusion-publish \ + --apphost ./src/AppHost/AppHost.csproj \ + --non-interactive +``` + +Publish resolves the stage that the stage parameter names, and fails when the api does not declare +it. It infers the complete, sorted source-name set from the current AppHost. Before source compute +is changed it downloads every exact `name@tag` from the Nitro API. A missing source fails +the deployment. This preflight records only identities and canonical content digests, then clears +the archive buffers before provider deployment begins. + +After every source provider deploys, composition downloads the exact versions again, rejects any +canonical digest change from preflight, and resolves their settings with the current AppHost +composition options and selected composition environment. Readiness and publication +revalidate the session state and composed FAR digest. Publish never exports a schema, reads a +source checkout, invokes Git, or calls the source-upload API. The Fusion-specific download, +composition, readiness, and publication steps create no Fusion apply-state files and do not resolve +Aspire's output-path service. Provider-contributed deployment dependencies may still write target +artifacts or Aspire deployment state. Source archives are limited to 128,000,000 bytes each and +512,000,000 bytes in aggregate. Owned buffers are cleared after success, failure, or cancellation. + +## Ordering + +The build-side graph is independent of deployment: + +```text +fusion-artifacts -> fusion-upload +``` + +The deployment graph is: + +```text +fusion-download -> source DeployCompute -> fusion-compose -> fusion-readiness + +fusion-readiness + -> fusion-publish-stage + -> gateway DeployCompute + -> fusion-publish +``` + +`fusion-download` is a fail-before-compute, metadata-only preflight. `fusion-compose` performs the +second exact download after source compute. `fusion-publish-stage` is internal. The public +`fusion-publish` step is terminal and completes only after gateway deployment. The broader +`aspire deploy` root requires the same terminal step. + +The Aspire adapter recognizes direct `DeployCompute` steps and deployment-target contributed +`DeployCompute` steps. It fails closed when it cannot prove source and gateway compute ordering. + +## CI shape + +Use one immutable `RELEASE_TAG` for every job in a rollout: + +```yaml +env: + RELEASE_TAG: ${{ github.sha }} + +jobs: + fusion-upload: + env: + Parameters__tag: ${{ env.RELEASE_TAG }} + Parameters__nitroApiKey: ${{ secrets.NITRO_API_KEY }} + steps: + - uses: actions/checkout@v4 + - run: >- + aspire do fusion-upload + --apphost ./src/AppHost/AppHost.csproj + --non-interactive + + deploy-development: + needs: fusion-upload + env: + Parameters__stage: development + Parameters__tag: ${{ env.RELEASE_TAG }} + Parameters__nitroApiKey: ${{ secrets.NITRO_API_KEY }} + steps: + - uses: actions/checkout@v4 + - run: >- + aspire do fusion-publish + --apphost ./src/AppHost/AppHost.csproj + --non-interactive +``` + +There is intentionally no artifact handoff. Use stable concurrency keys per Nitro API for upload +and per Nitro stage for publish. Queue writers instead of cancelling them. + +## Failure and retry behavior + +- An existing `name@tag` with identical canonical content is a successful reconcile. +- An existing `name@tag` with different content is an immutable-version collision and fails. +- A missing exact source during publish fails before source compute deployment. +- A changed AppHost source set, tag, target, downloaded archive, composition environment, or FAR + fails in-memory session validation. +- A transient source endpoint is polled until the configured operation timeout. +- Approval rejection, timeout, failed commit, or unverified terminal Nitro state fails publication. + +The tag is the rollout identity. Use a new tag whenever the source content or intended rollout +changes, and reuse the same tag only for retries of that identical rollout. diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/FUSION_PUBLISH_DEPLOY_DESIGN.md b/src/HotChocolate/Fusion/src/Fusion.Aspire/FUSION_PUBLISH_DEPLOY_DESIGN.md new file mode 100644 index 00000000000..585afc2f477 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/FUSION_PUBLISH_DEPLOY_DESIGN.md @@ -0,0 +1,325 @@ +# Fusion publish and deploy with Aspire + +## Decision + +The canonical release commands are: + +```shell +aspire do fusion-upload --environment +aspire do fusion-publish --environment +``` + +`fusion-upload` exports and uploads immutable source schemas. `fusion-publish` downloads and +verifies the exact uploaded source versions, composes for the selected environment, deploys source +compute, checks readiness, publishes the Fusion configuration to Nitro, deploys the gateway, and +completes its terminal step. + +Nitro is the handoff between the two commands. There is no release-manifest parameter, CI artifact +upload/download, source archive handoff, or dependency from `fusion-publish` to `fusion-upload`. +Both invocations evaluate the same AppHost revision and receive the same `tag` parameter. + +`aspire publish` remains artifact-only for Fusion. It may create local source inputs but does not +resolve a Nitro credential or mutate Nitro. The broader `aspire deploy` root is supported and +requires the same public `fusion-publish` terminal. + +## Aspire integration boundary + +The implementation targets the repository's Aspire 13.4 integration. Aspire 13.4 made +`aspire publish` and `aspire deploy` generally available, while the programmatic custom-pipeline +APIs remain experimental and emit `ASPIREPIPELINES001`. Keep those APIs behind the small local +pipeline adapter. Sources: [deployment pipelines](https://aspire.dev/deployment/pipelines/), +[ASPIREPIPELINES001](https://aspire.dev/diagnostics/aspirepipelines001/), +[`aspire publish`](https://aspire.dev/reference/cli/commands/aspire-publish/), +[`aspire deploy`](https://aspire.dev/reference/cli/commands/aspire-deploy/), and +[`aspire do`](https://aspire.dev/reference/cli/commands/aspire-do/). + +The adapter recognizes provider steps tagged `DeployCompute`, including steps contributed through +an Aspire `DeploymentTarget`. It does not treat provisioning as successful compute deployment. If +the adapter cannot identify deploy-compute steps for a source or gateway resource, publication +fails closed instead of claiming safe ordering. + +## AppHost surface + +```csharp +var tag = builder.AddParameter("tag"); +var nitroApiKey = builder.AddParameter("nitroApiKey", secret: true); + +var nitro = builder + .AddNitroPublishTarget("nitro") + .WithNitroCloudUrl("https://api.chillicream.com") + .WithNitroApiId("products-fusion") + .WithNitroApiKey(nitroApiKey); + +nitro + .AddStage("production") + .WithCompositionEnvironment("production") + .WithApproval(waitForApproval: true) + .WithForce(false) + .WithTimeouts( + operation: TimeSpan.FromMinutes(15), + approval: TimeSpan.FromHours(2)); +``` + +`AddStage` declares a stage of the api. `WithStageParameter` names the parameter that selects the +stage of an invocation, and an unknown stage name is rejected. `WithCompositionEnvironment` selects +the source-settings environment. `WithConfigurationTag` supplies the immutable source version and +final configuration tag for every stage of the api. + +An AppHost can declare several apis, and each api can declare several stages. One invocation +publishes to exactly one stage per api. + +## Source declaration and acquisition + +Fusion sources come from the resources referenced by the single AppHost composition resource. +Supported acquisition modes are: + +1. checked-in `schema.graphqls` and `schema-settings.json` files; and +2. explicit command-line schema export. + +Runtime HTTP introspection is not accepted during publication. File-based input is preferred for +deterministic, auditable CI. + +The effective source name is the explicitly declared source-schema name when present, otherwise +the Aspire resource name. The implementation validates that effective names are unique, portable, +and exactly match the name in source settings. Duplicate names fail before any path can be +overwritten or any publication can become ambiguous. + +Command-line export must validate the expected schema and settings files, not only exit code zero. +It runs without a launch profile, records the project/configuration/framework/runtime inputs, and +rejects missing or empty output. + +## Identity + +One rollout uses one immutable `tag`. Upload assigns that value to every source in the declared +set, producing exact identities such as: + +```text +products@build-842-a1b2c3d4 +reviews@build-842-a1b2c3d4 +``` + +Publish uses the same value as the Fusion configuration tag. A Git commit is useful provenance but +is not the public rollout identity. A rebuild, endpoint change, composition change, or different +desired rollout requires a new tag. Retries of the identical rollout reuse the same tag. + +Recommended configuration precedence is explicit AppHost values and parameter resources first, +then documented compatibility configuration. Secrets come from CI or a secret provider and are +never written to output or logs. + +| Concern | Recommended input | +| --- | --- | +| Cloud URL | Explicit `.WithNitroCloudUrl(...)` HTTPS origin | +| API ID | Explicit `.WithNitroApiId(...)` | +| API key | Secret `ParameterResource` | +| Nitro stages | Explicit `.AddStage(...)` per stage | +| Selected stage | `.WithStageParameter(...)`, resolved per invocation | +| Rollout/source/configuration tag | `builder.AddParameter("tag")` | + +## `fusion-upload` + +The upload command writes immutable source versions, which every stage of an api shares, so it +needs no stage. It: + +1. resolves the current AppHost composition and complete effective source-name set; +2. exports or reads every source and validates schema, settings, extensions, and endpoint binding; +3. materializes every archive as `name@tag`; +4. computes its raw archive digest and canonical Fusion content digest; and +5. reconciles the immutable version on the selected Nitro API. + +An existing version with identical canonical content is success. An existing version with +different content is an immutable-version collision and fails. Partial uploads may remain orphaned +if a later source fails, but no Nitro stage is changed by upload. + +The build job needs only the release tag: + +```shell +export Parameters__tag="$RELEASE_TAG" +export Parameters__nitroApiKey="$NITRO_API_KEY" + +aspire do fusion-upload \ + --apphost ./src/AppHost/AppHost.csproj \ + --non-interactive +``` + +An immutable source version serves every stage of its api, so one upload covers all of them. Each +declared api is uploaded once. + +## `fusion-publish` + +Publish does not need a build-job artifact and does not read source schema files. It uses the +current AppHost only as the authority for: + +- exact effective source names; +- target cloud URL and API ID; +- selected stage and composition environment; and +- current composition settings. + +The command performs these release-critical phases: + +1. select the current environment declaration and resolve `tag`; +2. infer and sort the complete effective source-name set; +3. preflight-download each exact `name@tag`, record its canonical digest, and clear the archive + buffers before source compute starts; +4. deploy source compute; +5. download the exact versions again and require their canonical digests to match preflight; +6. compose a FAR from only those second-download archives using current AppHost composition settings + and the selected composition environment; +7. revalidate source digests, composition environment, and FAR digest, then poll the composed + production endpoints until ready; +8. publish the FAR and exact source references to the selected Nitro stage; +9. wait for approval when configured and verify the terminal Nitro result; +10. deploy gateway compute; and +11. complete the public terminal step. + +A missing exact source fails during download before source compute changes. Publish never calls the +source reconciliation API, runs schema export, reads a source checkout, or invokes Git. The +Fusion-specific download, composition, readiness, and publication steps create no Fusion +apply-state directory and do not resolve Aspire's output-path service. Provider-contributed +deployment dependencies may still write target artifacts or Aspire deployment state. + +## Pipeline graph and first release + +Build-side work is independent: + +```text +fusion-artifacts -> fusion-upload +``` + +Deployment uses this graph: + +```text +fusion-download -> source DeployCompute -> fusion-compose -> fusion-readiness + +fusion-readiness + -> fusion-publish-stage + -> gateway DeployCompute + -> fusion-publish +``` + +Every source deploy-compute step depends on `fusion-download`, so the exact Nitro source set is a +fail-before-compute preflight. The preflight retains only identities and canonical digests, not +archive bytes. Composition runs only after every source deploy-compute step, re-downloads the exact +versions, and rejects any digest change from preflight. The internal `fusion-publish-stage` step +runs only after readiness. Gateway deployment depends on stage publication, which is required for a +first release because no gateway can start from Nitro before the first FAR exists. The public +`fusion-publish` terminal depends on gateway deployment and is required by the broader Deploy root. + +The ordering is intentionally preflight, source deploy, exact re-download and composition, +readiness, internal Nitro publication, gateway deploy, terminal public publication. Upload is never +in the transitive dependency set of the public publish command. + +## Invocation-memory integrity + +Each `fusion-publish` invocation owns a private in-memory session shared only by its pipeline step +closures. Preflight retains only small source identities and canonical digests while provider steps +run. Exact archive bytes are downloaded again after source compute, compared with preflight, and +leased while composition, readiness, or publication reads them. Cancellation requests cleanup but +does not zero an actively leased buffer until its reader unwinds. The session retains no credentials +and writes no source archive, state file, apply directory, or composed FAR to disk. +Source archives are limited to 128,000,000 bytes each and 512,000,000 bytes in aggregate. + +Compose, readiness, and publish validate: + +- configured tag equals recorded tag; +- normalized cloud URL and API ID equal the selected target; +- recorded source names exactly equal the current sorted AppHost set; +- every source version equals the tag; +- every in-memory archive still has its recorded canonical content digest; +- the composition environment still matches the current declaration; and +- the FAR still has its recorded raw digest. + +All owned source and FAR buffers are cleared after success, failure, or cancellation. A retry starts +a new isolated session and downloads the exact source versions from Nitro again. + +## Readiness, approval, and retries + +Readiness comes from the composed gateway settings, not an arbitrary Aspire liveness endpoint. +Loopback URLs are rejected for deployment. A response below HTTP 500 is considered reachable; +transport failures and server errors are retried until the configured operation timeout. + +Publication is successful only after Nitro reports a verified terminal result. Approval rejection, +approval timeout, failed commit, polling timeout, or an unverified terminal state fails the step. + +Retry rules: + +- same `name@tag` and same canonical content: upload no-op success; +- same `name@tag` and different content: collision failure; +- missing `name@tag`: publish failure before compute; +- changed AppHost source set, target, tag, downloaded content, environment, or FAR: integrity + failure; and +- transient readiness/Nitro polling failure: retry the same rollout with the same tag after the + cause is resolved. + +Serialize writers per Nitro API during upload and per Nitro stage during publication. Queue writers +instead of cancelling them. Repository-local concurrency is insufficient when another repository +or deployment system can write the same target. + +## CI shape + +All jobs check out the same revision and receive the same `RELEASE_TAG`: + +```yaml +env: + RELEASE_TAG: ${{ github.sha }} + +jobs: + upload: + env: + Parameters__tag: ${{ env.RELEASE_TAG }} + Parameters__nitroApiKey: ${{ secrets.NITRO_API_KEY }} + steps: + - uses: actions/checkout@v4 + - run: >- + aspire do fusion-upload + --apphost ./src/AppHost/AppHost.csproj + --non-interactive + + deploy-development: + needs: upload + env: + Parameters__stage: development + Parameters__tag: ${{ env.RELEASE_TAG }} + Parameters__nitroApiKey: ${{ secrets.NITRO_API_KEY }} + steps: + - uses: actions/checkout@v4 + - run: >- + aspire do fusion-publish + --apphost ./src/AppHost/AppHost.csproj + --non-interactive + + deploy-test: + needs: deploy-development + env: + Parameters__stage: test + Parameters__tag: ${{ env.RELEASE_TAG }} + Parameters__nitroApiKey: ${{ secrets.NITRO_API_KEY }} + steps: + - uses: actions/checkout@v4 + - run: >- + aspire do fusion-publish + --apphost ./src/AppHost/AppHost.csproj + --non-interactive +``` + +There is deliberately no artifact upload/download step and no public `releaseId` or manifest +parameter. + +## Verification matrix + +The implementation is complete only when focused tests and a real materialized AppHost prove: + +| Scenario | Required result | +| --- | --- | +| Environment selection | Only the matching declaration is used; ambiguous mappings fail. | +| Complete source set | Duplicate effective names and missing exact downloads fail. | +| Cross-runner publish | Publish succeeds with AppHost metadata and Nitro downloads but no schema files, Git metadata, or upload artifact. | +| Fusion-only disk behavior | Fusion download, composition, readiness, and publication succeed without resolving `IPipelineOutputService` or writing source archives, apply state, or a FAR. Provider dependencies own their target output and deployment state. | +| Invocation isolation | Interleaved environments and repeated deployments use separate sessions with no retained state. | +| Cleanup | Success, failure, and cancellation clear all owned source and FAR buffers. | +| Memory bounds | Oversized individual sources, aggregate sources, and FAR output fail with explicit diagnostics. | +| Environment composition | The same `name@tag` archives compose different Development/Test endpoints. | +| Integrity | Tag, target, source-set, archive, environment, and FAR drift fail. | +| Provider ordering | Source deploy waits for download; readiness waits for source compute; gateway waits for Nitro publication. | +| First release | Nitro stage publication precedes gateway deployment. | +| Command surface | Real `aspire do --list-steps` exposes `fusion-upload` and terminal `fusion-publish`. | +| Compatibility | Build and focused tests pass for the repository's supported target frameworks. | diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/FusionPipeline.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/FusionPipeline.cs new file mode 100644 index 00000000000..df0c4337c10 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/FusionPipeline.cs @@ -0,0 +1,438 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Pipelines; +using HotChocolate.Fusion.Aspire; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace HotChocolate.Fusion.Aspire; + +#pragma warning disable ASPIREPIPELINES001 + +internal static class FusionPipeline +{ + internal const string ArtifactsStepName = "fusion-artifacts"; + internal const string DownloadStepName = "fusion-download"; + internal const string ComposeStepName = "fusion-compose"; + internal const string ReadinessStepName = "fusion-readiness"; + internal const string UploadStepName = "fusion-upload"; + internal const string PublishStageStepName = "fusion-publish-stage"; + internal const string PublishStepName = "fusion-publish"; + + public static void Configure( + IResourceBuilder pipeline) + { + var topology = new FusionPipelineTopology(); + + pipeline.WithPipelineStepFactory( + context => CreateSteps(context, topology)); + pipeline.WithPipelineConfiguration( + context => ConfigureSteps(context, topology)); + } + + /// + /// Gets every Nitro api that the distributed application publishes to. The sources an api + /// receives are the same for every stage, so the steps that only write immutable source + /// versions select apis instead of stages and need no stage. + /// + internal static IReadOnlyList SelectTargets( + DistributedApplicationModel model) + { + var targets = model.Resources + .OfType() + .Where(target => GetStages(model, target).Count > 0) + .ToArray(); + + foreach (var target in targets) + { + ValidateDeclaration(target); + } + + return targets; + } + + /// + /// Gets the stage that each Nitro api publishes to in this invocation, resolved from the stage + /// parameter of the api. + /// + internal static async Task> SelectStagesAsync( + DistributedApplicationModel model, + CancellationToken cancellationToken) + { + var selected = new List(); + + foreach (var target in SelectTargets(model)) + { + var stages = GetStages(model, target); + var stageName = await target.StageParameter!.GetValueAsync(cancellationToken); + + if (string.IsNullOrWhiteSpace(stageName)) + { + throw new InvalidOperationException( + $"Nitro target '{target.Name}' stage parameter " + + $"'{target.StageParameter.Name}' resolved to an empty value."); + } + + var stage = stages.FirstOrDefault( + candidate => string.Equals( + candidate.StageName, + stageName, + StringComparison.Ordinal)); + + if (stage is null) + { + throw new InvalidOperationException( + $"Nitro target '{target.Name}' does not declare the stage '{stageName}'. " + + $"Declared stages: {string.Join(", ", stages.Select(s => s.StageName).Order(StringComparer.Ordinal))}."); + } + + selected.Add(stage); + } + + return selected; + } + + internal static IReadOnlyList GetStages( + DistributedApplicationModel model, + NitroPublishTargetResource target) + => model.Resources + .OfType() + .Where(stage => ReferenceEquals(stage.Nitro, target)) + .ToArray(); + + internal static IResourceWithEndpoints GetCompositionResource( + DistributedApplicationModel model) + { + var compositions = model.GetGraphQLCompositionResources().ToArray(); + return compositions.Length switch + { + 1 => compositions[0], + 0 => throw new InvalidOperationException( + "A Fusion deployment requires one resource with GraphQL schema composition."), + _ => throw new InvalidOperationException( + "A Fusion deployment requires exactly one resource with GraphQL schema composition.") + }; + } + + internal static IEnumerable CreateSteps( + PipelineStepFactoryContext context, + FusionPipelineTopology topology) + { + topology.HasDeployments = + SelectTargets(context.PipelineContext.Model).Count > 0; + + var session = new FusionPipelineSession( + context.PipelineContext.CancellationToken); + return CreateStepDefinitions( + context.Resource, + topology, + session); + } + + private static PipelineStep[] CreateStepDefinitions( + IResource resource, + FusionPipelineTopology topology, + FusionPipelineSession session) + { + var buildSteps = new[] + { + new PipelineStep + { + Name = ArtifactsStepName, + Description = "Produce portable Fusion deployment artifacts.", + Resource = resource, + RequiredBySteps = [WellKnownPipelineSteps.Publish], + Action = ExecuteArtifactsAsync + }, + new PipelineStep + { + Name = UploadStepName, + Description = "Reconcile immutable Fusion source schema versions.", + Resource = resource, + DependsOnSteps = [ArtifactsStepName], + Action = ExecuteUploadAsync + } + }; + + return + [ + .. buildSteps, + new PipelineStep + { + Name = DownloadStepName, + Description = "Download exact Fusion source schema versions from Nitro.", + Resource = resource, + Action = context => ExecuteSessionStepAsync( + context, + session, + static (executor, stepContext, pipelineSession) => + executor.PreflightAsync(stepContext, pipelineSession)) + }, + new PipelineStep + { + Name = ComposeStepName, + Description = "Compose the Fusion configuration for this environment.", + Resource = resource, + DependsOnSteps = [DownloadStepName], + Action = context => ExecuteSessionStepAsync( + context, + session, + static async (executor, stepContext, pipelineSession) => + { + await executor.DownloadAsync( + stepContext, + pipelineSession); + await executor.ComposeAsync( + stepContext, + pipelineSession); + }) + }, + new PipelineStep + { + Name = ReadinessStepName, + Description = "Verify deployed Fusion source services are ready.", + Resource = resource, + DependsOnSteps = [ComposeStepName], + Action = context => ExecuteSessionStepAsync( + context, + session, + (executor, stepContext, pipelineSession) => + { + EnsureResourceDeploymentOrdering( + topology.ResourcesWithoutCompute); + return executor.VerifyReadinessAsync( + stepContext, + pipelineSession); + }) + }, + new PipelineStep + { + Name = PublishStageStepName, + Description = "Publish the Fusion configuration to Nitro.", + Resource = resource, + DependsOnSteps = [ReadinessStepName], + Action = context => ExecuteSessionStepAsync( + context, + session, + static (executor, stepContext, pipelineSession) => + executor.PublishAsync(stepContext, pipelineSession)) + }, + new PipelineStep + { + Name = PublishStepName, + Description = "Complete the Fusion deployment.", + Resource = resource, + DependsOnSteps = [PublishStageStepName], + RequiredBySteps = [WellKnownPipelineSteps.Deploy], + Action = _ => + { + session.Dispose(); + return Task.CompletedTask; + } + } + ]; + } + + private static void ConfigureSteps( + PipelineConfigurationContext context, + FusionPipelineTopology topology) + { + if (!topology.HasDeployments) + { + return; + } + + var composition = GetCompositionResource(context.Model); + var sources = GraphQLResourceModel.GetReferencedSourceSchemas( + composition, + context.Model); + var download = context.Steps.Single( + step => step.Name == DownloadStepName); + var readiness = context.Steps.Single( + step => step.Name == ReadinessStepName); + var compose = context.Steps.Single( + step => step.Name == ComposeStepName); + var stagePublication = context.Steps.Single( + step => step.Name == PublishStageStepName); + var publication = context.Steps.Single( + step => step.Name == PublishStepName); + + topology.ResourcesWithoutCompute.Clear(); + + foreach (var source in sources) + { + var computeSteps = SelectResourceDeploymentSteps( + context, + source.Resource); + + if (computeSteps.Length == 0) + { + topology.ResourcesWithoutCompute.Add(source.Resource.Name); + continue; + } + + foreach (var computeStep in computeSteps) + { + computeStep.DependsOn(download); + compose.DependsOn(computeStep); + readiness.DependsOn(computeStep); + } + } + + var gatewayComputeSteps = SelectResourceDeploymentSteps( + context, + composition); + + if (gatewayComputeSteps.Length == 0) + { + topology.ResourcesWithoutCompute.Add(composition.Name); + return; + } + + WireGatewayDeployment( + stagePublication, + publication, + gatewayComputeSteps); + } + + internal static void WireGatewayDeployment( + PipelineStep stagePublication, + PipelineStep publication, + IEnumerable gatewayComputeSteps) + { + foreach (var gatewayComputeStep in gatewayComputeSteps) + { + gatewayComputeStep.DependsOn(stagePublication); + publication.DependsOn(gatewayComputeStep); + } + } + + internal static PipelineStep[] SelectResourceDeploymentSteps( + PipelineConfigurationContext context, + IResource resource) + { + var deployComputeSteps = context + .GetSteps(resource, WellKnownPipelineTags.DeployCompute) + .ToArray(); + + if (deployComputeSteps.Length > 0) + { + return deployComputeSteps; + } + + var deploymentTarget = resource + .GetDeploymentTargetAnnotation() + ?.DeploymentTarget; + + if (deploymentTarget is not null) + { + deployComputeSteps = context + .GetSteps( + deploymentTarget, + WellKnownPipelineTags.DeployCompute) + .ToArray(); + + if (deployComputeSteps.Length > 0) + { + return deployComputeSteps; + } + } + + return []; + } + + private static Task ExecuteArtifactsAsync(PipelineStepContext context) + => FusionPipelineExecutor.Instance.CreateArtifactsAsync(context); + + private static async Task ExecuteSessionStepAsync( + PipelineStepContext context, + FusionPipelineSession session, + Func< + FusionPipelineExecutor, + PipelineStepContext, + FusionPipelineSession, + Task> execute) + { + try + { + await execute(FusionPipelineExecutor.Instance, context, session); + } + catch + { + session.Dispose(); + throw; + } + } + + internal static void EnsureResourceDeploymentOrdering( + IReadOnlyCollection resourcesWithoutCompute) + { + if (resourcesWithoutCompute.Count > 0) + { + throw new InvalidOperationException( + "Fusion publication cannot prove compute deployment ordering for resources: " + + string.Join(", ", resourcesWithoutCompute.Order())); + } + } + + private static Task ExecuteUploadAsync(PipelineStepContext context) + => FusionPipelineExecutor.Instance.UploadAsync(context); + + private static void ValidateDeclaration( + NitroPublishTargetResource target) + { + if (target.StageParameter is null) + { + throw new InvalidOperationException( + $"Nitro target '{target.Name}' must specify the parameter that selects the stage."); + } + + if (string.IsNullOrWhiteSpace(target.CloudUrl)) + { + throw new InvalidOperationException( + $"Nitro target '{target.Name}' must specify a cloud URL."); + } + + if (!Uri.TryCreate( + target.CloudUrl, + UriKind.Absolute, + out var cloudUri) + || cloudUri.Scheme is not "https") + { + throw new InvalidOperationException( + $"Nitro target '{target.Name}' cloud URL must use HTTPS."); + } + + if (!string.IsNullOrEmpty(cloudUri.UserInfo) + || cloudUri.AbsolutePath is not "/" + || !string.IsNullOrEmpty(cloudUri.Query) + || !string.IsNullOrEmpty(cloudUri.Fragment)) + { + throw new InvalidOperationException( + $"Nitro target '{target.Name}' cloud URL must be an origin."); + } + + if (string.IsNullOrWhiteSpace(target.ApiId)) + { + throw new InvalidOperationException( + $"Nitro target '{target.Name}' must specify an API ID."); + } + + if (target.ConfigurationTagParameter is null + && string.IsNullOrWhiteSpace(target.ConfigurationTag)) + { + throw new InvalidOperationException( + $"Nitro target '{target.Name}' must specify a configuration tag."); + } + } +} + +internal sealed class FusionPipelineTopology +{ + public bool HasDeployments { get; set; } + + public HashSet ResourcesWithoutCompute { get; } = + new(StringComparer.Ordinal); +} + +#pragma warning restore ASPIREPIPELINES001 diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/FusionPipelineExecutor.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/FusionPipelineExecutor.cs new file mode 100644 index 00000000000..0df60e6ed5e --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/FusionPipelineExecutor.cs @@ -0,0 +1,1559 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text.Json; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Pipelines; +using HotChocolate.Fusion.Aspire.Nitro; +using HotChocolate.Fusion.Packaging; +using HotChocolate.Fusion.SourceSchema.Packaging; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using IOPath = System.IO.Path; + +namespace HotChocolate.Fusion.Aspire; + +#pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREPIPELINES004 + +internal sealed class FusionPipelineExecutor +{ + private static readonly TimeSpan s_readinessRetryDelay = + TimeSpan.FromSeconds(1); + private static readonly JsonSerializerOptions s_jsonOptions = new( + JsonSerializerDefaults.Web) + { + WriteIndented = true + }; + + private readonly FusionPipelineMemoryLimits _memoryLimits; + + internal FusionPipelineExecutor(FusionPipelineMemoryLimits memoryLimits) + { + _memoryLimits = memoryLimits; + } + + public static FusionPipelineExecutor Instance { get; } = + new(FusionPipelineMemoryLimits.Default); + + public async Task CreateArtifactsAsync(PipelineStepContext context) + { + var targets = FusionPipeline.SelectTargets(context.Model); + + if (targets.Count == 0) + { + return; + } + + var composition = FusionPipeline.GetCompositionResource(context.Model); + var sources = GraphQLResourceModel.GetReferencedSourceSchemas( + composition, + context.Model); + + if (sources.Count == 0) + { + throw new InvalidOperationException( + $"Fusion composition resource '{composition.Name}' has no declared source schemas."); + } + + _ = GetSourceNames(context.Model); + + var output = context.Services + .GetRequiredService() + .GetOutputDirectory(); + + foreach (var target in targets) + { + await CreateDeploymentArtifactsAsync( + target, + FusionPipeline.GetStages(context.Model, target), + sources, + output, + context.CancellationToken); + } + } + + public async Task VerifyReadinessAsync( + PipelineStepContext context, + FusionPipelineSession session) + { + var deployments = await FusionPipeline.SelectStagesAsync( + context.Model, + context.CancellationToken); + + if (deployments.Count == 0) + { + return; + } + + var compositionResource = FusionPipeline.GetCompositionResource( + context.Model); + var composition = GraphQLResourceModel.GetComposition( + compositionResource); + using var httpClient = new HttpClient + { + Timeout = TimeSpan.FromSeconds(10) + }; + try + { + foreach (var deployment in deployments) + { + var state = session.GetState(deployment); + await ValidateSessionStateAsync( + state, + deployment, + context, + context.CancellationToken); + ValidateCompositionState( + state, + deployment, + composition.Settings); + VerifyMemoryDigest( + state.FusionArchive, + state.FusionArchiveSha256 + ?? throw new InvalidOperationException( + "The composed Fusion archive has no digest."), + "composed Fusion archive"); + await using var archiveStream = new MemoryStream( + state.FusionArchive, + writable: false); + using var archive = FusionArchive.Open(archiveStream); + using var configuration = + await archive.TryGetGatewayConfigurationAsync( + WellKnownVersions.LatestGatewayFormatVersion, + context.CancellationToken) + ?? throw new InvalidDataException( + "The composed Fusion archive contains no gateway configuration."); + + foreach (var (name, endpoint) in GetTransportEndpoints( + configuration.Settings)) + { + RejectLoopbackEndpoint(endpoint); + await WaitForReadinessAsync( + httpClient, + name, + endpoint, + deployment.OperationTimeout, + s_readinessRetryDelay, + context.CancellationToken); + } + } + } + catch + { + session.Clear(); + throw; + } + } + + public async Task UploadAsync(PipelineStepContext context) + { + var artifacts = await MaterializeArchivesAsync(context); + if (artifacts.Count == 0) + { + return; + } + + var workflow = context.Services.GetRequiredService(); + + foreach (var group in artifacts.GroupBy(artifact => artifact.Target)) + { + var target = await ResolveTargetAsync( + group.Key, + context, + context.CancellationToken); + + foreach (var artifact in group) + { + await workflow.ReconcileSourceSchemaAsync( + target, + new FusionSourceSchemaUpload( + artifact.Name, + artifact.Version, + artifact.ArchivePath, + artifact.Sha256), + context.CancellationToken); + } + } + } + + public async Task PreflightAsync( + PipelineStepContext context, + FusionPipelineSession session) + { + var deployments = await FusionPipeline.SelectStagesAsync( + context.Model, + context.CancellationToken); + if (deployments.Count == 0) + { + return; + } + + var workflow = context.Services + .GetRequiredService(); + var sourceNames = GetSourceNames(context.Model); + var preparedStates = new List<( + FusionStageResource Deployment, + FusionDeploymentSessionState State)>(deployments.Count); + long totalSourceBytes = 0; + var transferred = false; + + try + { + foreach (var deployment in deployments) + { + var tag = await ResolveConfigurationTagAsync( + deployment.Nitro, + context.CancellationToken); + var target = await ResolveTargetAsync( + deployment, + context, + context.CancellationToken); + var sources = new List( + sourceNames.Count); + + foreach (var sourceName in sourceNames) + { + using var download = await DownloadExactSourceAsync( + workflow, + target, + deployment, + sourceName, + tag, + context.CancellationToken); + AddSourceBytes( + download.Archive.Length, + sourceName, + tag, + ref totalSourceBytes); + byte[]? archive = download.Archive.ToArray(); + try + { + var contentSha256 = + await ValidateCanonicalDigestAsync( + download, + archive, + sourceName, + tag, + context.CancellationToken); + sources.Add( + new FusionSessionSourceIdentity( + sourceName, + tag, + contentSha256)); + } + finally + { + if (archive is not null) + { + Array.Clear(archive); + } + } + } + + preparedStates.Add( + (deployment, + new FusionDeploymentSessionState( + tag, + deployment.Nitro.CloudUrl!, + deployment.Nitro.ApiId!, + sources))); + } + + session.SetAll(preparedStates); + transferred = true; + } + catch + { + session.Clear(); + throw; + } + finally + { + if (!transferred) + { + foreach (var (_, state) in preparedStates) + { + state.Dispose(); + } + } + } + } + + public async Task DownloadAsync( + PipelineStepContext context, + FusionPipelineSession session) + { + var deployments = await FusionPipeline.SelectStagesAsync( + context.Model, + context.CancellationToken); + if (deployments.Count == 0) + { + return; + } + + var workflow = context.Services + .GetRequiredService(); + long totalSourceBytes = 0; + + try + { + foreach (var deployment in deployments) + { + var state = session.GetState(deployment); + await ValidatePreflightStateAsync( + state, + deployment, + context, + context.CancellationToken); + var target = await ResolveTargetAsync( + deployment, + context, + context.CancellationToken); + var sources = new List( + state.SourceIdentities.Count); + var sourceListTransferred = false; + + try + { + foreach (var identity in state.SourceIdentities) + { + using var download = await DownloadExactSourceAsync( + workflow, + target, + deployment, + identity.Name, + identity.Version, + context.CancellationToken); + AddSourceBytes( + download.Archive.Length, + identity.Name, + identity.Version, + ref totalSourceBytes); + byte[]? archive = download.Archive.ToArray(); + try + { + var contentSha256 = + await ValidateCanonicalDigestAsync( + download, + archive, + identity.Name, + identity.Version, + context.CancellationToken); + if (!string.Equals( + contentSha256, + identity.ContentSha256, + StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException( + $"Fusion source '{identity.Name}@{identity.Version}' " + + "changed between preflight and composition."); + } + + sources.Add( + new FusionSessionSource( + identity.Name, + identity.Version, + archive, + contentSha256)); + archive = null; + } + finally + { + if (archive is not null) + { + Array.Clear(archive); + } + } + } + + context.CancellationToken.ThrowIfCancellationRequested(); + state.SetSources(sources); + sourceListTransferred = true; + } + finally + { + if (!sourceListTransferred) + { + foreach (var source in sources) + { + Array.Clear(source.Archive); + } + } + } + } + } + catch + { + session.Clear(); + throw; + } + } + + public async Task ComposeAsync( + PipelineStepContext context, + FusionPipelineSession session) + { + var deployments = await FusionPipeline.SelectStagesAsync( + context.Model, + context.CancellationToken); + if (deployments.Count == 0) + { + return; + } + + var workflow = context.Services + .GetRequiredService(); + var compositionResource = FusionPipeline.GetCompositionResource( + context.Model); + var currentComposition = GraphQLResourceModel.GetComposition( + compositionResource); + + try + { + foreach (var deployment in deployments) + { + var state = session.GetState(deployment); + await ValidateSessionStateAsync( + state, + deployment, + context, + context.CancellationToken); + + var compositionEnvironment = ResolveCompositionEnvironment( + deployment, + currentComposition.Settings); + await using var farStream = new MemoryStream(); + var logger = context.Services + .GetRequiredService>(); + var target = await ResolveTargetAsync( + deployment, + context, + context.CancellationToken); + var stageSettings = await workflow.TryGetStageCompositionSettingsAsync( + target, + deployment.StageName, + logger, + context.CancellationToken); + if (!await AspireCompositionHelper.TryComposeArchivesAsync( + farStream, + state.Sources.Select( + source => new SourceSchemaArchiveInfo( + source.Name, + source.Archive)) + .ToArray(), + compositionEnvironment, + currentComposition.Settings, + stageSettings, + logger, + context.CancellationToken)) + { + throw new InvalidOperationException( + "Fusion configuration composition failed."); + } + + TransferComposition( + state, + compositionEnvironment, + farStream.ToArray(), + context.CancellationToken); + } + } + catch + { + session.Clear(); + throw; + } + } + + public async Task PublishAsync( + PipelineStepContext context, + FusionPipelineSession session) + { + var deployments = await FusionPipeline.SelectStagesAsync( + context.Model, + context.CancellationToken); + if (deployments.Count == 0) + { + return; + } + + var workflow = context.Services + .GetRequiredService(); + var compositionResource = FusionPipeline.GetCompositionResource( + context.Model); + var composition = GraphQLResourceModel.GetComposition( + compositionResource); + + try + { + foreach (var deployment in deployments) + { + var state = session.GetState(deployment); + await ValidateSessionStateAsync( + state, + deployment, + context, + context.CancellationToken); + var target = await ResolveTargetAsync( + deployment, + context, + context.CancellationToken); + ValidateCompositionState( + state, + deployment, + composition.Settings); + VerifyMemoryDigest( + state.FusionArchive, + state.FusionArchiveSha256 + ?? throw new InvalidOperationException( + "The composed Fusion archive has no digest."), + "composed Fusion archive"); + + await workflow.PublishAsync( + new FusionPublicationRequest( + target, + deployment.StageName, + state.Tag, + state.SourceIdentities + .Select(source => + new FusionSourceSchemaVersion( + source.Name, + source.Version)) + .ToArray(), + deployment.WaitForApproval, + deployment.Force, + deployment.OperationTimeout, + deployment.ApprovalTimeout), + state.FusionArchive, + context.CancellationToken); + } + } + finally + { + session.Clear(); + } + } + + internal static async Task WaitForReadinessAsync( + HttpClient httpClient, + string sourceName, + Uri endpoint, + TimeSpan timeout, + TimeSpan retryDelay, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(httpClient); + ArgumentException.ThrowIfNullOrWhiteSpace(sourceName); + ArgumentNullException.ThrowIfNull(endpoint); + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual( + timeout, + TimeSpan.Zero); + ArgumentOutOfRangeException.ThrowIfLessThan( + retryDelay, + TimeSpan.Zero); + + using var timeoutSource = + CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken); + timeoutSource.CancelAfter(timeout); + Exception? lastFailure = null; + + while (!timeoutSource.IsCancellationRequested) + { + try + { + using var response = await httpClient.GetAsync( + endpoint, + timeoutSource.Token); + if ((int)response.StatusCode + < (int)HttpStatusCode.InternalServerError) + { + return; + } + + lastFailure = new HttpRequestException( + $"The readiness endpoint returned HTTP {(int)response.StatusCode} " + + $"({response.StatusCode})."); + } + catch (HttpRequestException exception) + { + lastFailure = exception; + } + catch (OperationCanceledException exception) + when (!cancellationToken.IsCancellationRequested) + { + lastFailure = exception; + } + + if (timeoutSource.IsCancellationRequested) + { + break; + } + + try + { + await Task.Delay( + retryDelay, + timeoutSource.Token); + } + catch (OperationCanceledException) + when (!cancellationToken.IsCancellationRequested) + { + break; + } + } + + cancellationToken.ThrowIfCancellationRequested(); + + throw new InvalidOperationException( + $"Fusion source '{sourceName}' at '{endpoint}' did not pass its " + + $"production readiness check within {timeout}.", + lastFailure); + } + + internal async Task> MaterializeArchivesAsync( + PipelineStepContext context) + { + var targets = FusionPipeline.SelectTargets(context.Model); + + if (targets.Count == 0) + { + return []; + } + + var output = context.Services + .GetRequiredService() + .GetOutputDirectory(); + var artifacts = new List(); + var compositionResource = FusionPipeline.GetCompositionResource( + context.Model); + var composition = GraphQLResourceModel.GetComposition( + compositionResource); + + foreach (var target in targets) + { + var stages = FusionPipeline.GetStages(context.Model, target); + var tag = await ResolveConfigurationTagAsync( + target, + context.CancellationToken); + var deploymentDirectory = GetTargetDirectory(output, target); + var materializedDirectory = IOPath.Combine( + deploymentDirectory, + "materialized"); + Directory.CreateDirectory(materializedDirectory); + + foreach (var sourceDirectory in Directory.EnumerateDirectories( + IOPath.Combine(deploymentDirectory, "sources")) + .OrderBy(IOPath.GetFileName, StringComparer.Ordinal)) + { + var name = IOPath.GetFileName(sourceDirectory); + var sourceVersion = tag; + ValidatePathSegment(sourceVersion, "source version"); + var schema = await File.ReadAllBytesAsync( + IOPath.Combine(sourceDirectory, "schema.graphqls"), + context.CancellationToken); + var settingsPath = IOPath.Combine( + sourceDirectory, + "schema-settings.template.json"); + using var settings = JsonDocument.Parse( + await File.ReadAllTextAsync( + settingsPath, + context.CancellationToken)); + + ValidateSettingsName(name, settings); + + // an immutable source version serves every stage of the api, so the endpoint of + // every declared stage has to be publicly reachable. + foreach (var stage in stages) + { + using var resolvedSettings = + AspireCompositionHelper.ResolveSourceSchemaSettings( + settings, + ResolveCompositionEnvironment(stage, composition.Settings)); + RejectLoopbackEndpoint(GetTransportEndpoint(resolvedSettings)); + } + + var archivePath = IOPath.Combine( + materializedDirectory, + $"{name}-{sourceVersion}.zip"); + await CreateArchiveAsync( + archivePath, + schema, + settings, + GetExtensionsPath(sourceDirectory), + context.CancellationToken); + var digest = await ComputeFileDigestAsync( + archivePath, + context.CancellationToken); + + artifacts.Add( + new( + target, + name, + sourceVersion, + archivePath, + digest)); + } + } + + return artifacts; + } + + private static async Task CreateDeploymentArtifactsAsync( + NitroPublishTargetResource target, + IReadOnlyList stages, + IReadOnlyList sources, + string output, + CancellationToken cancellationToken) + { + var deploymentDirectory = GetTargetDirectory(output, target); + var fusionDirectory = IOPath.GetDirectoryName(deploymentDirectory)!; + Directory.CreateDirectory(fusionDirectory); + var temporaryDirectory = IOPath.Combine( + fusionDirectory, + $".{target.Name}.{Guid.NewGuid():N}.tmp"); + + try + { + var sourcesDirectory = IOPath.Combine(temporaryDirectory, "sources"); + Directory.CreateDirectory(sourcesDirectory); + var exportDirectory = IOPath.Combine(temporaryDirectory, "export"); + var sourceNames = new List(sources.Count); + + foreach (var source in sources) + { + sourceNames.Add( + await CreateSourceArtifactsAsync( + source, + sourcesDirectory, + exportDirectory, + cancellationToken)); + } + + // The export scratch space must not become part of the published output. + if (Directory.Exists(exportDirectory)) + { + Directory.Delete(exportDirectory, recursive: true); + } + + var template = new FusionDeploymentTemplate( + FormatVersion: 1, + CloudUrl: target.CloudUrl!, + ApiId: target.ApiId!, + Stages: stages + .Select(stage => stage.StageName) + .Order(StringComparer.Ordinal) + .ToArray(), + ConfigurationTag: target.ConfigurationTag + ?? $"{{{{{target.ConfigurationTagParameter!.Name}}}}}", + StageOwnership: "authoritative", + Sources: sourceNames.Order(StringComparer.Ordinal).ToArray()); + + await WriteJsonAtomicallyAsync( + IOPath.Combine(temporaryDirectory, "nitro-deployment-template.json"), + template, + cancellationToken); + + ReplaceDirectoryAtomically( + temporaryDirectory, + deploymentDirectory); + } + finally + { + DeleteDirectoryBestEffort(temporaryDirectory); + } + } + + internal static void ReplaceDirectoryAtomically( + string sourceDirectory, + string destinationDirectory) + { + if (!Directory.Exists(sourceDirectory)) + { + throw new DirectoryNotFoundException( + $"Replacement directory '{sourceDirectory}' does not exist."); + } + + var destinationParent = IOPath.GetDirectoryName(destinationDirectory) + ?? throw new InvalidOperationException( + $"Replacement destination '{destinationDirectory}' has no parent directory."); + Directory.CreateDirectory(destinationParent); + var backupDirectory = IOPath.Combine( + destinationParent, + $".{IOPath.GetFileName(destinationDirectory)}.{Guid.NewGuid():N}.bak"); + var movedDestination = false; + + try + { + if (Directory.Exists(destinationDirectory)) + { + Directory.Move(destinationDirectory, backupDirectory); + movedDestination = true; + } + + try + { + Directory.Move(sourceDirectory, destinationDirectory); + } + catch + { + if (movedDestination + && !Directory.Exists(destinationDirectory) + && Directory.Exists(backupDirectory)) + { + Directory.Move(backupDirectory, destinationDirectory); + movedDestination = false; + } + + throw; + } + } + finally + { + if (movedDestination) + { + DeleteDirectoryBestEffort(backupDirectory); + } + } + } + + private static async Task CreateSourceArtifactsAsync( + GraphQLSourceSchemaResource sourceSchema, + string sourcesDirectory, + string exportDirectory, + CancellationToken cancellationToken) + { + var source = sourceSchema.Resource; + var declaration = sourceSchema.Declaration; + + string schemaPath; + string settingsPath; + string? extensionsPath = null; + string projectPath; + string configuration; + string? targetFramework; + string? runtimeIdentifier; + + switch (declaration.Location) + { + case SourceSchemaLocationType.ProjectDirectory: + var schemaPaths = GraphQLResourceModel.GetProjectSchemaPaths( + source, + declaration); + projectPath = schemaPaths.ProjectPath; + schemaPath = schemaPaths.SchemaPath; + settingsPath = schemaPaths.SettingsPath; + extensionsPath = schemaPaths.ExtensionsPath; + configuration = "prebuilt"; + targetFramework = null; + runtimeIdentifier = null; + break; + + case SourceSchemaLocationType.CommandLineExport: + var export = await CommandLineSchemaExporter.ExportAsync( + source, + declaration, + IOPath.Combine(exportDirectory, source.Name), + cancellationToken); + schemaPath = export.SchemaPath; + settingsPath = export.SettingsPath; + projectPath = export.ProjectPath; + configuration = export.Configuration; + targetFramework = export.TargetFramework; + runtimeIdentifier = export.RuntimeIdentifier; + break; + + case SourceSchemaLocationType.SchemaEndpoint: + throw new InvalidOperationException( + $"GraphQL source '{source.Name}' uses runtime endpoint acquisition, which " + + "is unavailable during Aspire publish. Declare a schema file or an " + + "explicit command-line export."); + + default: + throw new InvalidOperationException( + $"GraphQL source '{source.Name}' has an unsupported acquisition mode."); + } + + if (!File.Exists(schemaPath) || !File.Exists(settingsPath)) + { + throw new InvalidOperationException( + $"GraphQL source '{source.Name}' did not provide both schema and settings files."); + } + + var schema = await File.ReadAllTextAsync(schemaPath, cancellationToken); + if (string.IsNullOrWhiteSpace(schema)) + { + throw new InvalidOperationException( + $"GraphQL source '{source.Name}' has an empty schema."); + } + + string? extensions = null; + if (extensionsPath is not null && File.Exists(extensionsPath)) + { + extensions = await File.ReadAllTextAsync( + extensionsPath, + cancellationToken); + } + + using var settings = JsonDocument.Parse( + await File.ReadAllTextAsync(settingsPath, cancellationToken)); + var endpointConfiguration = SchemaComposition.ReadEndpointConfiguration( + source.Name, + declaration.SourceSchemaName ?? source.Name, + settings); + var name = endpointConfiguration.SourceSchemaName; + ValidatePathSegment(name, "source schema name"); + GraphQLSourceSchemaValidator.Validate( + source.Name, + endpointConfiguration, + schema, + extensions); + + var sourceDirectory = IOPath.Combine(sourcesDirectory, name); + Directory.CreateDirectory(sourceDirectory); + var destinationSchema = IOPath.Combine(sourceDirectory, "schema.graphqls"); + var destinationSettings = IOPath.Combine( + sourceDirectory, + "schema-settings.template.json"); + await File.WriteAllTextAsync( + destinationSchema, + schema, + cancellationToken); + await File.WriteAllTextAsync( + destinationSettings, + settings.RootElement.GetRawText(), + cancellationToken); + + if (extensions is not null) + { + await File.WriteAllTextAsync( + IOPath.Combine(sourceDirectory, "schema-extensions.graphqls"), + extensions, + cancellationToken); + } + + var projectDigest = await ComputeFileDigestAsync( + projectPath, + cancellationToken); + var schemaDigest = await ComputeFileDigestAsync( + destinationSchema, + cancellationToken); + var provenance = new FusionSourceProvenance( + ProjectPath: projectPath, + ProjectSha256: projectDigest, + SchemaSha256: schemaDigest, + Configuration: configuration, + TargetFramework: targetFramework, + RuntimeIdentifier: runtimeIdentifier, + LaunchProfile: false, + WorkingDirectory: IOPath.GetDirectoryName(projectPath)!); + + await WriteJsonAtomicallyAsync( + IOPath.Combine(sourceDirectory, "provenance.json"), + provenance, + cancellationToken); + + return name; + } + + private static async Task CreateArchiveAsync( + string archivePath, + byte[] schema, + JsonDocument settings, + string? extensionsPath, + CancellationToken cancellationToken) + { + var temporaryPath = archivePath + "." + Guid.NewGuid().ToString("N") + ".tmp"; + + try + { + using (var archive = FusionSourceSchemaArchive.Create(temporaryPath)) + { + await archive.SetArchiveMetadataAsync( + new HotChocolate.Fusion.SourceSchema.Packaging.ArchiveMetadata(), + cancellationToken); + await archive.SetSchemaAsync(schema, cancellationToken); + await archive.SetSettingsAsync(settings, cancellationToken); + + if (extensionsPath is not null) + { + await archive.SetSchemaExtensionsAsync( + await File.ReadAllBytesAsync( + extensionsPath, + cancellationToken), + cancellationToken); + } + + await archive.CommitAsync(cancellationToken); + } + + File.Move(temporaryPath, archivePath, overwrite: true); + } + finally + { + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } + } + } + + internal static string ResolveCompositionEnvironment( + FusionStageResource deployment, + GraphQLCompositionSettings settings) + => deployment.CompositionEnvironmentName + ?? settings.EnvironmentName + ?? deployment.StageName; + + private static void ValidateCompositionState( + FusionDeploymentSessionState state, + FusionStageResource deployment, + GraphQLCompositionSettings settings) + { + var expectedEnvironment = ResolveCompositionEnvironment( + deployment, + settings); + if (!string.Equals( + state.CompositionEnvironment, + expectedEnvironment, + StringComparison.Ordinal)) + { + throw new InvalidDataException( + "The composed Fusion archive environment does not match " + + $"deployment '{deployment.Name}'."); + } + } + + internal static JsonDocument ResolveSourceSchemaSettings( + JsonDocument settings, + string environmentName) + => AspireCompositionHelper.ResolveSourceSchemaSettings( + settings, + environmentName); + + internal static IReadOnlyList GetSourceNames( + DistributedApplicationModel model) + { + var composition = FusionPipeline.GetCompositionResource(model); + var sourceNames = GraphQLResourceModel + .GetReferencedSourceSchemas(composition, model) + .Select(source => + source.Declaration.SourceSchemaName + ?? source.Resource.Name) + .ToArray(); + if (sourceNames.Length == 0) + { + throw new InvalidOperationException( + $"Fusion composition resource '{composition.Name}' has no declared source schemas."); + } + + var duplicateProvider = sourceNames + .GroupBy(name => name, StringComparer.Ordinal) + .FirstOrDefault(group => group.Count() > 1); + if (duplicateProvider is not null) + { + throw new InvalidOperationException( + "Multiple provider resources map to Fusion source " + + $"'{duplicateProvider.Key}'."); + } + + foreach (var sourceName in sourceNames) + { + ValidatePathSegment(sourceName, "source schema name"); + } + + Array.Sort(sourceNames, StringComparer.Ordinal); + return sourceNames; + } + + private static async Task + DownloadExactSourceAsync( + FusionDeploymentWorkflow workflow, + FusionTarget target, + FusionStageResource deployment, + string sourceName, + string tag, + CancellationToken cancellationToken) + { + var download = await workflow.DownloadSourceSchemaAsync( + target, + new FusionSourceSchemaVersion(sourceName, tag), + cancellationToken) + ?? throw new InvalidOperationException( + $"Fusion source '{sourceName}' version '{tag}' does not exist " + + $"on target '{deployment.Nitro.ApiId}'."); + if (!string.Equals( + download.Name, + sourceName, + StringComparison.Ordinal) + || !string.Equals( + download.Version, + tag, + StringComparison.Ordinal)) + { + throw new InvalidDataException( + $"Nitro returned Fusion source '{download.Name}@{download.Version}' " + + $"when '{sourceName}@{tag}' was requested."); + } + + return download; + } + + private void AddSourceBytes( + int sourceBytes, + string sourceName, + string tag, + ref long totalSourceBytes) + { + if (sourceBytes > _memoryLimits.SourceArchiveBytes) + { + throw new InvalidDataException( + $"Downloaded Fusion source '{sourceName}@{tag}' exceeds " + + $"the {_memoryLimits.SourceArchiveBytes:N0}-byte " + + "per-source in-memory size limit."); + } + + totalSourceBytes = checked(totalSourceBytes + sourceBytes); + if (totalSourceBytes > _memoryLimits.TotalSourceArchiveBytes) + { + throw new InvalidDataException( + "The downloaded Fusion sources exceed the " + + $"{_memoryLimits.TotalSourceArchiveBytes:N0}-byte " + + "aggregate in-memory size limit."); + } + } + + private static async Task ValidateCanonicalDigestAsync( + FusionSourceSchemaDownload download, + byte[] archive, + string sourceName, + string tag, + CancellationToken cancellationToken) + { + var contentSha256 = + await FusionSourceSchemaContent.ComputeSha256Async( + archive, + sourceName, + cancellationToken); + if (!string.Equals( + contentSha256, + download.ContentSha256, + StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException( + $"Downloaded Fusion source '{sourceName}@{tag}' content " + + "does not match its canonical digest."); + } + + return contentSha256; + } + + private static async Task ValidatePreflightStateAsync( + FusionDeploymentSessionState state, + FusionStageResource deployment, + PipelineStepContext context, + CancellationToken cancellationToken) + { + var tag = await ResolveConfigurationTagAsync( + deployment.Nitro, + cancellationToken); + var sourceNames = GetSourceNames(context.Model); + if (!string.Equals( + state.Tag, + tag, + StringComparison.Ordinal)) + { + throw new InvalidDataException( + $"Prepared Fusion tag '{state.Tag}' does not match configured tag '{tag}'."); + } + + if (!string.Equals( + state.CloudUrl.TrimEnd('/'), + deployment.Nitro.CloudUrl!.TrimEnd('/'), + StringComparison.OrdinalIgnoreCase) + || !string.Equals( + state.ApiId, + deployment.Nitro.ApiId, + StringComparison.Ordinal) + || state.SourceIdentities.Count != sourceNames.Count + || !state.SourceIdentities.Select(source => source.Name) + .SequenceEqual(sourceNames, StringComparer.Ordinal)) + { + throw new InvalidDataException( + $"Prepared Fusion state does not match deployment '{deployment.Name}'."); + } + + foreach (var source in state.SourceIdentities) + { + if (!string.Equals( + source.Version, + tag, + StringComparison.Ordinal)) + { + throw new InvalidDataException( + $"Prepared Fusion source '{source.Name}' does not use tag '{tag}'."); + } + } + } + + private static async Task ValidateSessionStateAsync( + FusionDeploymentSessionState state, + FusionStageResource deployment, + PipelineStepContext context, + CancellationToken cancellationToken) + { + await ValidatePreflightStateAsync( + state, + deployment, + context, + cancellationToken); + var sources = state.Sources; + if (sources.Count != state.SourceIdentities.Count + || !sources.Select(source => source.Name) + .SequenceEqual( + state.SourceIdentities.Select(source => source.Name), + StringComparer.Ordinal)) + { + throw new InvalidDataException( + $"Materialized Fusion sources do not match deployment '{deployment.Name}'."); + } + + for (var i = 0; i < sources.Count; i++) + { + var source = sources[i]; + var identity = state.SourceIdentities[i]; + if (!string.Equals( + source.Version, + identity.Version, + StringComparison.Ordinal) + || !string.Equals( + source.ContentSha256, + identity.ContentSha256, + StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException( + $"Materialized Fusion source '{source.Name}' does not match preflight."); + } + + var contentSha256 = + await FusionSourceSchemaContent.ComputeSha256Async( + source.Archive, + source.Name, + cancellationToken); + if (!string.Equals( + contentSha256, + source.ContentSha256, + StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException( + $"Prepared Fusion source '{source.Name}' content changed after download."); + } + } + } + + internal static IReadOnlyList<(string Name, Uri Endpoint)> + GetTransportEndpoints(JsonDocument gatewaySettings) + { + if (!gatewaySettings.RootElement.TryGetProperty( + "sourceSchemas", + out var sourceSchemas) + || sourceSchemas.ValueKind is not JsonValueKind.Object) + { + throw new InvalidDataException( + "The Fusion gateway settings contain no source schemas."); + } + + var endpoints = new List<(string Name, Uri Endpoint)>(); + foreach (var sourceSchema in sourceSchemas.EnumerateObject()) + { + if (!sourceSchema.Value.TryGetProperty( + "transports", + out var transports) + || !transports.TryGetProperty("http", out var http) + || !http.TryGetProperty("url", out var url) + || url.ValueKind is not JsonValueKind.String + || !Uri.TryCreate( + url.GetString(), + UriKind.Absolute, + out var endpoint)) + { + throw new InvalidDataException( + $"Fusion gateway settings source '{sourceSchema.Name}' " + + "must specify an absolute transports.http.url."); + } + + endpoints.Add((sourceSchema.Name, endpoint)); + } + + return endpoints; + } + + private static Task ResolveTargetAsync( + FusionStageResource deployment, + PipelineStepContext context, + CancellationToken cancellationToken) + => ResolveTargetAsync(deployment.Nitro, context, cancellationToken); + + private static async Task ResolveTargetAsync( + NitroPublishTargetResource target, + PipelineStepContext context, + CancellationToken cancellationToken) + { + var apiKey = target.ApiKey is null + ? context.Services.GetRequiredService()["Nitro:ApiKey"] + ?? context.Services.GetRequiredService()["NITRO_API_KEY"] + : await target.ApiKey.GetValueAsync(cancellationToken); + + if (string.IsNullOrWhiteSpace(apiKey)) + { + throw new InvalidOperationException( + $"Nitro target '{target.Name}' requires an API key."); + } + + return new( + new Uri(target.CloudUrl!, UriKind.Absolute), + target.ApiId!, + apiKey); + } + + internal static Uri GetTransportEndpoint(JsonDocument settings) + { + var root = settings.RootElement; + if (!root.TryGetProperty("transports", out var transports) + || !transports.TryGetProperty("http", out var http) + || !http.TryGetProperty("url", out var url) + || url.ValueKind is not JsonValueKind.String + || !Uri.TryCreate(url.GetString(), UriKind.Absolute, out var endpoint)) + { + throw new InvalidOperationException( + "Fusion deployment settings must specify an absolute production " + + "transports.http.url."); + } + + return endpoint; + } + + private static void RejectLoopbackEndpoint(Uri endpoint) + { + if (endpoint.IsLoopback + || endpoint.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "Fusion deployment settings must not contain a loopback production endpoint."); + } + } + + private static void ValidateSettingsName( + string expectedName, + JsonDocument settings) + { + if (!settings.RootElement.TryGetProperty("name", out var name) + || name.ValueKind is not JsonValueKind.String + || !string.Equals( + name.GetString(), + expectedName, + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Fusion source settings name must exactly match '{expectedName}'."); + } + } + + private static string? GetExtensionsPath(string sourceDirectory) + { + var path = IOPath.Combine( + sourceDirectory, + "schema-extensions.graphqls"); + return File.Exists(path) ? path : null; + } + + private static async Task ResolveConfigurationTagAsync( + NitroPublishTargetResource target, + CancellationToken cancellationToken) + { + var value = target.ConfigurationTag; + if (target.ConfigurationTagParameter is not null) + { + value = await target.ConfigurationTagParameter.GetValueAsync( + cancellationToken); + } + + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException( + $"Nitro target '{target.Name}' configuration tag resolved to an empty value."); + } + + ValidatePathSegment(value, "configuration tag"); + return value; + } + + internal static void ValidatePathSegment( + string value, + string description) + { + if (value is "." or ".." + || value.Any(character => + !char.IsAsciiLetterOrDigit(character) + && character is not '.' and not '_' and not '-')) + { + throw new InvalidOperationException( + $"Fusion {description} '{value}' cannot be used as a portable path segment."); + } + } + + private static async Task ComputeFileDigestAsync( + string path, + CancellationToken cancellationToken) + { + await using var stream = File.OpenRead(path); + var digest = await SHA256.HashDataAsync(stream, cancellationToken); + return Convert.ToHexStringLower(digest); + } + + private static string ComputeMemoryDigest(byte[] content) + => Convert.ToHexStringLower(SHA256.HashData(content)); + + internal void TransferComposition( + FusionDeploymentSessionState state, + string compositionEnvironment, + byte[] fusionArchive, + CancellationToken cancellationToken) + { + var transferred = false; + try + { + cancellationToken.ThrowIfCancellationRequested(); + state.SetComposition( + compositionEnvironment, + fusionArchive, + ComputeMemoryDigest(fusionArchive)); + transferred = true; + } + finally + { + if (!transferred) + { + Array.Clear(fusionArchive); + } + } + } + + private static void VerifyMemoryDigest( + byte[] content, + string expectedSha256, + string description) + { + var actualSha256 = ComputeMemoryDigest(content); + if (!string.Equals( + actualSha256, + expectedSha256, + StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException( + $"The {description} SHA-256 does not match the in-memory session state."); + } + } + + internal static async Task VerifyFileDigestAsync( + string path, + string expectedSha256, + string description, + CancellationToken cancellationToken) + { + var actualSha256 = await ComputeFileDigestAsync( + path, + cancellationToken); + if (!string.Equals( + actualSha256, + expectedSha256, + StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException( + $"The {description} SHA-256 does not match prepared state."); + } + } + + private static async Task WriteJsonAtomicallyAsync( + string path, + T value, + CancellationToken cancellationToken) + { + Directory.CreateDirectory(IOPath.GetDirectoryName(path)!); + var temporaryPath = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + + try + { + await using (var stream = File.Create(temporaryPath)) + { + await JsonSerializer.SerializeAsync( + stream, + value, + s_jsonOptions, + cancellationToken); + } + + File.Move(temporaryPath, path, overwrite: true); + } + finally + { + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } + } + } + + private static string GetTargetDirectory( + string output, + NitroPublishTargetResource target) + => IOPath.Combine(output, "fusion", target.Name); + + private static void DeleteDirectoryBestEffort(string path) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } +} + +internal sealed record FusionSourceArtifact( + NitroPublishTargetResource Target, + string Name, + string Version, + string ArchivePath, + string Sha256); + +internal sealed record FusionDeploymentTemplate( + int FormatVersion, + string CloudUrl, + string ApiId, + IReadOnlyList Stages, + string ConfigurationTag, + string StageOwnership, + IReadOnlyList Sources); + +internal sealed record FusionSourceProvenance( + string ProjectPath, + string ProjectSha256, + string SchemaSha256, + string Configuration, + string? TargetFramework, + string? RuntimeIdentifier, + bool LaunchProfile, + string WorkingDirectory); + +#pragma warning restore ASPIREPIPELINES004 +#pragma warning restore ASPIREPIPELINES001 diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/FusionPipelineMemoryLimits.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/FusionPipelineMemoryLimits.cs new file mode 100644 index 00000000000..1e61dc8222d --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/FusionPipelineMemoryLimits.cs @@ -0,0 +1,14 @@ +namespace HotChocolate.Fusion.Aspire; + +internal sealed record FusionPipelineMemoryLimits( + int SourceArchiveBytes, + long TotalSourceArchiveBytes) +{ + public const int DefaultSourceArchiveBytes = 128_000_000; + + public const int DefaultTotalSourceArchiveBytes = 512_000_000; + + public static FusionPipelineMemoryLimits Default { get; } = new( + DefaultSourceArchiveBytes, + DefaultTotalSourceArchiveBytes); +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/FusionPipelineResource.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/FusionPipelineResource.cs new file mode 100644 index 00000000000..19fe0f4ec66 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/FusionPipelineResource.cs @@ -0,0 +1,5 @@ +using Aspire.Hosting.ApplicationModel; + +namespace HotChocolate.Fusion.Aspire; + +internal sealed class FusionPipelineResource(string name) : Resource(name); diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/FusionPipelineSession.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/FusionPipelineSession.cs new file mode 100644 index 00000000000..2bb0d63d2a7 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/FusionPipelineSession.cs @@ -0,0 +1,329 @@ +namespace HotChocolate.Fusion.Aspire; + +internal sealed class FusionPipelineSession : IDisposable +{ + private readonly object _sync = new(); + private readonly Dictionary< + FusionStageResource, + FusionDeploymentSessionState> _deployments = []; + private readonly FusionPipelineMemoryLimits _memoryLimits; + private readonly CancellationToken _cancellationToken; + private readonly CancellationTokenRegistration _cancellationRegistration; + private bool _canceled; + private bool _disposed; + + public FusionPipelineSession( + CancellationToken cancellationToken, + FusionPipelineMemoryLimits? memoryLimits = null) + { + _memoryLimits = memoryLimits ?? FusionPipelineMemoryLimits.Default; + _cancellationToken = cancellationToken; + _cancellationRegistration = cancellationToken.UnsafeRegister( + static state => ((FusionPipelineSession)state!).Cancel(), + this); + } + + internal int DeploymentCount + { + get + { + lock (_sync) + { + return _deployments.Count; + } + } + } + + public void SetAll( + IReadOnlyList<( + FusionStageResource Deployment, + FusionDeploymentSessionState State)> deployments) + { + ArgumentNullException.ThrowIfNull(deployments); + + var uniqueDeployments = new HashSet(); + foreach (var (deployment, _) in deployments) + { + if (!uniqueDeployments.Add(deployment)) + { + throw new InvalidOperationException( + $"Fusion deployment '{deployment.Name}' was downloaded more than once."); + } + } + + var totalSourceBytes = deployments.Sum( + item => item.State.SourceArchiveBytes); + if (totalSourceBytes > _memoryLimits.TotalSourceArchiveBytes) + { + throw new InvalidDataException( + "The downloaded Fusion sources exceed the " + + $"{_memoryLimits.TotalSourceArchiveBytes:N0}-byte " + + "aggregate in-memory size limit."); + } + + lock (_sync) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_canceled) + { + DisposeStates(deployments.Select(item => item.State)); + throw new OperationCanceledException(_cancellationToken); + } + + Clear(); + foreach (var (deployment, state) in deployments) + { + _deployments.Add(deployment, state); + } + } + } + + public FusionDeploymentSessionState GetState( + FusionStageResource deployment) + { + ArgumentNullException.ThrowIfNull(deployment); + + lock (_sync) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_canceled) + { + throw new OperationCanceledException(_cancellationToken); + } + + if (!_deployments.TryGetValue(deployment, out var state)) + { + throw new InvalidOperationException( + $"Fusion sources for deployment '{deployment.Name}' have not been downloaded."); + } + + return state; + } + } + + public void Clear() + { + lock (_sync) + { + DisposeStates(_deployments.Values); + _deployments.Clear(); + } + } + + private void Cancel() + { + lock (_sync) + { + _canceled = true; + Clear(); + } + } + + private static void DisposeStates( + IEnumerable states) + { + foreach (var state in states) + { + state.Dispose(); + } + } + + public void Dispose() + { + lock (_sync) + { + if (_disposed) + { + return; + } + + _disposed = true; + } + + // Disposed outside the lock: it waits for a running cancellation + // callback, which itself needs the lock. + _cancellationRegistration.Dispose(); + Clear(); + } +} + +internal sealed class FusionDeploymentSessionState( + string tag, + string cloudUrl, + string apiId, + IReadOnlyList sourceIdentities) + : IDisposable +{ + private readonly object _sync = new(); + private IReadOnlyList? _sources; + private byte[]? _fusionArchive; + private string? _compositionEnvironment; + private string? _fusionArchiveSha256; + private bool _disposed; + + public string Tag { get; } = tag; + + public string CloudUrl { get; } = cloudUrl; + + public string ApiId { get; } = apiId; + + public IReadOnlyList SourceIdentities { get; } = + sourceIdentities; + + public IReadOnlyList Sources + { + get + { + lock (_sync) + { + ObjectDisposedException.ThrowIf(_disposed, this); + return _sources + ?? throw new InvalidOperationException( + "Fusion source archives have not been materialized."); + } + } + } + + public long SourceArchiveBytes + { + get + { + lock (_sync) + { + return _sources?.Sum( + source => (long)source.Archive.Length) ?? 0; + } + } + } + + public string? CompositionEnvironment + { + get + { + lock (_sync) + { + return _compositionEnvironment; + } + } + } + + public byte[] FusionArchive + { + get + { + lock (_sync) + { + ObjectDisposedException.ThrowIf(_disposed, this); + return _fusionArchive + ?? throw new InvalidOperationException( + "The downloaded Fusion sources have not been composed."); + } + } + } + + public string? FusionArchiveSha256 + { + get + { + lock (_sync) + { + return _fusionArchiveSha256; + } + } + } + + public void SetSources(IReadOnlyList sources) + { + ArgumentNullException.ThrowIfNull(sources); + + lock (_sync) + { + if (_disposed || _sources is not null) + { + foreach (var source in sources) + { + Array.Clear(source.Archive); + } + + ObjectDisposedException.ThrowIf(_disposed, this); + throw new InvalidOperationException( + "Fusion source archives have already been materialized."); + } + + _sources = sources; + } + } + + public void SetComposition( + string compositionEnvironment, + byte[] fusionArchive, + string fusionArchiveSha256) + { + ArgumentException.ThrowIfNullOrWhiteSpace(compositionEnvironment); + ArgumentNullException.ThrowIfNull(fusionArchive); + ArgumentException.ThrowIfNullOrWhiteSpace(fusionArchiveSha256); + + lock (_sync) + { + if (_disposed) + { + Array.Clear(fusionArchive); + throw new ObjectDisposedException( + nameof(FusionDeploymentSessionState)); + } + + if (_fusionArchive is not null) + { + Array.Clear(fusionArchive); + throw new InvalidOperationException( + "The Fusion archive has already been composed."); + } + + _compositionEnvironment = compositionEnvironment; + _fusionArchive = fusionArchive; + _fusionArchiveSha256 = fusionArchiveSha256; + } + } + + public void Dispose() + { + lock (_sync) + { + if (_disposed) + { + return; + } + + _disposed = true; + if (_sources is not null) + { + foreach (var source in _sources) + { + Array.Clear(source.Archive); + } + + _sources = null; + } + + if (_fusionArchive is not null) + { + Array.Clear(_fusionArchive); + _fusionArchive = null; + } + + _compositionEnvironment = null; + _fusionArchiveSha256 = null; + } + } +} + +internal sealed record FusionSessionSourceIdentity( + string Name, + string Version, + string ContentSha256); + +internal sealed record FusionSessionSource( + string Name, + string Version, + byte[] Archive, + string ContentSha256); diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/FusionStageResource.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/FusionStageResource.cs new file mode 100644 index 00000000000..593a215a269 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/FusionStageResource.cs @@ -0,0 +1,31 @@ +using Aspire.Hosting.ApplicationModel; + +namespace HotChocolate.Fusion.Aspire; + +/// +/// Represents a Nitro stage that a distributed application publishes its Fusion configuration to. +/// Every invocation publishes to exactly one declared stage of a Nitro api. +/// +public sealed class FusionStageResource( + string name, + string stageName, + NitroPublishTargetResource nitro) + : Resource(name) +{ + internal NitroPublishTargetResource Nitro { get; } = nitro; + + /// + /// The name of the Nitro stage. It is also the value that selects this stage for an invocation. + /// + internal string StageName { get; } = stageName; + + internal string? CompositionEnvironmentName { get; set; } + + internal bool WaitForApproval { get; set; } + + internal bool Force { get; set; } + + internal TimeSpan OperationTimeout { get; set; } = TimeSpan.FromMinutes(15); + + internal TimeSpan ApprovalTimeout { get; set; } = TimeSpan.FromHours(2); +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/GraphQLResourceBuilderExtensions.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/GraphQLResourceBuilderExtensions.cs index af8ed39c0da..9188cb35ec5 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Aspire/GraphQLResourceBuilderExtensions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/GraphQLResourceBuilderExtensions.cs @@ -74,6 +74,53 @@ public static IResourceBuilder WithGraphQLSchemaFile( return builder; } + /// + /// Marks a project resource as exporting a GraphQL schema through the Hot Chocolate + /// command-line schema exporter during publishing. + /// + /// The project resource builder. + /// The exact registered schema name to export. + /// The build configuration used by dotnet run. + /// The exact target framework used by dotnet run. + /// + /// The runtime identifier used by dotnet run, or for an + /// explicitly portable export. + /// + /// The maximum time allowed for the child process. + /// The resource builder for chaining. + public static IResourceBuilder WithGraphQLSchemaExport( + this IResourceBuilder builder, + string schemaName, + string configuration, + string targetFramework, + string? runtimeIdentifier, + TimeSpan timeout) + { + ArgumentException.ThrowIfNullOrWhiteSpace(schemaName); + ArgumentException.ThrowIfNullOrWhiteSpace(configuration); + ArgumentException.ThrowIfNullOrWhiteSpace(targetFramework); + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(timeout, TimeSpan.Zero); + + if (runtimeIdentifier is not null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(runtimeIdentifier); + } + + builder.WithAnnotation( + new GraphQLSourceSchemaAnnotation + { + SourceSchemaName = schemaName, + ExportSchemaName = schemaName, + ExportConfiguration = configuration, + ExportTargetFramework = targetFramework, + ExportRuntimeIdentifier = runtimeIdentifier, + ExportTimeout = timeout, + Location = SourceSchemaLocationType.CommandLineExport + }); + + return builder; + } + /// /// Marks a resource as needing GraphQL schema composition from its referenced subgraphs. /// diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/GraphQLResourceModel.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/GraphQLResourceModel.cs new file mode 100644 index 00000000000..652df83481e --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/GraphQLResourceModel.cs @@ -0,0 +1,115 @@ +using System.Diagnostics.CodeAnalysis; +using Aspire.Hosting.ApplicationModel; +using IOPath = System.IO.Path; + +namespace HotChocolate.Fusion.Aspire; + +internal static class GraphQLResourceModel +{ + [SuppressMessage( + "Trimming", + "IL2075:'this' argument does not satisfy 'DynamicallyAccessedMembersAttribute' " + + "in call to target method.")] + public static IReadOnlyList GetReferencedSourceSchemas( + IResourceWithEndpoints compositionResource, + DistributedApplicationModel model) + { + ArgumentNullException.ThrowIfNull(compositionResource); + ArgumentNullException.ThrowIfNull(model); + + var referencedResources = new HashSet( + ReferenceEqualityComparer.Instance); + + foreach (var annotation in compositionResource.Annotations) + { + switch (annotation) + { + case ResourceRelationshipAnnotation relationship: + referencedResources.Add(relationship.Resource); + break; + + case var endpointReference + when endpointReference.GetType().FullName + == "Aspire.Hosting.ApplicationModel.EndpointReferenceAnnotation": + var resourceProperty = endpointReference.GetType().GetProperty("Resource"); + if (resourceProperty?.GetValue(endpointReference) is IResource resource) + { + referencedResources.Add(resource); + } + break; + } + } + + return model.Resources + .OfType() + .Where(resource => referencedResources.Contains(resource)) + .Where(resource => resource.HasGraphQLSchema()) + .Select(resource => new GraphQLSourceSchemaResource( + resource, + GetSourceSchema(resource))) + .ToArray(); + } + + public static GraphQLSourceSchemaAnnotation GetSourceSchema( + IResource resource) + => resource.Annotations.OfType().Single(); + + public static GraphQLSchemaCompositionAnnotation GetComposition( + IResource resource) + => resource.Annotations.OfType().Single(); + + public static GraphQLProjectSchemaPaths GetProjectSchemaPaths( + IResource resource, + GraphQLSourceSchemaAnnotation declaration) + { + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(declaration); + + if (declaration.Location is not SourceSchemaLocationType.ProjectDirectory) + { + throw new InvalidOperationException( + $"GraphQL source resource '{resource.Name}' does not use a project schema file."); + } + + var projectPath = IOPath.GetFullPath(GetProjectPath(resource)); + var projectDirectory = IOPath.GetDirectoryName(projectPath)!; + var schemaPath = IOPath.GetFullPath( + IOPath.Combine( + projectDirectory, + declaration.SchemaPath ?? "schema.graphqls")); + var schemaDirectory = IOPath.GetDirectoryName(schemaPath)!; + var schemaFileName = IOPath.GetFileNameWithoutExtension(schemaPath); + + return new( + projectPath, + schemaPath, + IOPath.Combine(schemaDirectory, $"{schemaFileName}-settings.json"), + IOPath.Combine( + schemaDirectory, + schemaFileName + "-extensions" + IOPath.GetExtension(schemaPath))); + } + + [SuppressMessage( + "Trimming", + "IL2026:Members attributed with RequiresUnreferencedCodeAttribute require dynamic access otherwise.")] + public static string GetProjectPath(IResource resource) + { + if (resource is not ProjectResource projectResource) + { + throw new InvalidOperationException( + $"GraphQL source resource '{resource.Name}' must be a project resource."); + } + + return projectResource.GetProjectMetadata().ProjectPath; + } +} + +internal readonly record struct GraphQLSourceSchemaResource( + IResourceWithEndpoints Resource, + GraphQLSourceSchemaAnnotation Declaration); + +internal readonly record struct GraphQLProjectSchemaPaths( + string ProjectPath, + string SchemaPath, + string SettingsPath, + string ExtensionsPath); diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/GraphQLSourceSchemaAnnotation.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/GraphQLSourceSchemaAnnotation.cs index 1a42dd711ed..46be67d7a82 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Aspire/GraphQLSourceSchemaAnnotation.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/GraphQLSourceSchemaAnnotation.cs @@ -13,5 +13,15 @@ internal sealed class GraphQLSourceSchemaAnnotation : IResourceAnnotation /// public string? SchemaPath { get; init; } + public string? ExportSchemaName { get; init; } + + public string? ExportConfiguration { get; init; } + + public string? ExportTargetFramework { get; init; } + + public string? ExportRuntimeIdentifier { get; init; } + + public TimeSpan? ExportTimeout { get; init; } + public required SourceSchemaLocationType Location { get; init; } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/GraphQLSourceSchemaValidator.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/GraphQLSourceSchemaValidator.cs new file mode 100644 index 00000000000..acb8f77cdc2 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/GraphQLSourceSchemaValidator.cs @@ -0,0 +1,45 @@ +using HotChocolate.Fusion.Logging; + +namespace HotChocolate.Fusion.Aspire; + +internal static class GraphQLSourceSchemaValidator +{ + public static void Validate( + string resourceName, + SchemaEndpointConfiguration configuration, + string sourceText, + string? extensionsSourceText = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(resourceName); + + if (string.IsNullOrWhiteSpace(sourceText)) + { + throw new InvalidOperationException( + $"Schema export for resource '{resourceName}' produced empty GraphQL SDL."); + } + + if (extensionsSourceText is not null + && string.IsNullOrWhiteSpace(extensionsSourceText)) + { + throw new InvalidOperationException( + $"Schema extensions for resource '{resourceName}' contain empty GraphQL SDL."); + } + + var log = new CompositionLog(); + var result = new SourceSchemaParser( + new SourceSchemaText( + configuration.SourceSchemaName, + sourceText, + extensionsSourceText), + log, + isApolloFederationV1: + configuration.ApolloFederationVersion is ApolloFederationVersion.Version1) + .Parse(); + + if (result.IsFailure) + { + throw new InvalidOperationException( + $"Schema for resource '{resourceName}' is not valid GraphQL SDL."); + } + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/HotChocolate.Fusion.Aspire.csproj b/src/HotChocolate/Fusion/src/Fusion.Aspire/HotChocolate.Fusion.Aspire.csproj index c7b3490d2f3..587469ca8a5 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Aspire/HotChocolate.Fusion.Aspire.csproj +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/HotChocolate.Fusion.Aspire.csproj @@ -30,36 +30,17 @@ + - - - - - - - - - - - - + + - - - - - - - - - - - - + + diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/FusionArchiveContent.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/FusionArchiveContent.cs new file mode 100644 index 00000000000..0d1053ca765 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/FusionArchiveContent.cs @@ -0,0 +1,201 @@ +using System.Buffers; +using System.Buffers.Binary; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using HotChocolate.Fusion.SourceSchema.Packaging; +using HotChocolate.Language; + +namespace HotChocolate.Fusion.Aspire.Nitro; + +internal sealed record FusionArchiveContent( + string Schema, + string? SchemaExtensions, + string Settings) +{ + private static ReadOnlySpan DigestDomain + => "HotChocolate.Fusion.Aspire.SourceSchemaContent.v1"u8; + + public static async Task ReadAsync( + string archivePath, + string expectedName, + CancellationToken cancellationToken) + { + await using var stream = File.OpenRead(archivePath); + return await ReadAsync(stream, expectedName, cancellationToken); + } + + public static async Task ReadAsync( + Stream stream, + string expectedName, + CancellationToken cancellationToken) + { + try + { + using var archive = FusionSourceSchemaArchive.Open( + stream, + leaveOpen: true); + var schema = await archive.TryGetSchemaAsync(cancellationToken) + ?? throw new FusionDeploymentException( + "The Fusion source schema archive contains no schema."); + var extensions = + await archive.TryGetSchemaExtensionsAsync(cancellationToken); + using var settings = await archive.TryGetSettingsAsync(cancellationToken) + ?? throw new FusionDeploymentException( + "The Fusion source schema archive contains no settings."); + + if (!settings.RootElement.TryGetProperty("name", out var name) + || name.ValueKind is not JsonValueKind.String + || !string.Equals( + name.GetString(), + expectedName, + StringComparison.Ordinal)) + { + throw new FusionDeploymentException( + "The source schema settings name must exactly match " + + $"'{expectedName}'."); + } + + return new FusionArchiveContent( + NormalizeGraphQL(schema), + extensions is null + ? null + : NormalizeGraphQL(extensions.Value), + NormalizeJson(settings.RootElement)); + } + catch (FusionDeploymentException) + { + throw; + } + catch (Exception exception) when ( + exception is InvalidDataException + or InvalidOperationException + or JsonException + or SyntaxException) + { + throw new FusionDeploymentException( + "The Fusion source schema archive is invalid.", + exception); + } + } + + public string ComputeSha256(CancellationToken cancellationToken) + { + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + hash.AppendData(DigestDomain); + Append(hash, Schema, cancellationToken); + Append(hash, SchemaExtensions, cancellationToken); + Append(hash, Settings, cancellationToken); + return Convert.ToHexString(hash.GetHashAndReset()); + } + + private static string NormalizeGraphQL(ReadOnlyMemory source) + => Utf8GraphQLParser.Parse(source.Span).ToString(); + + private static string NormalizeJson(JsonElement value) + { + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + WriteCanonicalJson(writer, value); + } + + return Encoding.UTF8.GetString(stream.GetBuffer(), 0, (int)stream.Length); + } + + private static void WriteCanonicalJson( + Utf8JsonWriter writer, + JsonElement value) + { + switch (value.ValueKind) + { + case JsonValueKind.Object: + writer.WriteStartObject(); + foreach (var property in value.EnumerateObject() + .OrderBy(property => property.Name, StringComparer.Ordinal)) + { + writer.WritePropertyName(property.Name); + WriteCanonicalJson(writer, property.Value); + } + writer.WriteEndObject(); + break; + + case JsonValueKind.Array: + writer.WriteStartArray(); + foreach (var item in value.EnumerateArray()) + { + WriteCanonicalJson(writer, item); + } + writer.WriteEndArray(); + break; + + case JsonValueKind.String: + writer.WriteStringValue(value.GetString()); + break; + + case JsonValueKind.Number: + writer.WriteRawValue(value.GetRawText()); + break; + + case JsonValueKind.True: + writer.WriteBooleanValue(true); + break; + + case JsonValueKind.False: + writer.WriteBooleanValue(false); + break; + + case JsonValueKind.Null: + writer.WriteNullValue(); + break; + + default: + throw new JsonException( + $"Unsupported JSON token kind '{value.ValueKind}'."); + } + } + + private static void Append( + IncrementalHash hash, + string? value, + CancellationToken cancellationToken) + { + Span header = stackalloc byte[9]; + header[0] = value is null ? (byte)0 : (byte)1; + BinaryPrimitives.WriteInt64BigEndian( + header[1..], + value is null ? 0 : Encoding.UTF8.GetByteCount(value)); + hash.AppendData(header); + + if (value is null) + { + return; + } + + var buffer = ArrayPool.Shared.Rent(4096); + try + { + var encoder = Encoding.UTF8.GetEncoder(); + var remaining = value.AsSpan(); + var completed = false; + + while (!completed) + { + cancellationToken.ThrowIfCancellationRequested(); + encoder.Convert( + remaining, + buffer, + flush: true, + out var charsUsed, + out var bytesUsed, + out completed); + hash.AppendData(buffer.AsSpan(0, bytesUsed)); + remaining = remaining[charsUsed..]; + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/FusionDeploymentException.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/FusionDeploymentException.cs new file mode 100644 index 00000000000..3958b825f90 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/FusionDeploymentException.cs @@ -0,0 +1,17 @@ +namespace HotChocolate.Fusion.Aspire.Nitro; + +/// +/// The base exception for failures reported by the Fusion deployment workflow. +/// +internal class FusionDeploymentException : Exception +{ + public FusionDeploymentException(string message) + : base(message) + { + } + + public FusionDeploymentException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/FusionDeploymentWorkflow.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/FusionDeploymentWorkflow.cs new file mode 100644 index 00000000000..40d84ab947a --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/FusionDeploymentWorkflow.cs @@ -0,0 +1,742 @@ +using System.Security.Cryptography; +using HotChocolate.Fusion.Options; +using Microsoft.Extensions.Logging; + +namespace HotChocolate.Fusion.Aspire.Nitro; + +/// +/// Reconciles Fusion source schema versions and publishes Fusion configurations. +/// +internal sealed class FusionDeploymentWorkflow(NitroFusionApi api) +{ + /// + /// The GraphQL error code that a server reports for a field that its schema does not define. + /// + private const string UnknownFieldErrorCode = "HC0020"; + + /// + /// Downloads an exact immutable source schema version and computes its canonical content + /// identity. The caller owns a non-null result and must dispose it after use. + /// + public async Task DownloadSourceSchemaAsync( + FusionTarget target, + FusionSourceSchemaVersion source, + CancellationToken cancellationToken) + { + ValidateTarget(target); + ValidateSourceVersion(source); + + var archive = await api.DownloadSourceSchemaAsync( + target, + source.Name, + source.Version, + cancellationToken); + + if (archive is null) + { + return null; + } + + var ownershipTransferred = false; + try + { + await using var stream = new MemoryStream(archive, writable: false); + var content = await FusionArchiveContent.ReadAsync( + stream, + source.Name, + cancellationToken); + var contentSha256 = content.ComputeSha256(cancellationToken); + var download = new FusionSourceSchemaDownload( + source.Name, + source.Version, + archive, + contentSha256); + ownershipTransferred = true; + return download; + } + finally + { + if (!ownershipTransferred) + { + Array.Clear(archive); + } + } + } + + /// + /// Reads the composition settings that a stage declares. A Nitro server that does not know + /// the operation is reported as a warning and yields null, so that the composition + /// continues with the settings of the distributed application alone. + /// + /// + /// The Nitro api that the stage belongs to. + /// + /// + /// The name of the stage. + /// + /// + /// The logger that receives a warning when the Nitro server does not support the operation. + /// + /// + /// The cancellation token. + /// + public async Task TryGetStageCompositionSettingsAsync( + FusionTarget target, + string stageName, + ILogger logger, + CancellationToken cancellationToken) + { + ValidateTarget(target); + ArgumentException.ThrowIfNullOrWhiteSpace(stageName); + ArgumentNullException.ThrowIfNull(logger); + + try + { + return await api.GetStageCompositionSettingsAsync( + target, + stageName, + cancellationToken); + } + catch (NitroOperationException exception) + when (exception.ErrorCode is UnknownFieldErrorCode) + { + // a Nitro server that predates the stage composition settings answers with a field + // validation error, which is the only failure the composition can continue without. + logger.LogWarning( + "The composition settings of stage {StageName} could not be downloaded, so the " + + "composition only uses the settings of the distributed application. Update the " + + "Nitro server to the latest version. {Message}", + stageName, + exception.Message); + + return null; + } + } + + /// + /// Ensures that an immutable source schema version exists with the expected content. + /// + public async Task ReconcileSourceSchemaAsync( + FusionTarget target, + FusionSourceSchemaUpload source, + CancellationToken cancellationToken) + { + ValidateTarget(target); + ValidateSource(source); + + var localContent = await FusionArchiveContent.ReadAsync( + source.ArchivePath, + source.Name, + cancellationToken); + var localContentSha256 = localContent.ComputeSha256(cancellationToken); + await VerifySha256Async(source, cancellationToken); + + var remoteArchive = await api.DownloadSourceSchemaAsync( + target, + source.Name, + source.Version, + cancellationToken); + + if (remoteArchive is not null) + { + await EnsureContentMatchesAndClearAsync( + source, + localContentSha256, + remoteArchive, + cancellationToken); + return; + } + + FusionRemoteCommandResult uploadResult; + try + { + uploadResult = await api.UploadSourceSchemaAsync( + target, + source.Version, + source.ArchivePath, + cancellationToken); + } + catch (OperationCanceledException) when ( + cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + await ReconcileAfterUncertainUploadAsync( + api, + target, + source, + localContentSha256, + exception, + cancellationToken); + return; + } + + if (uploadResult.Succeeded) + { + return; + } + + if (uploadResult.IsDuplicate) + { + var racedArchive = await api.DownloadSourceSchemaAsync( + target, + source.Name, + source.Version, + cancellationToken); + if (racedArchive is null) + { + throw new FusionIndeterminateStateException( + $"Nitro reported source schema '{source.Name}' version " + + $"'{source.Version}' as duplicated, but the version " + + "could not be read back."); + } + + await EnsureContentMatchesAndClearAsync( + source, + localContentSha256, + racedArchive, + cancellationToken); + return; + } + + throw CreateRemoteFailure( + "Nitro rejected the Fusion source schema upload.", + uploadResult.Errors); + } + + /// + /// Publishes a locally composed Fusion archive and waits for a verified terminal result. + /// + public async Task PublishAsync( + FusionPublicationRequest request, + ReadOnlyMemory fusionArchive, + CancellationToken cancellationToken) + { + ValidatePublication(request, fusionArchive); + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken); + timeout.CancelAfter(request.OperationTimeout); + var operationToken = timeout.Token; + string? requestId = null; + + try + { + FusionRemoteBeginResult begin; + + try + { + begin = await api.BeginPublishAsync( + request, + operationToken); + } + catch (Exception exception) when ( + exception is not OperationCanceledException) + { + throw new FusionIndeterminateStateException( + "The Fusion publication request may have been created, " + + "but Nitro did not return a verifiable result.", + exception); + } + + if (begin.Errors.Count > 0) + { + throw CreateRemoteFailure( + "Nitro rejected the Fusion publication request.", + begin.Errors); + } + + requestId = begin.RequestId; + if (string.IsNullOrWhiteSpace(requestId)) + { + throw new FusionIndeterminateStateException( + "Nitro accepted the Fusion publication request without " + + "returning a request identifier."); + } + + await using var events = api.WatchPublishAsync( + request.Target, + requestId, + operationToken) + .GetAsyncEnumerator(operationToken); + + var readyResult = await WaitForReadyAsync( + events, + requestId, + operationToken); + if (readyResult is PublishWaitResult.Succeeded) + { + return; + } + + await EnsureRemoteCommandSucceededAsync( + () => api.ClaimPublishAsync( + request.Target, + requestId, + operationToken), + "claim the Fusion publication slot", + requestId); + + if (!request.WaitForApproval) + { + await EnsureRemoteCommandSucceededAsync( + () => api.ValidatePublishAsync( + request.Target, + requestId, + fusionArchive, + operationToken), + "validate the Fusion configuration", + requestId); + + var validation = await WaitForValidationAsync( + events, + requestId, + operationToken); + if (validation is { Succeeded: false } && !request.Force) + { + await ReleaseKnownFailedValidationAsync( + api, + request.Target, + requestId, + operationToken); + throw CreateRemoteFailure( + "Nitro rejected the Fusion configuration validation.", + validation.Errors); + } + } + + await EnsureRemoteCommandSucceededAsync( + () => api.CommitPublishAsync( + request.Target, + requestId, + fusionArchive, + operationToken), + "commit the Fusion configuration", + requestId); + + await WaitForTerminalPublishAsync( + events, + requestId, + timeout, + request.ApprovalTimeout, + operationToken); + } + catch (OperationCanceledException) when ( + !cancellationToken.IsCancellationRequested) + { + throw new FusionIndeterminateStateException( + "The Fusion publication timed out before a terminal result " + + "could be verified.", + requestId); + } + } + + private static async Task ReconcileAfterUncertainUploadAsync( + NitroFusionApi api, + FusionTarget target, + FusionSourceSchemaUpload source, + string localContentSha256, + Exception uploadException, + CancellationToken cancellationToken) + { + byte[]? remoteArchive; + try + { + remoteArchive = await api.DownloadSourceSchemaAsync( + target, + source.Name, + source.Version, + cancellationToken); + } + catch (Exception readException) + { + throw new FusionIndeterminateStateException( + $"The upload of source schema '{source.Name}' version " + + $"'{source.Version}' may have succeeded and could not be " + + "verified.", + new AggregateException(uploadException, readException)); + } + + if (remoteArchive is null) + { + throw new FusionIndeterminateStateException( + $"The upload of source schema '{source.Name}' version " + + $"'{source.Version}' returned an uncertain result and the " + + "version was not available for verification.", + uploadException); + } + + await EnsureContentMatchesAndClearAsync( + source, + localContentSha256, + remoteArchive, + cancellationToken); + } + + private static async Task EnsureContentMatchesAndClearAsync( + FusionSourceSchemaUpload source, + string localContentSha256, + byte[] remoteArchive, + CancellationToken cancellationToken) + { + try + { + await EnsureContentMatchesAsync( + source, + localContentSha256, + remoteArchive, + cancellationToken); + } + finally + { + Array.Clear(remoteArchive); + } + } + + private static async Task EnsureContentMatchesAsync( + FusionSourceSchemaUpload source, + string localContentSha256, + byte[] remoteArchive, + CancellationToken cancellationToken) + { + await using var stream = new MemoryStream(remoteArchive, writable: false); + var remoteContent = await FusionArchiveContent.ReadAsync( + stream, + source.Name, + cancellationToken); + + if (!string.Equals( + localContentSha256, + remoteContent.ComputeSha256(cancellationToken), + StringComparison.Ordinal)) + { + throw new FusionIdentityCollisionException( + $"Source schema '{source.Name}' version '{source.Version}' " + + "already exists with different normalized schema, settings, " + + "or extensions."); + } + } + + private static async Task VerifySha256Async( + FusionSourceSchemaUpload source, + CancellationToken cancellationToken) + { + ValidateSha256(source.Sha256, "The source schema SHA-256"); + + await using var stream = File.OpenRead(source.ArchivePath); + var actual = Convert.ToHexString( + await SHA256.HashDataAsync(stream, cancellationToken)); + if (!string.Equals( + actual, + source.Sha256, + StringComparison.OrdinalIgnoreCase)) + { + throw new FusionDeploymentException( + $"The SHA-256 for source schema '{source.Name}' version " + + $"'{source.Version}' does not match its archive."); + } + } + + private static async Task WaitForReadyAsync( + IAsyncEnumerator events, + string requestId, + CancellationToken cancellationToken) + { + while (await MoveNextAsync(events, requestId, cancellationToken)) + { + switch (events.Current.Kind) + { + case FusionRemoteEventKind.Ready: + return PublishWaitResult.Ready; + case FusionRemoteEventKind.PublishingSucceeded: + return PublishWaitResult.Succeeded; + case FusionRemoteEventKind.PublishingFailed: + throw CreateRemoteFailure( + "The Fusion publication failed.", + events.Current.Errors); + case FusionRemoteEventKind.Queued: + continue; + default: + throw new FusionIndeterminateStateException( + "The Fusion publication request was already in an " + + $"ambiguous '{events.Current.Kind}' state.", + requestId); + } + } + + throw CreateEndedEarly(requestId); + } + + private static async Task WaitForValidationAsync( + IAsyncEnumerator events, + string requestId, + CancellationToken cancellationToken) + { + while (await MoveNextAsync(events, requestId, cancellationToken)) + { + switch (events.Current.Kind) + { + case FusionRemoteEventKind.ValidationSucceeded: + return new FusionValidationResult(true, []); + case FusionRemoteEventKind.ValidationFailed: + return new FusionValidationResult( + false, + events.Current.Errors); + case FusionRemoteEventKind.InProgress: + continue; + case FusionRemoteEventKind.PublishingFailed: + throw CreateRemoteFailure( + "The Fusion publication failed during validation.", + events.Current.Errors); + default: + throw new FusionIndeterminateStateException( + "Nitro reported an unexpected Fusion validation state " + + $"'{events.Current.Kind}'.", + requestId); + } + } + + throw CreateEndedEarly(requestId); + } + + private static async Task WaitForTerminalPublishAsync( + IAsyncEnumerator events, + string requestId, + CancellationTokenSource timeout, + TimeSpan approvalTimeout, + CancellationToken cancellationToken) + { + while (await MoveNextAsync(events, requestId, cancellationToken)) + { + switch (events.Current.Kind) + { + case FusionRemoteEventKind.PublishingSucceeded: + return; + case FusionRemoteEventKind.PublishingFailed: + throw CreateRemoteFailure( + "The Fusion publication failed.", + events.Current.Errors); + case FusionRemoteEventKind.WaitingForApproval: + timeout.CancelAfter(approvalTimeout); + continue; + case FusionRemoteEventKind.Approved: + case FusionRemoteEventKind.InProgress: + case FusionRemoteEventKind.ValidationSucceeded: + continue; + default: + throw new FusionIndeterminateStateException( + "Nitro reported an unexpected Fusion publishing state " + + $"'{events.Current.Kind}'.", + requestId); + } + } + + throw CreateEndedEarly(requestId); + } + + private static async Task MoveNextAsync( + IAsyncEnumerator events, + string requestId, + CancellationToken cancellationToken) + { + try + { + var hasNext = await events.MoveNextAsync(); + cancellationToken.ThrowIfCancellationRequested(); + return hasNext; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + throw new FusionIndeterminateStateException( + "The Fusion publication event stream ended with an error.", + exception, + requestId); + } + } + + private static async Task EnsureRemoteCommandSucceededAsync( + Func> command, + string operation, + string requestId) + { + FusionRemoteCommandResult result; + try + { + result = await command(); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + throw new FusionIndeterminateStateException( + $"Nitro may have completed the operation to {operation}, but " + + "its result could not be verified.", + exception, + requestId); + } + + if (!result.Succeeded) + { + throw new FusionIndeterminateStateException( + $"Nitro could not {operation}: " + + FormatErrors(result.Errors), + requestId); + } + } + + private static async Task ReleaseKnownFailedValidationAsync( + NitroFusionApi api, + FusionTarget target, + string requestId, + CancellationToken cancellationToken) + { + var result = await api.ReleasePublishAsync( + target, + requestId, + cancellationToken); + if (!result.Succeeded) + { + throw new FusionIndeterminateStateException( + "The failed Fusion validation was known, but its publication " + + "slot could not be released: " + + FormatErrors(result.Errors), + requestId); + } + } + + private static FusionIndeterminateStateException CreateEndedEarly( + string requestId) + => new( + "The Fusion publication event stream ended before Nitro reported " + + "a terminal result.", + requestId); + + private static FusionDeploymentException CreateRemoteFailure( + string message, + IReadOnlyList errors) + => new($"{message} {FormatErrors(errors)}"); + + private static string FormatErrors(IReadOnlyList errors) + => errors.Count is 0 + ? "Nitro returned no error details." + : string.Join(" ", errors); + + private static void ValidateTarget(FusionTarget target) + { + ArgumentNullException.ThrowIfNull(target); + if (!target.CloudUrl.IsAbsoluteUri) + { + throw new ArgumentException( + "The Nitro cloud URL must be absolute.", + nameof(target)); + } + + ArgumentException.ThrowIfNullOrWhiteSpace(target.ApiId); + ArgumentException.ThrowIfNullOrWhiteSpace(target.ApiKey); + } + + private static void ValidateSource(FusionSourceSchemaUpload source) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentException.ThrowIfNullOrWhiteSpace(source.Name); + ArgumentException.ThrowIfNullOrWhiteSpace(source.Version); + ArgumentException.ThrowIfNullOrWhiteSpace(source.ArchivePath); + ArgumentException.ThrowIfNullOrWhiteSpace(source.Sha256); + if (!File.Exists(source.ArchivePath)) + { + throw new FileNotFoundException( + "The Fusion source schema archive does not exist.", + source.ArchivePath); + } + } + + private static void ValidateSourceVersion(FusionSourceSchemaVersion source) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentException.ThrowIfNullOrWhiteSpace(source.Name); + ArgumentException.ThrowIfNullOrWhiteSpace(source.Version); + } + + private static void ValidateSha256(string sha256, string description) + { + if (sha256.Length is not 64 + || !sha256.All(Uri.IsHexDigit)) + { + throw new FusionDeploymentException( + $"{description} must contain exactly 64 hexadecimal " + + "characters."); + } + } + + private static void ValidatePublication( + FusionPublicationRequest request, + ReadOnlyMemory fusionArchive) + { + ArgumentNullException.ThrowIfNull(request); + ValidateTarget(request.Target); + ArgumentException.ThrowIfNullOrWhiteSpace(request.Stage); + ArgumentException.ThrowIfNullOrWhiteSpace(request.ConfigurationTag); + ArgumentNullException.ThrowIfNull(request.SourceSchemas); + + if (request.SourceSchemas.Count is 0) + { + throw new ArgumentException( + "At least one source schema version must be selected.", + nameof(request)); + } + + foreach (var source in request.SourceSchemas) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentException.ThrowIfNullOrWhiteSpace(source.Name); + ArgumentException.ThrowIfNullOrWhiteSpace(source.Version); + } + + if (request.SourceSchemas + .GroupBy(source => source.Name, StringComparer.Ordinal) + .Any(group => group.Count() > 1)) + { + throw new ArgumentException( + "A source schema may only be selected once.", + nameof(request)); + } + + if (request.OperationTimeout <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException( + nameof(request), + "The operation timeout must be greater than zero."); + } + + if (request.ApprovalTimeout <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException( + nameof(request), + "The approval timeout must be greater than zero."); + } + + if (fusionArchive.IsEmpty) + { + throw new FusionDeploymentException( + "The Fusion configuration archive is empty."); + } + } + + private sealed record FusionValidationResult( + bool Succeeded, + IReadOnlyList Errors); + + private enum PublishWaitResult + { + Ready, + Succeeded + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/FusionIdentityCollisionException.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/FusionIdentityCollisionException.cs new file mode 100644 index 00000000000..4cd0969a578 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/FusionIdentityCollisionException.cs @@ -0,0 +1,12 @@ +namespace HotChocolate.Fusion.Aspire.Nitro; + +/// +/// A source schema name and version already exist with different content. +/// +internal sealed class FusionIdentityCollisionException : FusionDeploymentException +{ + public FusionIdentityCollisionException(string message) + : base(message) + { + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/FusionIndeterminateStateException.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/FusionIndeterminateStateException.cs new file mode 100644 index 00000000000..da4d0b021bf --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/FusionIndeterminateStateException.cs @@ -0,0 +1,27 @@ +namespace HotChocolate.Fusion.Aspire.Nitro; + +/// +/// A remote operation may have changed state, but its result could not be verified. +/// +internal sealed class FusionIndeterminateStateException : FusionDeploymentException +{ + public FusionIndeterminateStateException(string message, string? requestId = null) + : base(message) + { + RequestId = requestId; + } + + public FusionIndeterminateStateException( + string message, + Exception innerException, + string? requestId = null) + : base(message, innerException) + { + RequestId = requestId; + } + + /// + /// Gets the remote publication request identifier when one is known. + /// + public string? RequestId { get; } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/FusionPublicationRequest.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/FusionPublicationRequest.cs new file mode 100644 index 00000000000..b931ecec6b2 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/FusionPublicationRequest.cs @@ -0,0 +1,14 @@ +namespace HotChocolate.Fusion.Aspire.Nitro; + +/// +/// Describes a Fusion configuration publication. +/// +internal sealed record FusionPublicationRequest( + FusionTarget Target, + string Stage, + string ConfigurationTag, + IReadOnlyList SourceSchemas, + bool WaitForApproval, + bool Force, + TimeSpan OperationTimeout, + TimeSpan ApprovalTimeout); diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/FusionSourceSchemaContent.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/FusionSourceSchemaContent.cs new file mode 100644 index 00000000000..97aada0b786 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/FusionSourceSchemaContent.cs @@ -0,0 +1,60 @@ +namespace HotChocolate.Fusion.Aspire.Nitro; + +/// +/// Computes the canonical content identity of a Fusion source schema archive. +/// +internal static class FusionSourceSchemaContent +{ + /// + /// Computes a SHA-256 over the normalized schema, settings, and extensions. + /// + public static async Task ComputeSha256Async( + string archivePath, + string expectedName, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(archivePath); + ArgumentException.ThrowIfNullOrWhiteSpace(expectedName); + + if (!File.Exists(archivePath)) + { + throw new FileNotFoundException( + "The Fusion source schema archive does not exist.", + archivePath); + } + + var content = await FusionArchiveContent.ReadAsync( + archivePath, + expectedName, + cancellationToken); + return content.ComputeSha256(cancellationToken); + } + + /// + /// Computes a SHA-256 over the normalized schema, settings, and extensions in memory. + /// + public static async Task ComputeSha256Async( + byte[] archive, + string expectedName, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(archive); + ArgumentException.ThrowIfNullOrWhiteSpace(expectedName); + + if (archive.Length is 0) + { + throw new ArgumentException( + "The Fusion source schema archive is empty.", + nameof(archive)); + } + + await using var stream = new MemoryStream( + archive, + writable: false); + var content = await FusionArchiveContent.ReadAsync( + stream, + expectedName, + cancellationToken); + return content.ComputeSha256(cancellationToken); + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/FusionSourceSchemaDownload.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/FusionSourceSchemaDownload.cs new file mode 100644 index 00000000000..45db0d066c3 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/FusionSourceSchemaDownload.cs @@ -0,0 +1,80 @@ +namespace HotChocolate.Fusion.Aspire.Nitro; + +/// +/// Owns an exact immutable source schema archive downloaded from Nitro together with its +/// canonical content identity. +/// +/// +/// The caller must dispose this instance after consuming . Disposal clears +/// the owned archive buffer. +/// +internal sealed class FusionSourceSchemaDownload : IDisposable +{ + private byte[]? _archive; + + /// + /// Initializes a new owned source schema download. + /// + /// + /// Ownership of transfers to this instance. + /// + public FusionSourceSchemaDownload( + string name, + string version, + byte[] archive, + string contentSha256) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(version); + ArgumentNullException.ThrowIfNull(archive); + ArgumentException.ThrowIfNullOrWhiteSpace(contentSha256); + + Name = name; + Version = version; + _archive = archive; + ContentSha256 = contentSha256; + } + + /// + /// Gets the source schema name. + /// + public string Name { get; } + + /// + /// Gets the exact source schema version. + /// + public string Version { get; } + + /// + /// Gets read-only access to the owned archive buffer. + /// + /// + /// The download has already been disposed. + /// + public ReadOnlyMemory Archive + { + get + { + var archive = Volatile.Read(ref _archive); + ObjectDisposedException.ThrowIf(archive is null, this); + return archive; + } + } + + /// + /// Gets the canonical source content digest. + /// + public string ContentSha256 { get; } + + /// + /// Clears and releases the owned archive buffer. + /// + public void Dispose() + { + var archive = Interlocked.Exchange(ref _archive, null); + if (archive is not null) + { + Array.Clear(archive); + } + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/FusionSourceSchemaUpload.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/FusionSourceSchemaUpload.cs new file mode 100644 index 00000000000..6fcadf59422 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/FusionSourceSchemaUpload.cs @@ -0,0 +1,10 @@ +namespace HotChocolate.Fusion.Aspire.Nitro; + +/// +/// Describes an immutable Fusion source schema archive to reconcile. +/// +internal sealed record FusionSourceSchemaUpload( + string Name, + string Version, + string ArchivePath, + string Sha256); diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/FusionSourceSchemaVersion.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/FusionSourceSchemaVersion.cs new file mode 100644 index 00000000000..9e188fe4d02 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/FusionSourceSchemaVersion.cs @@ -0,0 +1,6 @@ +namespace HotChocolate.Fusion.Aspire.Nitro; + +/// +/// Selects one immutable source schema version for a Fusion publication. +/// +internal sealed record FusionSourceSchemaVersion(string Name, string Version); diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/FusionTarget.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/FusionTarget.cs new file mode 100644 index 00000000000..e98a918ddbe --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/FusionTarget.cs @@ -0,0 +1,10 @@ +namespace HotChocolate.Fusion.Aspire.Nitro; + +/// +/// Identifies a Nitro API and the credentials used to access it. +/// +internal sealed record FusionTarget(Uri CloudUrl, string ApiId, string ApiKey) +{ + /// + public override string ToString() => $"{CloudUrl} (API {ApiId})"; +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/NitroCompositionSettingsClient.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/NitroCompositionSettingsClient.cs index 31ebf82358a..ee69875bf2e 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/NitroCompositionSettingsClient.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/NitroCompositionSettingsClient.cs @@ -47,8 +47,10 @@ internal sealed class NitroCompositionSettingsClient(GraphQLHttpClient client) if (result.Errors.ValueKind is JsonValueKind.Array && result.Errors.GetArrayLength() > 0) { - throw new InvalidDataException( - "Nitro returned GraphQL errors for the composition settings operation."); + throw new NitroOperationException( + "Nitro returned GraphQL errors for the composition settings operation: " + + (ReadErrorMessage(result.Errors) ?? "no message."), + ReadErrorCode(result.Errors)); } if (result.Data.ValueKind is not JsonValueKind.Object @@ -109,6 +111,22 @@ internal sealed class NitroCompositionSettingsClient(GraphQLHttpClient client) }; } + private static string? ReadErrorMessage(JsonElement errors) + => errors[0].ValueKind is JsonValueKind.Object + && errors[0].TryGetProperty("message", out var message) + && message.ValueKind is JsonValueKind.String + ? message.GetString() + : null; + + private static string? ReadErrorCode(JsonElement errors) + => errors[0].ValueKind is JsonValueKind.Object + && errors[0].TryGetProperty("extensions", out var extensions) + && extensions.ValueKind is JsonValueKind.Object + && extensions.TryGetProperty("code", out var code) + && code.ValueKind is JsonValueKind.String + ? code.GetString() + : null; + private static bool? ReadBoolean(JsonElement parent, string propertyName) { if (!parent.TryGetProperty(propertyName, out var value) diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/NitroFusionApi.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/NitroFusionApi.cs new file mode 100644 index 00000000000..3c5a5232714 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/NitroFusionApi.cs @@ -0,0 +1,725 @@ +using System.Buffers; +using System.Net; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text.Json; +using HotChocolate.Fusion.Options; +using HotChocolate.Transport; +using HotChocolate.Transport.Http; + +namespace HotChocolate.Fusion.Aspire.Nitro; + +/// +/// Sends the Nitro API operations of the Fusion deployment workflow. Every operation is bounded +/// by the cancellation token of its caller, and results are read as JSON without an intermediate +/// object model. +/// +internal sealed class NitroFusionApi : IDisposable +{ + internal const int MaxSourceSchemaArchiveSize = 128_000_000; + + private const string SourceSchemaArchiveFileName = "source-schema.zip"; + private const string ValidatedConfigurationFileName = "gateway.fgp"; + private const string CommittedConfigurationFileName = "gateway.far"; + private const string ArchiveContentType = "application/zip"; + + private readonly HttpClient _httpClient; + private readonly bool _disposeHttpClient; + private readonly GraphQLHttpClient _client; + private readonly INitroCompositionSettingsClient _compositionSettingsClient; + + /// + /// Initializes the Nitro API over an existing HTTP client. + /// + /// + /// The HTTP client that sends the requests. It must not impose a timeout of its own, because + /// a publication is observed over a long-running subscription. + /// + /// + /// Specifies whether is disposed with this instance. + /// + public NitroFusionApi(HttpClient httpClient, bool disposeHttpClient) + { + ArgumentNullException.ThrowIfNull(httpClient); + + _httpClient = httpClient; + _disposeHttpClient = disposeHttpClient; + _client = GraphQLHttpClient.Create(httpClient, disposeHttpClient: false); + _compositionSettingsClient = new NitroCompositionSettingsClient(_client); + } + + /// + /// Creates the Nitro API with an HTTP client that it owns. + /// + public static NitroFusionApi Create() + { + var httpClient = new HttpClient + { + // A publication is observed over a subscription that stays open for as long as Nitro + // takes to reach a terminal state, so every bound comes from a cancellation token. + Timeout = Timeout.InfiniteTimeSpan, + DefaultRequestVersion = new Version(2, 0), + DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower + }; + + return new NitroFusionApi(httpClient, disposeHttpClient: true); + } + + /// + /// Downloads an exact immutable source schema version, or returns null when the + /// version does not exist. The caller owns a non-null result and must clear it after use. + /// + public async Task DownloadSourceSchemaAsync( + FusionTarget target, + string name, + string version, + CancellationToken cancellationToken) + { + var requestUri = new Uri( + target.CloudUrl, + $"/api/v1/apis/{Uri.EscapeDataString(target.ApiId)}" + + $"/fusion-subgraphs/{Uri.EscapeDataString(name)}" + + $"/versions/{Uri.EscapeDataString(version)}/download"); + using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + NitroRequestHeaders.Apply(request, CreateCredential(target)); + + using var response = await _httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + + if (response.StatusCode is HttpStatusCode.NotFound) + { + return null; + } + + EnsureSuccess(response); + + return await ReadSourceSchemaArchiveAsync( + response.Content, + MaxSourceSchemaArchiveSize, + cancellationToken); + } + + /// + /// Uploads an immutable source schema version. + /// + public async Task UploadSourceSchemaAsync( + FusionTarget target, + string version, + string archivePath, + CancellationToken cancellationToken) + { + var variables = new Dictionary + { + ["input"] = new Dictionary + { + ["apiId"] = target.ApiId, + ["tag"] = version, + ["archive"] = new FileReference( + () => File.OpenRead(archivePath), + SourceSchemaArchiveFileName, + ArchiveContentType) + } + }; + + var payload = await SendAsync( + target, + NitroOperationDocuments.UploadSourceSchemaOperationName, + "uploadFusionSubgraph", + variables, + enableFileUploads: true, + cancellationToken); + var errors = GetErrors(payload); + var hasVersion = + payload.TryGetProperty("fusionSubgraphVersion", out var uploadedVersion) + && uploadedVersion.ValueKind is JsonValueKind.Object; + + return new FusionRemoteCommandResult( + hasVersion && errors.Count is 0, + HasError(payload, "DuplicatedTagError"), + errors); + } + + /// + /// Reads the composition settings that a stage declares, or returns null when the + /// stage declares none. + /// + /// + /// Nitro rejected the request. + /// + public Task GetStageCompositionSettingsAsync( + FusionTarget target, + string stageName, + CancellationToken cancellationToken) + => _compositionSettingsClient.GetAsync( + CreateConnection(target), + target.ApiId, + stageName, + cancellationToken); + + /// + /// Opens a publication request. + /// + public async Task BeginPublishAsync( + FusionPublicationRequest request, + CancellationToken cancellationToken) + { + var variables = new Dictionary + { + ["input"] = new Dictionary + { + ["apiId"] = request.Target.ApiId, + ["stageName"] = request.Stage, + ["tag"] = request.ConfigurationTag, + ["waitForApproval"] = request.WaitForApproval, + ["subgraphs"] = request.SourceSchemas + .Select(source => new Dictionary + { + ["name"] = source.Name, + ["tag"] = source.Version + }) + .ToArray() + } + }; + + var payload = await SendAsync( + request.Target, + NitroOperationDocuments.BeginDeploymentOperationName, + "beginFusionConfigurationPublish", + variables, + enableFileUploads: false, + cancellationToken); + + return new FusionRemoteBeginResult( + GetString(payload, "requestId"), + GetErrors(payload)); + } + + /// + /// Observes the state of a publication request until Nitro ends the subscription. + /// + public async IAsyncEnumerable WatchPublishAsync( + FusionTarget target, + string requestId, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + var request = CreateRequest( + target, + NitroOperationDocuments.WatchDeploymentOperationName, + new Dictionary { ["requestId"] = requestId }, + enableFileUploads: false); + + var events = NitroGraphQL.StreamAsync( + _client, + request, + ReadRemoteEvent, + cancellationToken); + + await foreach (var remoteEvent in events.WithCancellation(cancellationToken)) + { + yield return remoteEvent; + } + } + + /// + /// Claims the publication slot of a publication request. + /// + public async Task ClaimPublishAsync( + FusionTarget target, + string requestId, + CancellationToken cancellationToken) + => ToCommandResult( + await SendAsync( + target, + NitroOperationDocuments.ClaimDeploymentOperationName, + "startFusionConfigurationComposition", + CreateRequestIdVariables(requestId), + enableFileUploads: false, + cancellationToken)); + + /// + /// Releases the publication slot of a publication request. + /// + public async Task ReleasePublishAsync( + FusionTarget target, + string requestId, + CancellationToken cancellationToken) + => ToCommandResult( + await SendAsync( + target, + NitroOperationDocuments.ReleaseDeploymentOperationName, + "cancelFusionConfigurationComposition", + CreateRequestIdVariables(requestId), + enableFileUploads: false, + cancellationToken)); + + /// + /// Hands the composed configuration to Nitro for validation. + /// + public async Task ValidatePublishAsync( + FusionTarget target, + string requestId, + ReadOnlyMemory archive, + CancellationToken cancellationToken) + => ToCommandResult( + await SendAsync( + target, + NitroOperationDocuments.ValidateDeploymentOperationName, + "validateFusionConfigurationComposition", + CreateConfigurationVariables( + requestId, + archive, + ValidatedConfigurationFileName), + enableFileUploads: true, + cancellationToken)); + + /// + /// Commits the composed configuration. + /// + public async Task CommitPublishAsync( + FusionTarget target, + string requestId, + ReadOnlyMemory archive, + CancellationToken cancellationToken) + => ToCommandResult( + await SendAsync( + target, + NitroOperationDocuments.CommitDeploymentOperationName, + "commitFusionConfigurationPublish", + CreateConfigurationVariables( + requestId, + archive, + CommittedConfigurationFileName), + enableFileUploads: true, + cancellationToken)); + + /// + public void Dispose() + { + _client.Dispose(); + + if (_disposeHttpClient) + { + _httpClient.Dispose(); + } + } + + private async Task SendAsync( + FusionTarget target, + string operationName, + string fieldName, + Dictionary variables, + bool enableFileUploads, + CancellationToken cancellationToken) + { + var result = await NitroGraphQL.SendWithRetryAsync( + _client, + CreateRequest(target, operationName, variables, enableFileUploads), + // A publication operation is not safe to send more than once. + retryTransientFailures: false, + // The publication as a whole is bounded by the cancellation token of the caller. + Timeout.InfiniteTimeSpan, + cancellationToken); + + if (result.Failure is not null) + { + throw new FusionDeploymentException(result.Failure); + } + + if (result.Data.ValueKind is not JsonValueKind.Object + || !result.Data.TryGetProperty(fieldName, out var payload) + || payload.ValueKind is not JsonValueKind.Object) + { + throw new FusionDeploymentException( + "Nitro GraphQL request returned no data."); + } + + return payload; + } + + private static GraphQLHttpRequest CreateRequest( + FusionTarget target, + string operationName, + Dictionary variables, + bool enableFileUploads) + { + return new GraphQLHttpRequest( + CreateBody(operationName, variables), + NitroApiUrl.CreateGraphQLEndpoint(target.CloudUrl)) + { + EnableFileUploads = enableFileUploads, + OnMessageCreated = (_, requestMessage, _) => + NitroRequestHeaders.Apply(requestMessage, CreateCredential(target)) + }; + } + + private static OperationRequest CreateBody( + string operationName, + Dictionary variables) + { +#if NITRO_PERSISTED_OPERATIONS + var operationId = operationName switch + { + NitroOperationDocuments.UploadSourceSchemaOperationName + => NitroOperationDocuments.GetUploadSourceSchemaOperationId(), + NitroOperationDocuments.BeginDeploymentOperationName + => NitroOperationDocuments.GetBeginDeploymentOperationId(), + NitroOperationDocuments.ClaimDeploymentOperationName + => NitroOperationDocuments.GetClaimDeploymentOperationId(), + NitroOperationDocuments.ValidateDeploymentOperationName + => NitroOperationDocuments.GetValidateDeploymentOperationId(), + NitroOperationDocuments.CommitDeploymentOperationName + => NitroOperationDocuments.GetCommitDeploymentOperationId(), + NitroOperationDocuments.ReleaseDeploymentOperationName + => NitroOperationDocuments.GetReleaseDeploymentOperationId(), + NitroOperationDocuments.WatchDeploymentOperationName + => NitroOperationDocuments.GetWatchDeploymentOperationId(), + _ => throw UnknownOperation(operationName) + }; + + return new OperationRequest( + id: operationId, + operationName: operationName, + variables: variables); +#else + var document = operationName switch + { + NitroOperationDocuments.UploadSourceSchemaOperationName + => NitroOperationDocuments.GetUploadSourceSchemaDocument(), + NitroOperationDocuments.BeginDeploymentOperationName + => NitroOperationDocuments.GetBeginDeploymentDocument(), + NitroOperationDocuments.ClaimDeploymentOperationName + => NitroOperationDocuments.GetClaimDeploymentDocument(), + NitroOperationDocuments.ValidateDeploymentOperationName + => NitroOperationDocuments.GetValidateDeploymentDocument(), + NitroOperationDocuments.CommitDeploymentOperationName + => NitroOperationDocuments.GetCommitDeploymentDocument(), + NitroOperationDocuments.ReleaseDeploymentOperationName + => NitroOperationDocuments.GetReleaseDeploymentDocument(), + NitroOperationDocuments.WatchDeploymentOperationName + => NitroOperationDocuments.GetWatchDeploymentDocument(), + _ => throw UnknownOperation(operationName) + }; + + return new OperationRequest( + document, + operationName: operationName, + variables: variables); +#endif + } + + private static InvalidOperationException UnknownOperation(string operationName) + => new($"The Nitro operation '{operationName}' is unknown."); + + private static Dictionary CreateRequestIdVariables(string requestId) + => new() + { + ["input"] = new Dictionary + { + ["requestId"] = requestId + } + }; + + private static Dictionary CreateConfigurationVariables( + string requestId, + ReadOnlyMemory archive, + string fileName) + => new() + { + ["input"] = new Dictionary + { + ["requestId"] = requestId, + ["configuration"] = new FileReference( + () => OpenRead(archive), + fileName, + ArchiveContentType) + } + }; + + private static NitroCredential CreateCredential(FusionTarget target) + => NitroCredential.FromApiKey(target.ApiKey); + + private static NitroConnection CreateConnection(FusionTarget target) + => new( + target.CloudUrl, + NitroApiUrl.CreateGraphQLEndpoint(target.CloudUrl), + CreateCredential(target)); + + private static FusionRemoteEvent ReadRemoteEvent(OperationResult result) + { + if (result.Data.ValueKind is not JsonValueKind.Object + || !result.Data.TryGetProperty( + "onFusionConfigurationPublishingTaskChanged", + out var value) + || value.ValueKind is not JsonValueKind.Object) + { + throw new FusionDeploymentException( + "Nitro GraphQL request returned no data."); + } + + var typeName = GetString(value, "__typename"); + var kind = typeName switch + { + "ProcessingTaskIsReady" => FusionRemoteEventKind.Ready, + "ProcessingTaskIsQueued" => FusionRemoteEventKind.Queued, + "FusionConfigurationValidationSuccess" + => FusionRemoteEventKind.ValidationSucceeded, + "FusionConfigurationValidationFailed" + => FusionRemoteEventKind.ValidationFailed, + "FusionConfigurationPublishingSuccess" + => FusionRemoteEventKind.PublishingSucceeded, + "FusionConfigurationPublishingFailed" + => FusionRemoteEventKind.PublishingFailed, + "WaitForApproval" => FusionRemoteEventKind.WaitingForApproval, + "ProcessingTaskApproved" => FusionRemoteEventKind.Approved, + "OperationInProgress" or "ValidationInProgress" + => FusionRemoteEventKind.InProgress, + _ => FusionRemoteEventKind.Unknown + }; + IReadOnlyList errors = kind is FusionRemoteEventKind.PublishingFailed + or FusionRemoteEventKind.ValidationFailed + ? GetErrors(value) + : []; + + return new FusionRemoteEvent(kind, errors); + } + + private static FusionRemoteCommandResult ToCommandResult(JsonElement payload) + { + var errors = GetErrors(payload); + + return new FusionRemoteCommandResult(errors.Count is 0, false, errors); + } + + private static List GetErrors(JsonElement payload) + { + var messages = new List(); + + if (!payload.TryGetProperty("errors", out var errors) + || errors.ValueKind is not JsonValueKind.Array) + { + return messages; + } + + foreach (var error in errors.EnumerateArray()) + { + if (GetString(error, "message") is { } message) + { + messages.Add(message); + } + } + + return messages; + } + + private static bool HasError(JsonElement payload, string typeName) + { + if (!payload.TryGetProperty("errors", out var errors) + || errors.ValueKind is not JsonValueKind.Array) + { + return false; + } + + foreach (var error in errors.EnumerateArray()) + { + if (string.Equals(GetString(error, "__typename"), typeName, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + private static string? GetString(JsonElement value, string propertyName) + => value.ValueKind is JsonValueKind.Object + && value.TryGetProperty(propertyName, out var property) + && property.ValueKind is JsonValueKind.String + ? property.GetString() + : null; + + private static Stream OpenRead(ReadOnlyMemory content) + { + if (MemoryMarshal.TryGetArray(content, out ArraySegment segment)) + { + return new MemoryStream( + segment.Array!, + segment.Offset, + segment.Count, + writable: false, + publiclyVisible: true); + } + + return new MemoryStream(content.ToArray(), writable: false); + } + + private static void EnsureSuccess(HttpResponseMessage response) + { + if (response.StatusCode is HttpStatusCode.Unauthorized + or HttpStatusCode.Forbidden) + { + throw new FusionDeploymentException( + "Nitro rejected the supplied API key."); + } + + if (!response.IsSuccessStatusCode) + { + throw new FusionDeploymentException( + $"Nitro returned HTTP {(int)response.StatusCode} " + + $"({response.ReasonPhrase})."); + } + } + + internal static Task ReadSourceSchemaArchiveAsync( + HttpContent content, + int maxArchiveSize, + CancellationToken cancellationToken) + => ReadSourceSchemaArchiveAsync( + content, + maxArchiveSize, + ArrayPool.Shared, + 81920, + cancellationToken); + + internal static async Task ReadSourceSchemaArchiveAsync( + HttpContent content, + int maxArchiveSize, + ArrayPool bufferPool, + int bufferSize, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(content); + ArgumentNullException.ThrowIfNull(bufferPool); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxArchiveSize); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bufferSize); + + if (content.Headers.ContentLength is > 0 and var contentLength + && contentLength > maxArchiveSize) + { + throw ArchiveResponseTooLarge(maxArchiveSize); + } + + await using var stream = await content.ReadAsStreamAsync(cancellationToken); + var chunks = new List<(byte[] Buffer, int Length)>(); + var length = 0; + + try + { + var endOfStream = false; + while (!endOfStream) + { + var remaining = maxArchiveSize - length; + var readSize = remaining >= bufferSize + ? bufferSize + : remaining + 1; + var buffer = bufferPool.Rent(readSize); + var bufferLength = 0; + var retained = false; + + try + { + while (bufferLength < readSize) + { + var bytesRead = await stream.ReadAsync( + buffer.AsMemory(bufferLength, readSize - bufferLength), + cancellationToken); + + if (bytesRead is 0) + { + endOfStream = true; + break; + } + + bufferLength += bytesRead; + } + + if (bufferLength is 0) + { + continue; + } + + if (bufferLength > maxArchiveSize - length) + { + throw ArchiveResponseTooLarge(maxArchiveSize); + } + + length += bufferLength; + chunks.Add((buffer, bufferLength)); + retained = true; + } + finally + { + if (!retained) + { + bufferPool.Return(buffer, clearArray: true); + } + } + } + + var archive = GC.AllocateUninitializedArray(length); + var offset = 0; + foreach (var chunk in chunks) + { + chunk.Buffer.AsSpan(0, chunk.Length).CopyTo(archive.AsSpan(offset)); + offset += chunk.Length; + } + + return archive; + } + finally + { + foreach (var chunk in chunks) + { + bufferPool.Return(chunk.Buffer, clearArray: true); + } + } + } + + private static FusionDeploymentException ArchiveResponseTooLarge(int maxArchiveSize) + => new( + "The compressed Fusion source schema archive exceeds the maximum " + + $"allowed size of {maxArchiveSize} bytes."); +} + +/// +/// The result of a Nitro operation that either succeeds or reports why it did not. +/// +internal sealed record FusionRemoteCommandResult( + bool Succeeded, + bool IsDuplicate, + IReadOnlyList Errors) +{ + public static FusionRemoteCommandResult Success { get; } = new(true, false, []); +} + +/// +/// The result of opening a publication request. +/// +internal sealed record FusionRemoteBeginResult( + string? RequestId, + IReadOnlyList Errors); + +/// +/// A state change of a publication request. +/// +internal sealed record FusionRemoteEvent( + FusionRemoteEventKind Kind, + IReadOnlyList Errors); + +/// +/// The kind of state change that Nitro reported for a publication request. +/// +internal enum FusionRemoteEventKind +{ + Ready, + Queued, + ValidationSucceeded, + ValidationFailed, + PublishingSucceeded, + PublishingFailed, + InProgress, + WaitingForApproval, + Approved, + Unknown +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/NitroGraphQL.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/NitroGraphQL.cs new file mode 100644 index 00000000000..e6c67e687a2 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/NitroGraphQL.cs @@ -0,0 +1,405 @@ +using System.Net; +using System.Runtime.CompilerServices; +using System.Text.Json; +using HotChocolate.Transport; +using HotChocolate.Transport.Http; + +namespace HotChocolate.Fusion.Aspire.Nitro; + +/// +/// Sends the GraphQL requests of the Nitro integration. Requests are bounded in time and in size, +/// transient transport failures are retried, and subscriptions are consumed over server-sent +/// events until the caller recognizes a terminal result. +/// +internal static class NitroGraphQL +{ + private const int MaxAttempts = 3; + private const int MaxResponseBytes = 2 * 1024 * 1024; + private const int MaxSubscriptionEvents = 1000; + private static readonly TimeSpan s_requestTimeout = TimeSpan.FromSeconds(15); + + /// + /// The time a single request may take when the caller has no bound of its own. + /// + public static TimeSpan DefaultRequestTimeout => s_requestTimeout; + + /// + /// Sends a request and reads its single result. + /// + /// + /// The GraphQL client that sends the request. + /// + /// + /// The request to send. + /// + /// + /// Specifies whether a transient transport failure is retried. Only requests that are safe to + /// send more than once can be retried. + /// + /// + /// The time a single attempt may take. Use when the + /// request is bounded by alone. + /// + /// + /// The cancellation token. + /// + public static async Task SendWithRetryAsync( + GraphQLHttpClient client, + GraphQLHttpRequest request, + bool retryTransientFailures, + TimeSpan requestTimeout, + CancellationToken cancellationToken) + { + for (var attempt = 1; ; attempt++) + { + try + { + using var attemptTimeout = + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + attemptTimeout.CancelAfter(requestTimeout); + + using var response = await client.SendAsync(request, attemptTimeout.Token); + if (!response.IsSuccessStatusCode) + { + if (retryTransientFailures + && IsTransient(response.StatusCode) + && attempt < MaxAttempts) + { + await DelayRetryAsync(attempt, cancellationToken); + continue; + } + + return NitroGraphQLResult.Failed( + $"Nitro returned HTTP {(int)response.StatusCode} ({response.StatusCode})."); + } + + if (response.ContentHeaders.ContentLength is > MaxResponseBytes) + { + return NitroGraphQLResult.Failed( + "Nitro returned a schema validation response that exceeded the size limit."); + } + + using var document = await ReadBoundedJsonAsync( + response.HttpResponseMessage, + attemptTimeout.Token); + var root = document.RootElement; + if (root.TryGetProperty("errors", out var errors) + && errors.ValueKind is JsonValueKind.Array + && errors.GetArrayLength() > 0) + { + return NitroGraphQLResult.Failed(DescribeErrors(errors)); + } + + if (!root.TryGetProperty("data", out var data)) + { + return NitroGraphQLResult.Failed("Nitro returned a malformed GraphQL response."); + } + + return NitroGraphQLResult.Success(data.Clone()); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (OperationCanceledException) when ( + retryTransientFailures && attempt < MaxAttempts) + { + await DelayRetryAsync(attempt, cancellationToken); + } + catch (HttpRequestException) when ( + retryTransientFailures && attempt < MaxAttempts) + { + await DelayRetryAsync(attempt, cancellationToken); + } + catch (IOException) when ( + retryTransientFailures && attempt < MaxAttempts) + { + await DelayRetryAsync(attempt, cancellationToken); + } + } + } + + /// + /// Subscribes to a Nitro subscription and waits for the terminal result of the operation. The + /// subscription is consumed over server-sent events and every event is handed to + /// , which returns null for an event that the + /// operation has not completed with. + /// + /// + /// The GraphQL client that sends the subscription. + /// + /// + /// The subscription request to send. + /// + /// + /// Reads the terminal result of the operation from an event, or returns null when the + /// operation is still running. The values of an event are only valid for the duration of the + /// call, so everything that outlives the call has to be copied. + /// + /// + /// The cancellation token. It bounds the subscription as a whole. + /// + /// + /// The terminal result of the operation, or the reason why none was received. A subscription + /// that ends without a terminal result is a failure. + /// + public static async Task<(T? Value, string? Failure)> SubscribeAsync( + GraphQLHttpClient client, + GraphQLHttpRequest request, + Func readTerminalResult, + CancellationToken cancellationToken) + where T : class + { + var (response, failure) = await ConnectAsync(client, request, cancellationToken); + + if (response is null) + { + return (null, failure); + } + + using (response) + { + // Once the first event can arrive the subscription is no longer safe to send again, + // so the stream itself is never retried. + return await ReadSubscriptionAsync(response, readTerminalResult, cancellationToken); + } + } + + /// + /// Subscribes to a Nitro subscription and reads every event of the subscription until it + /// ends. The subscription is consumed over server-sent events. + /// + /// + /// The GraphQL client that sends the subscription. + /// + /// + /// The subscription request to send. + /// + /// + /// Reads an event of the subscription. The values of an event are only valid for the duration + /// of the call, so everything that outlives the call has to be copied. + /// + /// + /// The cancellation token. It bounds the subscription as a whole. + /// + /// + /// The subscription could not be established, or Nitro ended it with an error. + /// + public static async IAsyncEnumerable StreamAsync( + GraphQLHttpClient client, + GraphQLHttpRequest request, + Func readEvent, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + var (response, failure) = await ConnectAsync(client, request, cancellationToken); + + if (response is null) + { + throw new NitroSubscriptionException(failure!); + } + + using (response) + { + var eventCount = 0; + + await foreach (var result in response.ReadAsResultStreamAsync() + .WithCancellation(cancellationToken)) + { + using (result) + { + if (++eventCount > MaxSubscriptionEvents) + { + throw new NitroSubscriptionException( + "Nitro sent too many subscription events."); + } + + if (result.Errors.ValueKind is JsonValueKind.Array + && result.Errors.GetArrayLength() > 0) + { + throw new NitroSubscriptionException(DescribeErrors(result.Errors)); + } + + yield return readEvent(result); + } + } + } + } + + /// + /// Establishes a subscription over server-sent events. The returned response is owned by the + /// caller and no event has been read from it yet. + /// + private static async Task<(GraphQLHttpResponse? Response, string? Failure)> ConnectAsync( + GraphQLHttpClient client, + GraphQLHttpRequest request, + CancellationToken cancellationToken) + { + request.Accept = GraphQLHttpRequest.GraphQLOverSse; + + for (var attempt = 1; ; attempt++) + { + GraphQLHttpResponse response; + + try + { + response = await SubscribeCoreAsync(client, request, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (OperationCanceledException) when (attempt < MaxAttempts) + { + await DelayRetryAsync(attempt, cancellationToken); + continue; + } + catch (HttpRequestException) when (attempt < MaxAttempts) + { + await DelayRetryAsync(attempt, cancellationToken); + continue; + } + catch (IOException) when (attempt < MaxAttempts) + { + await DelayRetryAsync(attempt, cancellationToken); + continue; + } + + if (!response.IsSuccessStatusCode) + { + var statusCode = response.StatusCode; + response.Dispose(); + + if (IsTransient(statusCode) && attempt < MaxAttempts) + { + await DelayRetryAsync(attempt, cancellationToken); + continue; + } + + return (null, $"Nitro returned HTTP {(int)statusCode} ({statusCode})."); + } + + // A server that ignores the pinned Accept header answers with a single result + // instead of a stream, which would end the subscription after one event. That is + // a protocol failure and not the end of a subscription. + var mediaType = response.ContentHeaders.ContentType?.MediaType; + if (!string.Equals(mediaType, ContentType.EventStream, StringComparison.OrdinalIgnoreCase)) + { + response.Dispose(); + + return ( + null, + "Nitro answered the subscription with the content type " + + $"'{mediaType ?? "none"}' instead of {ContentType.EventStream}."); + } + + return (response, null); + } + } + + private static async Task SubscribeCoreAsync( + GraphQLHttpClient client, + GraphQLHttpRequest request, + CancellationToken cancellationToken) + { + // The request timeout only covers the subscribe call, which completes once the response + // headers have been read. The stream that follows is bounded by the cancellation token of + // the caller, so the timeout is released before the first event is read. + using var requestTimeout = + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + requestTimeout.CancelAfter(s_requestTimeout); + + return await client.SendAsync(request, requestTimeout.Token); + } + + private static async Task<(T? Value, string? Failure)> ReadSubscriptionAsync( + GraphQLHttpResponse response, + Func readTerminalResult, + CancellationToken cancellationToken) + where T : class + { + var eventCount = 0; + + await foreach (var result in response.ReadAsResultStreamAsync() + .WithCancellation(cancellationToken)) + { + using (result) + { + if (++eventCount > MaxSubscriptionEvents) + { + return (null, "Nitro sent too many subscription events."); + } + + if (result.Errors.ValueKind is JsonValueKind.Array + && result.Errors.GetArrayLength() > 0) + { + return (null, DescribeErrors(result.Errors)); + } + + var value = readTerminalResult(result); + if (value is not null) + { + return (value, null); + } + } + } + + return (null, "Nitro ended the subscription without a result."); + } + + private static string DescribeErrors(JsonElement errors) + { + var error = errors[0]; + + return error.ValueKind is JsonValueKind.Object + && error.TryGetProperty("message", out var message) + && message.ValueKind is JsonValueKind.String + ? $"Nitro returned GraphQL errors: {message.GetString()}" + : "Nitro returned GraphQL errors."; + } + + private static async Task ReadBoundedJsonAsync( + HttpResponseMessage response, + CancellationToken cancellationToken) + { + await using var source = await response.Content.ReadAsStreamAsync(cancellationToken); + await using var destination = new MemoryStream(); + var buffer = new byte[16 * 1024]; + + while (true) + { + var read = await source.ReadAsync(buffer, cancellationToken); + if (read == 0) + { + break; + } + + if (destination.Length + read > MaxResponseBytes) + { + throw new NitroResponseTooLargeException( + "Nitro returned a schema validation response that exceeded the size limit."); + } + + destination.Write(buffer, 0, read); + } + + destination.Position = 0; + return await JsonDocument.ParseAsync(destination, default, cancellationToken); + } + + private static async Task DelayRetryAsync(int attempt, CancellationToken cancellationToken) + => await Task.Delay( + TimeSpan.FromMilliseconds(250 * (1 << (attempt - 1))), + cancellationToken); + + private static bool IsTransient(HttpStatusCode statusCode) + => statusCode is HttpStatusCode.RequestTimeout + or HttpStatusCode.TooManyRequests + || statusCode >= HttpStatusCode.InternalServerError; + + private sealed class NitroResponseTooLargeException(string message) : Exception(message); +} + +/// +/// A Nitro subscription could not be established, or Nitro ended it with an error. +/// +internal sealed class NitroSubscriptionException(string message) : Exception(message); diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/NitroGraphQLResult.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/NitroGraphQLResult.cs new file mode 100644 index 00000000000..d55d41fbd59 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/NitroGraphQLResult.cs @@ -0,0 +1,32 @@ +using System.Text.Json; + +namespace HotChocolate.Fusion.Aspire.Nitro; + +/// +/// The result of a single Nitro GraphQL request. Either the data of the response or the reason +/// why Nitro was unavailable. +/// +internal sealed class NitroGraphQLResult +{ + private NitroGraphQLResult(JsonElement data, string? failure) + { + Data = data; + Failure = failure; + } + + /// + /// Gets the data of the response. + /// + public JsonElement Data { get; } + + /// + /// Gets the reason why Nitro was unavailable, or null when the request succeeded. + /// + public string? Failure { get; } + + public static NitroGraphQLResult Success(JsonElement data) + => new(data, null); + + public static NitroGraphQLResult Failed(string failure) + => new(default, failure); +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/NitroOperationDocuments.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/NitroOperationDocuments.cs index 4c9bb9c4040..f13a5186369 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/NitroOperationDocuments.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/NitroOperationDocuments.cs @@ -13,32 +13,60 @@ internal static class NitroOperationDocuments #if NITRO_PERSISTED_OPERATIONS private const string ResolveApiNameHashFile = "ResolveNitroApiName.graphql.sha256"; private const string ValidateSchemaHashFile = "ValidateNitroSchema.graphql.sha256"; - private const string PollSchemaValidationHashFile = "PollNitroSchemaValidation.graphql.sha256"; + private const string WatchSchemaValidationHashFile = "WatchNitroSchemaValidation.graphql.sha256"; private const string GetStageVersionHashFile = "GetNitroStageVersion.graphql.sha256"; private const string WatchStageHashFile = "WatchNitroStage.graphql.sha256"; private const string GetCompositionSettingsHashFile = "GetNitroCompositionSettings.graphql.sha256"; + private const string UploadSourceSchemaHashFile = "UploadFusionSourceSchema.graphql.sha256"; + private const string BeginDeploymentHashFile = "BeginFusionDeployment.graphql.sha256"; + private const string ClaimDeploymentHashFile = "ClaimFusionDeployment.graphql.sha256"; + private const string ValidateDeploymentHashFile = "ValidateFusionDeployment.graphql.sha256"; + private const string CommitDeploymentHashFile = "CommitFusionDeployment.graphql.sha256"; + private const string ReleaseDeploymentHashFile = "ReleaseFusionDeployment.graphql.sha256"; + private const string WatchDeploymentHashFile = "WatchFusionDeployment.graphql.sha256"; private static string? s_resolveApiNameHash; private static string? s_validateSchemaHash; - private static string? s_pollSchemaValidationHash; + private static string? s_watchSchemaValidationHash; private static string? s_getStageVersionHash; private static string? s_watchStageHash; private static string? s_getCompositionSettingsHash; + private static string? s_uploadSourceSchemaHash; + private static string? s_beginDeploymentHash; + private static string? s_claimDeploymentHash; + private static string? s_validateDeploymentHash; + private static string? s_commitDeploymentHash; + private static string? s_releaseDeploymentHash; + private static string? s_watchDeploymentHash; #else private const string ResolveApiNameFile = "ResolveNitroApiName.graphql"; private const string ValidateSchemaFile = "ValidateNitroSchema.graphql"; - private const string PollSchemaValidationFile = "PollNitroSchemaValidation.graphql"; + private const string WatchSchemaValidationFile = "WatchNitroSchemaValidation.graphql"; private const string GetStageVersionFile = "GetNitroStageVersion.graphql"; private const string WatchStageFile = "WatchNitroStage.graphql"; private const string GetCompositionSettingsFile = "GetNitroCompositionSettings.graphql"; + private const string UploadSourceSchemaFile = "UploadFusionSourceSchema.graphql"; + private const string BeginDeploymentFile = "BeginFusionDeployment.graphql"; + private const string ClaimDeploymentFile = "ClaimFusionDeployment.graphql"; + private const string ValidateDeploymentFile = "ValidateFusionDeployment.graphql"; + private const string CommitDeploymentFile = "CommitFusionDeployment.graphql"; + private const string ReleaseDeploymentFile = "ReleaseFusionDeployment.graphql"; + private const string WatchDeploymentFile = "WatchFusionDeployment.graphql"; private static string? s_resolveApiName; private static string? s_validateSchema; - private static string? s_pollSchemaValidation; + private static string? s_watchSchemaValidation; private static string? s_getStageVersion; private static string? s_watchStage; private static string? s_getCompositionSettings; + private static string? s_uploadSourceSchema; + private static string? s_beginDeployment; + private static string? s_claimDeployment; + private static string? s_validateDeployment; + private static string? s_commitDeployment; + private static string? s_releaseDeployment; + private static string? s_watchDeployment; #endif /// @@ -46,11 +74,46 @@ internal static class NitroOperationDocuments /// public const string ResolveApiNameOperationName = "ResolveNitroApiName"; public const string ValidateSchemaOperationName = "ValidateNitroSchema"; - public const string PollSchemaValidationOperationName = "PollNitroSchemaValidation"; + public const string WatchSchemaValidationOperationName = "WatchNitroSchemaValidation"; public const string GetStageVersionOperationName = "GetNitroStageVersion"; public const string WatchStageOperationName = "WatchNitroStage"; public const string GetCompositionSettingsOperationName = "GetNitroCompositionSettings"; + /// + /// The operation name of the document that uploads an immutable source schema version. + /// + public const string UploadSourceSchemaOperationName = "UploadFusionSourceSchema"; + + /// + /// The operation name of the document that opens a publication request. + /// + public const string BeginDeploymentOperationName = "BeginFusionDeployment"; + + /// + /// The operation name of the document that claims the publication slot. + /// + public const string ClaimDeploymentOperationName = "ClaimFusionDeployment"; + + /// + /// The operation name of the document that validates a composed configuration. + /// + public const string ValidateDeploymentOperationName = "ValidateFusionDeployment"; + + /// + /// The operation name of the document that commits a composed configuration. + /// + public const string CommitDeploymentOperationName = "CommitFusionDeployment"; + + /// + /// The operation name of the document that releases the publication slot. + /// + public const string ReleaseDeploymentOperationName = "ReleaseFusionDeployment"; + + /// + /// The operation name of the document that observes the state of a publication request. + /// + public const string WatchDeploymentOperationName = "WatchFusionDeployment"; + #if NITRO_PERSISTED_OPERATIONS /// /// Gets the persisted operation id of the document that resolves the name of an api by its id. @@ -61,8 +124,8 @@ public static string GetResolveApiNameOperationId() public static string GetValidateSchemaOperationId() => s_validateSchemaHash ??= ReadDocument(ValidateSchemaHashFile).Trim(); - public static string GetPollSchemaValidationOperationId() - => s_pollSchemaValidationHash ??= ReadDocument(PollSchemaValidationHashFile).Trim(); + public static string GetWatchSchemaValidationOperationId() + => s_watchSchemaValidationHash ??= ReadDocument(WatchSchemaValidationHashFile).Trim(); public static string GetStageVersionOperationId() => s_getStageVersionHash ??= ReadDocument(GetStageVersionHashFile).Trim(); @@ -71,8 +134,28 @@ public static string GetWatchStageOperationId() => s_watchStageHash ??= ReadDocument(WatchStageHashFile).Trim(); public static string GetCompositionSettingsOperationId() - => s_getCompositionSettingsHash ??= - ReadDocument(GetCompositionSettingsHashFile).Trim(); + => s_getCompositionSettingsHash ??= ReadDocument(GetCompositionSettingsHashFile).Trim(); + + public static string GetUploadSourceSchemaOperationId() + => s_uploadSourceSchemaHash ??= ReadDocument(UploadSourceSchemaHashFile).Trim(); + + public static string GetBeginDeploymentOperationId() + => s_beginDeploymentHash ??= ReadDocument(BeginDeploymentHashFile).Trim(); + + public static string GetClaimDeploymentOperationId() + => s_claimDeploymentHash ??= ReadDocument(ClaimDeploymentHashFile).Trim(); + + public static string GetValidateDeploymentOperationId() + => s_validateDeploymentHash ??= ReadDocument(ValidateDeploymentHashFile).Trim(); + + public static string GetCommitDeploymentOperationId() + => s_commitDeploymentHash ??= ReadDocument(CommitDeploymentHashFile).Trim(); + + public static string GetReleaseDeploymentOperationId() + => s_releaseDeploymentHash ??= ReadDocument(ReleaseDeploymentHashFile).Trim(); + + public static string GetWatchDeploymentOperationId() + => s_watchDeploymentHash ??= ReadDocument(WatchDeploymentHashFile).Trim(); #else /// /// Gets the document that resolves the name of an api by its id. @@ -83,8 +166,8 @@ public static string GetResolveApiNameDocument() public static string GetValidateSchemaDocument() => s_validateSchema ??= ReadDocument(ValidateSchemaFile); - public static string GetPollSchemaValidationDocument() - => s_pollSchemaValidation ??= ReadDocument(PollSchemaValidationFile); + public static string GetWatchSchemaValidationDocument() + => s_watchSchemaValidation ??= ReadDocument(WatchSchemaValidationFile); public static string GetStageVersionDocument() => s_getStageVersion ??= ReadDocument(GetStageVersionFile); @@ -94,6 +177,27 @@ public static string GetWatchStageDocument() public static string GetCompositionSettingsDocument() => s_getCompositionSettings ??= ReadDocument(GetCompositionSettingsFile); + + public static string GetUploadSourceSchemaDocument() + => s_uploadSourceSchema ??= ReadDocument(UploadSourceSchemaFile); + + public static string GetBeginDeploymentDocument() + => s_beginDeployment ??= ReadDocument(BeginDeploymentFile); + + public static string GetClaimDeploymentDocument() + => s_claimDeployment ??= ReadDocument(ClaimDeploymentFile); + + public static string GetValidateDeploymentDocument() + => s_validateDeployment ??= ReadDocument(ValidateDeploymentFile); + + public static string GetCommitDeploymentDocument() + => s_commitDeployment ??= ReadDocument(CommitDeploymentFile); + + public static string GetReleaseDeploymentDocument() + => s_releaseDeployment ??= ReadDocument(ReleaseDeploymentFile); + + public static string GetWatchDeploymentDocument() + => s_watchDeployment ??= ReadDocument(WatchDeploymentFile); #endif private static string ReadDocument(string fileName) diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/NitroOperationException.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/NitroOperationException.cs new file mode 100644 index 00000000000..632618c764f --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/NitroOperationException.cs @@ -0,0 +1,29 @@ +namespace HotChocolate.Fusion.Aspire.Nitro; + +/// +/// Nitro answered an operation with GraphQL errors. +/// +internal sealed class NitroOperationException : Exception +{ + /// + /// Initializes a new instance of . + /// + /// + /// The message that describes the errors that Nitro reported. + /// + /// + /// The code that Nitro reported for the first error, or null when the error carries + /// no code. + /// + public NitroOperationException(string message, string? errorCode) + : base(message) + { + ErrorCode = errorCode; + } + + /// + /// Gets the code that Nitro reported for the first error, or null when the error + /// carries no code. + /// + public string? ErrorCode { get; } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/NitroSchemaValidator.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/NitroSchemaValidator.cs index 80086c3a490..dae85eaebdb 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/NitroSchemaValidator.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/NitroSchemaValidator.cs @@ -1,4 +1,3 @@ -using System.Net; using System.Text.Json; using HotChocolate.Transport; using HotChocolate.Transport.Http; @@ -8,17 +7,11 @@ namespace HotChocolate.Fusion.Aspire.Nitro; internal sealed class NitroSchemaValidator( GraphQLHttpClient client, - TimeProvider timeProvider, ILogger logger) : INitroSchemaValidator { - private const int MaxAttempts = 3; - private const int MaxResponseBytes = 2 * 1024 * 1024; private const int MaxParsedFindings = 10_000; - private static readonly TimeSpan s_requestTimeout = TimeSpan.FromSeconds(15); private static readonly TimeSpan s_overallTimeout = TimeSpan.FromMinutes(2); - private static readonly TimeSpan s_initialPollDelay = TimeSpan.FromMilliseconds(250); - private static readonly TimeSpan s_maxPollDelay = TimeSpan.FromSeconds(2); public async Task ValidateAsync( NitroConnection connection, @@ -66,51 +59,13 @@ public async Task ValidateAsync( schemaHash); } - var pollDelay = s_initialPollDelay; - - while (true) - { - await Task.Delay(pollDelay, timeProvider, overall.Token); - - var poll = await SendPollAsync(connection, requestId, overall.Token); - if (poll.Failure is not null) - { - return Unavailable(poll.Failure, schemaHash, requestId); - } - - if (poll.Result.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) - { - return Unavailable( - "Nitro returned no schema validation result.", - schemaHash, - requestId); - } + var watch = await NitroGraphQL.SubscribeAsync( + client, + CreateWatchRequest(connection, requestId), + result => ReadValidationResult(result, schemaHash, requestId), + overall.Token); - var typeName = GetString(poll.Result, "__typename"); - switch (typeName) - { - case "OperationInProgress": - case "ValidationInProgress": - pollDelay = TimeSpan.FromMilliseconds( - Math.Min(pollDelay.TotalMilliseconds * 2, s_maxPollDelay.TotalMilliseconds)); - continue; - - case "SchemaVersionValidationSuccess": - return NitroSchemaValidationReport.Passed( - schemaHash, - requestId, - timeProvider.GetUtcNow()); - - case "SchemaVersionValidationFailed": - return ParseViolations(poll.Result, schemaHash, requestId); - - default: - return Unavailable( - $"Nitro returned the unknown validation result '{typeName ?? "null"}'.", - schemaHash, - requestId); - } - } + return watch.Value ?? Unavailable(watch.Failure!, schemaHash, requestId); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -153,7 +108,8 @@ public async Task ValidateAsync( } }; - var result = await SendWithRetryAsync( + var result = await NitroGraphQL.SendWithRetryAsync( + client, CreateRequest( connection, NitroOperationDocuments.ValidateSchemaOperationName, @@ -165,6 +121,7 @@ public async Task ValidateAsync( variables, enableFileUploads: true), retryTransientFailures: false, + NitroGraphQL.DefaultRequestTimeout, cancellationToken); if (result.Failure is not null) @@ -185,154 +142,57 @@ public async Task ValidateAsync( : (null, error); } - private async Task<(JsonElement Result, string? Failure)> SendPollAsync( + private static GraphQLHttpRequest CreateWatchRequest( NitroConnection connection, - string requestId, - CancellationToken cancellationToken) - { - var variables = new Dictionary - { - ["input"] = new Dictionary { ["id"] = requestId } - }; - - var result = await SendWithRetryAsync( - CreateRequest( - connection, - NitroOperationDocuments.PollSchemaValidationOperationName, + string requestId) + => CreateRequest( + connection, + NitroOperationDocuments.WatchSchemaValidationOperationName, #if NITRO_PERSISTED_OPERATIONS - NitroOperationDocuments.GetPollSchemaValidationOperationId(), + NitroOperationDocuments.GetWatchSchemaValidationOperationId(), #else - NitroOperationDocuments.GetPollSchemaValidationDocument(), + NitroOperationDocuments.GetWatchSchemaValidationDocument(), #endif - variables, - enableFileUploads: false), - retryTransientFailures: true, - cancellationToken); - - if (result.Failure is not null) - { - return (default, result.Failure); - } + new Dictionary { ["requestId"] = requestId }, + enableFileUploads: false); + private static NitroSchemaValidationReport? ReadValidationResult( + OperationResult result, + string schemaHash, + string requestId) + { if (result.Data.ValueKind is not JsonValueKind.Object - || !result.Data.TryGetProperty("pollSchemaVersionValidationRequest", out var payload) + || !result.Data.TryGetProperty("onSchemaVersionValidationUpdate", out var payload) || payload.ValueKind is not JsonValueKind.Object) { - return (default, "Nitro returned a malformed validation polling response."); - } - - var error = ReadFirstError(payload); - if (error is not null) - { - return (default, error); + return Unavailable( + "Nitro returned no schema validation result.", + schemaHash, + requestId); } - return payload.TryGetProperty("result", out var validationResult) - ? (validationResult.Clone(), null) - : (default, "Nitro returned no schema validation result."); - } - - private async Task SendWithRetryAsync( - GraphQLHttpRequest request, - bool retryTransientFailures, - CancellationToken cancellationToken) - { - for (var attempt = 1; ; attempt++) + var typeName = GetString(payload, "__typename"); + switch (typeName) { - try - { - using var requestTimeout = - CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - requestTimeout.CancelAfter(s_requestTimeout); - - using var response = await client.SendAsync(request, requestTimeout.Token); - if (!response.IsSuccessStatusCode) - { - if (retryTransientFailures - && IsTransient(response.StatusCode) - && attempt < MaxAttempts) - { - await DelayRetryAsync(attempt, cancellationToken); - continue; - } - - return NitroGraphQLResult.Failed( - $"Nitro returned HTTP {(int)response.StatusCode} ({response.StatusCode})."); - } + case "OperationInProgress": + case "ValidationInProgress": + return null; - if (response.ContentHeaders.ContentLength is > MaxResponseBytes) - { - return NitroGraphQLResult.Failed( - "Nitro returned a schema validation response that exceeded the size limit."); - } - - using var document = await ReadBoundedJsonAsync( - response.HttpResponseMessage, - requestTimeout.Token); - var root = document.RootElement; - if (root.TryGetProperty("errors", out var errors) - && errors.ValueKind is JsonValueKind.Array - && errors.GetArrayLength() > 0) - { - return NitroGraphQLResult.Failed("Nitro returned GraphQL errors."); - } - - if (!root.TryGetProperty("data", out var data)) - { - return NitroGraphQLResult.Failed("Nitro returned a malformed GraphQL response."); - } - - return NitroGraphQLResult.Success(data.Clone()); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch (OperationCanceledException) when ( - retryTransientFailures && attempt < MaxAttempts) - { - await DelayRetryAsync(attempt, cancellationToken); - } - catch (HttpRequestException) when ( - retryTransientFailures && attempt < MaxAttempts) - { - await DelayRetryAsync(attempt, cancellationToken); - } - catch (IOException) when ( - retryTransientFailures && attempt < MaxAttempts) - { - await DelayRetryAsync(attempt, cancellationToken); - } - } - } - - private static async Task ReadBoundedJsonAsync( - HttpResponseMessage response, - CancellationToken cancellationToken) - { - await using var source = await response.Content.ReadAsStreamAsync(cancellationToken); - await using var destination = new MemoryStream(); - var buffer = new byte[16 * 1024]; - - while (true) - { - var read = await source.ReadAsync(buffer, cancellationToken); - if (read == 0) - { - break; - } + case "SchemaVersionValidationSuccess": + return NitroSchemaValidationReport.Passed( + schemaHash, + requestId, + DateTimeOffset.UtcNow); - if (destination.Length + read > MaxResponseBytes) - { - throw new NitroResponseTooLargeException( - "Nitro returned a schema validation response that exceeded the size limit."); - } + case "SchemaVersionValidationFailed": + return ParseViolations(payload, schemaHash, requestId); - destination.Write(buffer, 0, read); + default: + return Unavailable( + $"Nitro returned the unknown validation result '{typeName ?? "null"}'.", + schemaHash, + requestId); } - - destination.Position = 0; - return await JsonDocument.ParseAsync(destination, default, cancellationToken); } private static GraphQLHttpRequest CreateRequest( @@ -361,7 +221,7 @@ private static GraphQLHttpRequest CreateRequest( }; } - private NitroSchemaValidationReport ParseViolations( + private static NitroSchemaValidationReport ParseViolations( JsonElement result, string schemaHash, string requestId) @@ -378,7 +238,7 @@ private NitroSchemaValidationReport ParseViolations( return NitroSchemaValidationReport.Unavailable( schemaHash, "Nitro returned a malformed validation failure.", - timeProvider.GetUtcNow(), + DateTimeOffset.UtcNow, requestId); } @@ -389,7 +249,7 @@ private NitroSchemaValidationReport ParseViolations( return NitroSchemaValidationReport.Unavailable( schemaHash, "Nitro returned too many schema validation findings.", - timeProvider.GetUtcNow(), + DateTimeOffset.UtcNow, requestId); } @@ -448,7 +308,7 @@ finding with return NitroSchemaValidationReport.Unavailable( schemaHash, "Nitro returned too many schema validation findings.", - timeProvider.GetUtcNow(), + DateTimeOffset.UtcNow, requestId); } } @@ -461,7 +321,7 @@ finding with return NitroSchemaValidationReport.Unavailable( schemaHash, unknownFailure, - timeProvider.GetUtcNow(), + DateTimeOffset.UtcNow, requestId); } @@ -470,7 +330,7 @@ finding with return NitroSchemaValidationReport.Unavailable( schemaHash, "Nitro returned a validation failure without findings.", - timeProvider.GetUtcNow(), + DateTimeOffset.UtcNow, requestId); } @@ -481,7 +341,7 @@ finding with clients, findings, null, - timeProvider.GetUtcNow()) + DateTimeOffset.UtcNow) { HasMoreClientErrors = hasMoreClientErrors }; @@ -907,45 +767,13 @@ private static string GroupFor(string typeName) ? property.GetBoolean() : null; - private async Task DelayRetryAsync(int attempt, CancellationToken cancellationToken) - => await Task.Delay( - TimeSpan.FromMilliseconds(250 * (1 << (attempt - 1))), - timeProvider, - cancellationToken); - - private NitroSchemaValidationReport Unavailable( + private static NitroSchemaValidationReport Unavailable( string reason, string schemaHash, string? requestId = null) => NitroSchemaValidationReport.Unavailable( schemaHash, reason, - timeProvider.GetUtcNow(), + DateTimeOffset.UtcNow, requestId); - - private static bool IsTransient(HttpStatusCode statusCode) - => statusCode is HttpStatusCode.RequestTimeout - or HttpStatusCode.TooManyRequests - || statusCode >= HttpStatusCode.InternalServerError; - - private sealed class NitroGraphQLResult - { - private NitroGraphQLResult(JsonElement data, string? failure) - { - Data = data; - Failure = failure; - } - - public JsonElement Data { get; } - - public string? Failure { get; } - - public static NitroGraphQLResult Success(JsonElement data) - => new(data, null); - - public static NitroGraphQLResult Failed(string failure) - => new(default, failure); - } - - private sealed class NitroResponseTooLargeException(string message) : Exception(message); } diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/NitroSeedCoordinator.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/NitroSeedCoordinator.cs index 588a54cc18a..5ec16fb6d07 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/NitroSeedCoordinator.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/NitroSeedCoordinator.cs @@ -103,6 +103,9 @@ public static NitroSeedCoordinator CreateProduction( var timeProvider = TimeProvider.System; var httpClient = new HttpClient(); + // Schema validation waits on a server-sent event stream that outlives any fixed request + // timeout, so the client that carries it is bounded by the validator instead. + var streamingHttpClient = new HttpClient { Timeout = Timeout.InfiniteTimeSpan }; var connectionResolver = new NitroConnectionResolver( new NitroSessionReader( NitroDefaults.GetSessionFilePath(), @@ -120,8 +123,7 @@ public static NitroSeedCoordinator CreateProduction( new NitroApiLookupClient( GraphQLHttpClient.Create(httpClient, disposeHttpClient: false))); var schemaValidator = new NitroSchemaValidator( - GraphQLHttpClient.Create(httpClient, disposeHttpClient: false), - timeProvider, + GraphQLHttpClient.Create(streamingHttpClient, disposeHttpClient: false), NullLogger.Instance); var stageUpdateClient = new NitroStageUpdateClient( GraphQLHttpClient.Create(httpClient, disposeHttpClient: false)); diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/BeginFusionDeployment.graphql b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/BeginFusionDeployment.graphql new file mode 100644 index 00000000000..217aee9257f --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/BeginFusionDeployment.graphql @@ -0,0 +1,11 @@ +mutation BeginFusionDeployment($input: BeginFusionConfigurationPublishInput!) { + beginFusionConfigurationPublish(input: $input) { + requestId + errors { + __typename + ... on Error { + message + } + } + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/BeginFusionDeployment.graphql.sha256 b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/BeginFusionDeployment.graphql.sha256 new file mode 100644 index 00000000000..83d9bf5b910 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/BeginFusionDeployment.graphql.sha256 @@ -0,0 +1 @@ +ceab775204b86a28d147d5799f39b5b05cd2e7abb93d337df315432f5b8a32ac diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/ClaimFusionDeployment.graphql b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/ClaimFusionDeployment.graphql new file mode 100644 index 00000000000..926d4b56ed9 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/ClaimFusionDeployment.graphql @@ -0,0 +1,10 @@ +mutation ClaimFusionDeployment($input: StartFusionConfigurationCompositionInput!) { + startFusionConfigurationComposition(input: $input) { + errors { + __typename + ... on Error { + message + } + } + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/ClaimFusionDeployment.graphql.sha256 b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/ClaimFusionDeployment.graphql.sha256 new file mode 100644 index 00000000000..564d57796b9 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/ClaimFusionDeployment.graphql.sha256 @@ -0,0 +1 @@ +1efc9d275a44832a90a1a5227e280de66582e21e4e83bc89ae08fe8f0b3c69e1 diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/CommitFusionDeployment.graphql b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/CommitFusionDeployment.graphql new file mode 100644 index 00000000000..c69673ad57e --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/CommitFusionDeployment.graphql @@ -0,0 +1,10 @@ +mutation CommitFusionDeployment($input: CommitFusionConfigurationPublishInput!) { + commitFusionConfigurationPublish(input: $input) { + errors { + __typename + ... on Error { + message + } + } + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/CommitFusionDeployment.graphql.sha256 b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/CommitFusionDeployment.graphql.sha256 new file mode 100644 index 00000000000..4a18e695530 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/CommitFusionDeployment.graphql.sha256 @@ -0,0 +1 @@ +74a2f5e237fb7213158efed61f9b3e48d420bb3fe45b2f0bce82ecff64319edd diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/PollNitroSchemaValidation.graphql b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/PollNitroSchemaValidation.graphql deleted file mode 100644 index 044f8cd2c26..00000000000 --- a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/PollNitroSchemaValidation.graphql +++ /dev/null @@ -1,302 +0,0 @@ -mutation PollNitroSchemaValidation($input: PollSchemaVersionValidationRequestInput!) { - pollSchemaVersionValidationRequest(input: $input) { - errors { - __typename - ... on Error { - message - } - } - result { - __typename - state - ... on SchemaVersionValidationFailed { - errors { - __typename - message - ... on PersistedQueryValidationError { - clientId - hasMoreErrors - client { - id - name - } - queries { - deployedTags - hash - message - errors { - message - code - path - locations { - line - column - } - } - } - } - ... on SchemaVersionChangeViolationError { - changes { - ...SchemaChangeSummary - ... on ObjectModifiedChange { - changes { - ...SchemaChangeDetail - } - } - ... on InputObjectModifiedChange { - changes { - ...SchemaChangeDetail - } - } - ... on DirectiveModifiedChange { - changes { - ...SchemaChangeDetail - } - } - ... on ScalarModifiedChange { - changes { - ...SchemaChangeDetail - } - } - ... on EnumModifiedChange { - changes { - ...SchemaChangeDetail - } - } - ... on UnionModifiedChange { - changes { - ...SchemaChangeDetail - } - } - ... on InterfaceModifiedChange { - changes { - ...SchemaChangeDetail - } - } - } - } - ... on InvalidGraphQLSchemaError { - errors { - message - code - } - } - ... on SchemaVersionSyntaxError { - line - column - position - } - ... on OperationsAreNotAllowedError { - operationName - } - ... on OpenApiCollectionValidationError { - collections { - openApiCollection { - id - name - } - entities { - __typename - ... on OpenApiCollectionValidationEndpoint { - httpMethod - route - } - ... on OpenApiCollectionValidationModel { - name - } - errors { - __typename - message - ... on OpenApiCollectionValidationDocumentError { - code - path - locations { - line - column - } - } - } - } - } - } - ... on McpFeatureCollectionValidationError { - collections { - mcpFeatureCollection { - id - name - } - entities { - __typename - ... on McpFeatureCollectionValidationPrompt { - name - } - ... on McpFeatureCollectionValidationTool { - name - } - errors { - __typename - message - ... on McpFeatureCollectionValidationDocumentError { - code - path - locations { - line - column - } - } - } - } - } - } - } - } - } - } -} - -fragment SchemaChangeSummary on SchemaChangeLogEntry { - __typename - ... on SchemaChange { - severity - } - ... on TypeSystemMemberAddedChange { - coordinate - } - ... on TypeSystemMemberModifiedChange { - coordinate - } - ... on TypeSystemMemberRemovedChange { - coordinate - } - ... on ObjectModifiedChange { - coordinate - } - ... on InputObjectModifiedChange { - coordinate - } - ... on DirectiveModifiedChange { - coordinate - } - ... on ScalarModifiedChange { - coordinate - } - ... on EnumModifiedChange { - coordinate - } - ... on UnionModifiedChange { - coordinate - } - ... on InterfaceModifiedChange { - coordinate - } -} - -fragment SchemaChangeDetail on SchemaChange { - ...SchemaChangeLeaf - ... on InputFieldChanged { - changes { - ...SchemaChangeLeaf - } - } - ... on OutputFieldChanged { - changes { - ...SchemaChangeLeaf - ... on ArgumentChanged { - changes { - ...SchemaChangeLeaf - } - } - } - } - ... on ArgumentChanged { - changes { - ...SchemaChangeLeaf - } - } - ... on EnumValueChanged { - changes { - ...SchemaChangeLeaf - } - } -} - -fragment SchemaChangeLeaf on SchemaChange { - __typename - severity - ... on InterfaceImplementationAdded { - interfaceName - } - ... on InterfaceImplementationRemoved { - interfaceName - } - ... on DescriptionChanged { - old - new - } - ... on FieldAddedChange { - coordinate - typeName - fieldName - } - ... on FieldRemovedChange { - coordinate - typeName - fieldName - } - ... on InputFieldChanged { - coordinate - fieldName - } - ... on OutputFieldChanged { - coordinate - fieldName - } - ... on PossibleTypeAdded { - typeName - } - ... on PossibleTypeRemoved { - typeName - } - ... on UnionMemberAdded { - typeName - } - ... on UnionMemberRemoved { - typeName - } - ... on DirectiveLocationAdded { - location - } - ... on DirectiveLocationRemoved { - location - } - ... on DeprecatedChange { - deprecationReason - } - ... on ArgumentAdded { - coordinate - name - typeName - } - ... on ArgumentRemoved { - coordinate - name - typeName - } - ... on ArgumentChanged { - coordinate - name - } - ... on EnumValueAdded { - coordinate - } - ... on EnumValueRemoved { - coordinate - } - ... on EnumValueChanged { - coordinate - } - ... on TypeChanged { - oldType - newType - } -} diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/PollNitroSchemaValidation.graphql.sha256 b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/PollNitroSchemaValidation.graphql.sha256 deleted file mode 100644 index 2db8a929079..00000000000 --- a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/PollNitroSchemaValidation.graphql.sha256 +++ /dev/null @@ -1 +0,0 @@ -9c6725ca85cf073343e4136f6a9b53353328156eb4335363fe624bfefc86bdd3 diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/ReleaseFusionDeployment.graphql b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/ReleaseFusionDeployment.graphql new file mode 100644 index 00000000000..1f162172fbe --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/ReleaseFusionDeployment.graphql @@ -0,0 +1,10 @@ +mutation ReleaseFusionDeployment($input: CancelFusionConfigurationCompositionInput!) { + cancelFusionConfigurationComposition(input: $input) { + errors { + __typename + ... on Error { + message + } + } + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/ReleaseFusionDeployment.graphql.sha256 b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/ReleaseFusionDeployment.graphql.sha256 new file mode 100644 index 00000000000..8afd1d46236 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/ReleaseFusionDeployment.graphql.sha256 @@ -0,0 +1 @@ +0ce52927ce212dfd36404a8a74db96c06be1e791845c526d8c0b0a8ded1a4a61 diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/UploadFusionSourceSchema.graphql b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/UploadFusionSourceSchema.graphql new file mode 100644 index 00000000000..d3ce59f5998 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/UploadFusionSourceSchema.graphql @@ -0,0 +1,13 @@ +mutation UploadFusionSourceSchema($input: UploadFusionSubgraphInput!) { + uploadFusionSubgraph(input: $input) { + fusionSubgraphVersion { + id + } + errors { + __typename + ... on Error { + message + } + } + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/UploadFusionSourceSchema.graphql.sha256 b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/UploadFusionSourceSchema.graphql.sha256 new file mode 100644 index 00000000000..1ef3321891a --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/UploadFusionSourceSchema.graphql.sha256 @@ -0,0 +1 @@ +1114c593d527acede0596dc456f59242f899101ed4c089314adaa60cdddcff79 diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/ValidateFusionDeployment.graphql b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/ValidateFusionDeployment.graphql new file mode 100644 index 00000000000..89f5335de4a --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/ValidateFusionDeployment.graphql @@ -0,0 +1,10 @@ +mutation ValidateFusionDeployment($input: ValidateFusionConfigurationCompositionInput!) { + validateFusionConfigurationComposition(input: $input) { + errors { + __typename + ... on Error { + message + } + } + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/ValidateFusionDeployment.graphql.sha256 b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/ValidateFusionDeployment.graphql.sha256 new file mode 100644 index 00000000000..07dc162d26c --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/ValidateFusionDeployment.graphql.sha256 @@ -0,0 +1 @@ +e5d213965c3e14e470ba31e9c238147ebd35fcec42b636960a7c2dcc535e1325 diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/WatchFusionDeployment.graphql b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/WatchFusionDeployment.graphql new file mode 100644 index 00000000000..f184504cfa2 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/WatchFusionDeployment.graphql @@ -0,0 +1,19 @@ +subscription WatchFusionDeployment($requestId: ID!) { + onFusionConfigurationPublishingTaskChanged(requestId: $requestId) { + __typename + state + ... on ProcessingTaskIsQueued { + queuePosition + } + ... on FusionConfigurationPublishingFailed { + errors { + message + } + } + ... on FusionConfigurationValidationFailed { + errors { + message + } + } + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/WatchFusionDeployment.graphql.sha256 b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/WatchFusionDeployment.graphql.sha256 new file mode 100644 index 00000000000..f5be26bac50 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/WatchFusionDeployment.graphql.sha256 @@ -0,0 +1 @@ +25fff4ef7a751b2d9b1008f353dc23c89b8aed8978334407d3aa9a83806839b1 diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/WatchNitroSchemaValidation.graphql b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/WatchNitroSchemaValidation.graphql new file mode 100644 index 00000000000..abc2c69099b --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/WatchNitroSchemaValidation.graphql @@ -0,0 +1,294 @@ +subscription WatchNitroSchemaValidation($requestId: ID!) { + onSchemaVersionValidationUpdate(requestId: $requestId) { + __typename + state + ... on SchemaVersionValidationFailed { + errors { + __typename + message + ... on PersistedQueryValidationError { + clientId + hasMoreErrors + client { + id + name + } + queries { + deployedTags + hash + message + errors { + message + code + path + locations { + line + column + } + } + } + } + ... on SchemaVersionChangeViolationError { + changes { + ...SchemaChangeSummary + ... on ObjectModifiedChange { + changes { + ...SchemaChangeDetail + } + } + ... on InputObjectModifiedChange { + changes { + ...SchemaChangeDetail + } + } + ... on DirectiveModifiedChange { + changes { + ...SchemaChangeDetail + } + } + ... on ScalarModifiedChange { + changes { + ...SchemaChangeDetail + } + } + ... on EnumModifiedChange { + changes { + ...SchemaChangeDetail + } + } + ... on UnionModifiedChange { + changes { + ...SchemaChangeDetail + } + } + ... on InterfaceModifiedChange { + changes { + ...SchemaChangeDetail + } + } + } + } + ... on InvalidGraphQLSchemaError { + errors { + message + code + } + } + ... on SchemaVersionSyntaxError { + line + column + position + } + ... on OperationsAreNotAllowedError { + operationName + } + ... on OpenApiCollectionValidationError { + collections { + openApiCollection { + id + name + } + entities { + __typename + ... on OpenApiCollectionValidationEndpoint { + httpMethod + route + } + ... on OpenApiCollectionValidationModel { + name + } + errors { + __typename + message + ... on OpenApiCollectionValidationDocumentError { + code + path + locations { + line + column + } + } + } + } + } + } + ... on McpFeatureCollectionValidationError { + collections { + mcpFeatureCollection { + id + name + } + entities { + __typename + ... on McpFeatureCollectionValidationPrompt { + name + } + ... on McpFeatureCollectionValidationTool { + name + } + errors { + __typename + message + ... on McpFeatureCollectionValidationDocumentError { + code + path + locations { + line + column + } + } + } + } + } + } + } + } + } +} + +fragment SchemaChangeSummary on SchemaChangeLogEntry { + __typename + ... on SchemaChange { + severity + } + ... on TypeSystemMemberAddedChange { + coordinate + } + ... on TypeSystemMemberModifiedChange { + coordinate + } + ... on TypeSystemMemberRemovedChange { + coordinate + } + ... on ObjectModifiedChange { + coordinate + } + ... on InputObjectModifiedChange { + coordinate + } + ... on DirectiveModifiedChange { + coordinate + } + ... on ScalarModifiedChange { + coordinate + } + ... on EnumModifiedChange { + coordinate + } + ... on UnionModifiedChange { + coordinate + } + ... on InterfaceModifiedChange { + coordinate + } +} + +fragment SchemaChangeDetail on SchemaChange { + ...SchemaChangeLeaf + ... on InputFieldChanged { + changes { + ...SchemaChangeLeaf + } + } + ... on OutputFieldChanged { + changes { + ...SchemaChangeLeaf + ... on ArgumentChanged { + changes { + ...SchemaChangeLeaf + } + } + } + } + ... on ArgumentChanged { + changes { + ...SchemaChangeLeaf + } + } + ... on EnumValueChanged { + changes { + ...SchemaChangeLeaf + } + } +} + +fragment SchemaChangeLeaf on SchemaChange { + __typename + severity + ... on InterfaceImplementationAdded { + interfaceName + } + ... on InterfaceImplementationRemoved { + interfaceName + } + ... on DescriptionChanged { + old + new + } + ... on FieldAddedChange { + coordinate + typeName + fieldName + } + ... on FieldRemovedChange { + coordinate + typeName + fieldName + } + ... on InputFieldChanged { + coordinate + fieldName + } + ... on OutputFieldChanged { + coordinate + fieldName + } + ... on PossibleTypeAdded { + typeName + } + ... on PossibleTypeRemoved { + typeName + } + ... on UnionMemberAdded { + typeName + } + ... on UnionMemberRemoved { + typeName + } + ... on DirectiveLocationAdded { + location + } + ... on DirectiveLocationRemoved { + location + } + ... on DeprecatedChange { + deprecationReason + } + ... on ArgumentAdded { + coordinate + name + typeName + } + ... on ArgumentRemoved { + coordinate + name + typeName + } + ... on ArgumentChanged { + coordinate + name + } + ... on EnumValueAdded { + coordinate + } + ... on EnumValueRemoved { + coordinate + } + ... on EnumValueChanged { + coordinate + } + ... on TypeChanged { + oldType + newType + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/WatchNitroSchemaValidation.graphql.sha256 b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/WatchNitroSchemaValidation.graphql.sha256 new file mode 100644 index 00000000000..db03f861924 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/Nitro/Operations/WatchNitroSchemaValidation.graphql.sha256 @@ -0,0 +1 @@ +22190a0146ff00a9719826ea025019ea279b61a1be0d88b0b21015acd1f88dc4 diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/NitroExtensions.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/NitroExtensions.cs index 98a3027f49c..89c66879094 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Aspire/NitroExtensions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/NitroExtensions.cs @@ -2,6 +2,7 @@ using Aspire.Hosting.ApplicationModel; using HotChocolate.Fusion.Aspire.Nitro; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace HotChocolate.Fusion.Aspire; @@ -175,11 +176,11 @@ private static IDistributedApplicationBuilder AddNitroCore( /// The resource builder for chaining. /// /// - /// The api id only takes effect on a resource whose schema is composed and only when the - /// distributed application calls - /// . - /// On any other resource it is metadata - /// without an effect. + /// On a gateway the api id selects the fusion configuration that the gateway composes on top + /// of, and it only takes effect when the distributed application calls + /// . On a publish target + /// added with the api id selects the api that Fusion + /// deployments publish to. On any other resource it is metadata without an effect. /// public static IResourceBuilder WithNitroApiId( this IResourceBuilder builder, @@ -197,6 +198,343 @@ public static IResourceBuilder WithNitroApiId( return builder; } + /// + /// Adds the Nitro api that the Fusion deployments of the distributed application publish to. + /// The cloud URL and the api id default to the Nitro:CloudUrl and Nitro:ApiId + /// configuration values, or to the NITRO_CLOUD_URL and NITRO_API_ID environment + /// variables. + /// + /// + /// The distributed application builder. + /// + /// + /// The name of the publish target resource. + /// + /// + /// The resource builder of the publish target for chaining. + /// + public static IResourceBuilder AddNitroPublishTarget( + this IDistributedApplicationBuilder builder, + string name) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + EnsureFusionPipeline(builder); + builder.Services.TryAddSingleton(_ => NitroFusionApi.Create()); + builder.Services.TryAddSingleton(); + + var configuredCloudUrl = + builder.Configuration["Nitro:CloudUrl"] + ?? builder.Configuration["NITRO_CLOUD_URL"]; + var resource = new NitroPublishTargetResource(name) + { + CloudUrl = string.IsNullOrWhiteSpace(configuredCloudUrl) + ? null + : NormalizeCloudUrl(configuredCloudUrl) + }; + var resourceBuilder = builder.AddResource(resource); + + var configuredApiId = + builder.Configuration["Nitro:ApiId"] + ?? builder.Configuration["NITRO_API_ID"]; + + if (!string.IsNullOrWhiteSpace(configuredApiId)) + { + resourceBuilder.WithNitroApiId(configuredApiId); + } + + return resourceBuilder; + } + + /// + /// Sets the Nitro api URL that Fusion deployments publish to. + /// + /// + /// The resource builder of a Nitro publish target. + /// + /// + /// An absolute HTTPS origin without a path, query, fragment, or user information. + /// + /// + /// The resource builder for chaining. + /// + public static IResourceBuilder WithNitroCloudUrl( + this IResourceBuilder builder, + string cloudUrl) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Resource.CloudUrl = NormalizeCloudUrl(cloudUrl); + return builder; + } + + /// + /// Sets the secret parameter that supplies the Nitro api key. When this is not configured, the + /// credential is resolved from the NITRO_API_KEY environment variable or the Nitro CLI + /// session. + /// + /// + /// The resource builder of a Nitro publish target. + /// + /// + /// The parameter that supplies the api key. It has to be declared as a secret. + /// + /// + /// The resource builder for chaining. + /// + /// + /// The parameter is not declared as a secret. + /// + public static IResourceBuilder WithNitroApiKey( + this IResourceBuilder builder, + IResourceBuilder apiKey) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(apiKey); + + if (!apiKey.Resource.Secret) + { + throw new ArgumentException( + "The Nitro API key parameter must be declared as a secret.", + nameof(apiKey)); + } + + builder.Resource.ApiKey = apiKey.Resource; + return builder; + } + + /// + /// Declares a Nitro stage that this api publishes to. Every stage an api publishes to is + /// declared once, and each invocation selects one of them by name. + /// + /// + /// The resource builder of a Nitro publish target. + /// + /// + /// The name of the Nitro stage. + /// + /// + /// The resource builder of the stage for chaining. + /// + public static IResourceBuilder AddStage( + this IResourceBuilder builder, + string stageName) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(stageName); + + var resource = new FusionStageResource( + $"{builder.Resource.Name}-{stageName}", + stageName, + builder.Resource); + return builder.ApplicationBuilder.AddResource(resource); + } + + /// + /// Sets the parameter that names the declared stage an invocation publishes to. + /// + /// + /// The resource builder of a Nitro publish target. + /// + /// + /// The parameter that supplies the name of the stage. + /// + /// + /// The resource builder for chaining. + /// + /// + /// The publication fails when the parameter names a stage that the api does not declare. + /// + public static IResourceBuilder WithStageParameter( + this IResourceBuilder builder, + IResourceBuilder stage) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(stage); + + builder.Resource.StageParameter = stage.Resource; + return builder; + } + + /// + /// Selects the exact schema-settings.json environment used for composition. + /// + /// + /// The resource builder of a Fusion stage. + /// + /// + /// The name of the settings environment. + /// + /// + /// The resource builder for chaining. + /// + /// + /// When this is not configured, an environment selected by the GraphQL composition is used, + /// followed by the Nitro stage name. + /// + public static IResourceBuilder WithCompositionEnvironment( + this IResourceBuilder builder, + string environmentName) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(environmentName); + + builder.Resource.CompositionEnvironmentName = environmentName; + return builder; + } + + /// + /// Sets the immutable release tag. The tag is both the source schema version and the fusion + /// configuration tag of the deployment. + /// + /// + /// The resource builder of a Nitro publish target. + /// + /// + /// The parameter that supplies the release tag. + /// + /// + /// The resource builder for chaining. + /// + public static IResourceBuilder WithConfigurationTag( + this IResourceBuilder builder, + IResourceBuilder configurationTag) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configurationTag); + + builder.Resource.ConfigurationTagParameter = configurationTag.Resource; + builder.Resource.ConfigurationTag = null; + return builder; + } + + /// + /// Sets the immutable release tag. The tag is both the source schema version and the fusion + /// configuration tag of the deployment. + /// + /// + /// The resource builder of a Nitro publish target. + /// + /// + /// The release tag. + /// + /// + /// The resource builder for chaining. + /// + public static IResourceBuilder WithConfigurationTag( + this IResourceBuilder builder, + string configurationTag) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(configurationTag); + + builder.Resource.ConfigurationTag = configurationTag; + builder.Resource.ConfigurationTagParameter = null; + return builder; + } + + /// + /// Configures whether Nitro waits for approval before the configuration is committed. + /// + /// + /// The resource builder of a Fusion stage. + /// + /// + /// Specifies whether the publication waits for approval. + /// + /// + /// The resource builder for chaining. + /// + public static IResourceBuilder WithApproval( + this IResourceBuilder builder, + bool waitForApproval) + { + ArgumentNullException.ThrowIfNull(builder); + builder.Resource.WaitForApproval = waitForApproval; + return builder; + } + + /// + /// Configures whether validation failures may be forced. + /// + /// + /// The resource builder of a Fusion stage. + /// + /// + /// Specifies whether the publication proceeds despite validation failures. + /// + /// + /// The resource builder for chaining. + /// + public static IResourceBuilder WithForce( + this IResourceBuilder builder, + bool force) + { + ArgumentNullException.ThrowIfNull(builder); + builder.Resource.Force = force; + return builder; + } + + /// + /// Configures the operation and approval timeouts of the deployment. + /// + /// + /// The resource builder of a Fusion stage. + /// + /// + /// The time a single remote operation may take. + /// + /// + /// The time the publication waits for approval. + /// + /// + /// The resource builder for chaining. + /// + public static IResourceBuilder WithTimeouts( + this IResourceBuilder builder, + TimeSpan operation, + TimeSpan approval) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(operation, TimeSpan.Zero); + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(approval, TimeSpan.Zero); + + builder.Resource.OperationTimeout = operation; + builder.Resource.ApprovalTimeout = approval; + return builder; + } + + private static void EnsureFusionPipeline(IDistributedApplicationBuilder builder) + { + if (builder.Resources.OfType().Any()) + { + return; + } + + var pipeline = builder.AddResource( + new FusionPipelineResource("fusion-nitro-pipeline")); + FusionPipeline.Configure(pipeline); + } + + private static string NormalizeCloudUrl(string cloudUrl) + { + if (!Uri.TryCreate(cloudUrl, UriKind.Absolute, out var uri) + || uri.Scheme is not "https" + || !string.IsNullOrEmpty(uri.UserInfo) + || uri.AbsolutePath is not "/" + || !string.IsNullOrEmpty(uri.Query) + || !string.IsNullOrEmpty(uri.Fragment)) + { + throw new ArgumentException( + "The Nitro cloud URL must be an absolute HTTPS origin without " + + "a path, query, fragment, or user information.", + nameof(cloudUrl)); + } + + return uri.GetLeftPart(UriPartial.Authority); + } + internal static string? GetNitroApiId(this IResource resource) => resource.Annotations.OfType().SingleOrDefault()?.ApiId; diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/NitroPublishTargetResource.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/NitroPublishTargetResource.cs new file mode 100644 index 00000000000..35e578abd02 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/NitroPublishTargetResource.cs @@ -0,0 +1,24 @@ +using Aspire.Hosting.ApplicationModel; + +namespace HotChocolate.Fusion.Aspire; + +/// +/// Represents the Nitro api that the Fusion deployments of a distributed application publish to. +/// +public sealed class NitroPublishTargetResource(string name) : Resource(name) +{ + internal string? CloudUrl { get; set; } + + internal string? ApiId => this.GetNitroApiId(); + + internal ParameterResource? ApiKey { get; set; } + + /// + /// The parameter that names the declared stage an invocation publishes to. + /// + internal ParameterResource? StageParameter { get; set; } + + internal string? ConfigurationTag { get; set; } + + internal ParameterResource? ConfigurationTagParameter { get; set; } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/SchemaComposition.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/SchemaComposition.cs index acd52f8d33c..2f1bad32184 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Aspire/SchemaComposition.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/SchemaComposition.cs @@ -985,8 +985,8 @@ internal async Task> DiscoverReferencedSourceSchemasAsync { var sourceSchemas = new List(); - // Get all resources referenced by the composition resource - var referencedResources = GetReferencedResources(compositionResource, appModel); + var referencedResources = + GraphQLResourceModel.GetReferencedSourceSchemas(compositionResource, appModel); logger.LogInformation( "Found {Count} referenced resources for {ResourceName}", @@ -994,17 +994,12 @@ internal async Task> DiscoverReferencedSourceSchemasAsync try { - foreach (var referencedResource in referencedResources) + foreach (var referencedSource in referencedResources) { - if (!referencedResource.HasGraphQLSchema()) - { - logger.LogDebug( - "Resource {ResourceName} does not have a GraphQL schema, skipping", - referencedResource.Name); - continue; - } - - var schemaInfo = await GetSourceSchemaInfoAsync(referencedResource, cancellationToken); + var schemaInfo = await GetSourceSchemaInfoAsync( + referencedSource.Resource, + referencedSource.Declaration, + cancellationToken); if (schemaInfo is null) { // Composing without this source would either drop it from the composed @@ -1013,7 +1008,8 @@ internal async Task> DiscoverReferencedSourceSchemasAsync // the gateway as failed to start, on a recomposition the gateway keeps // the previous schema. throw new InvalidOperationException( - $"The source schema for resource '{referencedResource.Name}' could not be loaded."); + $"The source schema for resource '{referencedSource.Resource.Name}' " + + "could not be loaded."); } sourceSchemas.Add(schemaInfo); @@ -1074,14 +1070,9 @@ private static List GetReferencedResources( private async Task GetSourceSchemaInfoAsync( IResourceWithEndpoints resource, + GraphQLSourceSchemaAnnotation sourceSchemaSettings, CancellationToken cancellationToken) { - var sourceSchemaSettings = resource.Annotations.OfType().FirstOrDefault(); - if (sourceSchemaSettings is null) - { - return null; - } - switch (sourceSchemaSettings.Location) { case SourceSchemaLocationType.SchemaEndpoint: @@ -1090,6 +1081,12 @@ private static List GetReferencedResources( case SourceSchemaLocationType.ProjectDirectory: return await GetSourceSchemaFromFileAsync(resource, sourceSchemaSettings, cancellationToken); + case SourceSchemaLocationType.CommandLineExport: + return await GetSourceSchemaFromCommandLineAsync( + resource, + sourceSchemaSettings, + cancellationToken); + default: logger.LogWarning( "Unknown schema location type {LocationType} for {ResourceName}", @@ -1099,6 +1096,86 @@ private static List GetReferencedResources( } } + private async Task GetSourceSchemaFromCommandLineAsync( + IResourceWithEndpoints resource, + GraphQLSourceSchemaAnnotation annotation, + CancellationToken cancellationToken) + { + var outputDirectory = IOPath.Combine( + IOPath.GetTempPath(), + "hotchocolate-fusion-aspire", + Guid.NewGuid().ToString("N")); + + try + { + var result = await CommandLineSchemaExporter.ExportAsync( + resource, + annotation, + outputDirectory, + cancellationToken); + var schemaText = await File.ReadAllTextAsync( + result.SchemaPath, + cancellationToken); + var settings = JsonDocument.Parse( + await File.ReadAllTextAsync(result.SettingsPath, cancellationToken)); + var ownershipTransferred = false; + + try + { + var configuration = ReadEndpointConfiguration( + resource.Name, + annotation.SourceSchemaName, + settings); + GraphQLSourceSchemaValidator.Validate( + resource.Name, + configuration, + schemaText); + + var sourceSchema = new SourceSchemaInfo + { + Name = configuration.SourceSchemaName, + ResourceName = resource.Name, + Schema = new SourceSchemaText(configuration.SourceSchemaName, schemaText), + SchemaSettings = settings + }; + + ownershipTransferred = true; + return sourceSchema; + } + finally + { + if (!ownershipTransferred) + { + settings.Dispose(); + } + } + } + finally + { + try + { + if (Directory.Exists(outputDirectory)) + { + Directory.Delete(outputDirectory, recursive: true); + } + } + catch (IOException exception) + { + logger.LogWarning( + exception, + "Could not remove schema export directory for {ResourceName}.", + resource.Name); + } + catch (UnauthorizedAccessException exception) + { + logger.LogWarning( + exception, + "Could not remove schema export directory for {ResourceName}.", + resource.Name); + } + } + } + private async Task GetSourceSchemaFromEndpointAsync( IResourceWithEndpoints resource, GraphQLSourceSchemaAnnotation annotation, @@ -1141,6 +1218,11 @@ private static List GetReferencedResources( return null; } + GraphQLSourceSchemaValidator.Validate( + resource.Name, + endpointConfiguration, + schemaText); + var sourceSchema = new SourceSchemaInfo { Name = endpointConfiguration.SourceSchemaName, @@ -1219,9 +1301,12 @@ internal static SchemaEndpointConfiguration ReadEndpointConfiguration( GraphQLSourceSchemaAnnotation annotation, CancellationToken cancellationToken) { - var sourceSchemaName = resource.GetGraphQLSourceSchemaName() ?? resource.Name; + var sourceSchemaName = annotation.SourceSchemaName ?? resource.Name; - var schemaPath = annotation.SchemaPath ?? "schema.graphql"; + var schemaPaths = GraphQLResourceModel.GetProjectSchemaPaths( + resource, + annotation); + var schemaPath = schemaPaths.SchemaPath; if (IsExtensionsSchemaPath(schemaPath)) { @@ -1231,31 +1316,62 @@ internal static SchemaEndpointConfiguration ReadEndpointConfiguration( return null; } - var schemaFromFile = await ReadSchemaFromProjectDirectoryAsync(resource, schemaPath, cancellationToken); + var schemaFromFile = await ReadSchemaFromProjectDirectoryAsync( + resource, + schemaPath, + cancellationToken); if (schemaFromFile is not { } schemaFiles) { return null; } - // For file schemas, settings file is named after the schema file - // e.g., "foo.graphql" -> "foo-settings.json" - var settingsFileName = $"{IOPath.GetFileNameWithoutExtension(schemaPath)}-settings.json"; - - var schemaSettings = await GetSourceSchemaSettingsAsync(resource, settingsFileName, cancellationToken); + var schemaSettings = await GetSourceSchemaSettingsAsync( + resource, + schemaPaths.SettingsPath, + cancellationToken); if (schemaSettings == null) { return null; } - return new SourceSchemaInfo + var ownershipTransferred = false; + + try { - Name = sourceSchemaName, - ResourceName = resource.Name, - HttpEndpointUrl = null, // No schema download endpoint for file-based schemas - AllocatedHttpEndpointUrl = resource.GetAllocatedHttpEndpointUrl(), - Schema = new SourceSchemaText(sourceSchemaName, schemaFiles.Schema, schemaFiles.Extensions), - SchemaSettings = schemaSettings - }; + var configuration = ReadEndpointConfiguration( + resource.Name, + sourceSchemaName, + schemaSettings); + GraphQLSourceSchemaValidator.Validate( + resource.Name, + configuration, + schemaFiles.Schema, + schemaFiles.Extensions); + + var sourceSchema = new SourceSchemaInfo + { + Name = configuration.SourceSchemaName, + ResourceName = resource.Name, + // No schema download endpoint for file-based schemas + HttpEndpointUrl = null, + AllocatedHttpEndpointUrl = resource.GetAllocatedHttpEndpointUrl(), + Schema = new SourceSchemaText( + configuration.SourceSchemaName, + schemaFiles.Schema, + schemaFiles.Extensions), + SchemaSettings = schemaSettings + }; + + ownershipTransferred = true; + return sourceSchema; + } + finally + { + if (!ownershipTransferred) + { + schemaSettings.Dispose(); + } + } } private async Task GetSourceSchemaSettingsAsync( @@ -1273,7 +1389,9 @@ internal static SchemaEndpointConfiguration ReadEndpointConfiguration( } var projectDirectory = IOPath.GetDirectoryName(projectPath); - var settingsFile = IOPath.Combine(projectDirectory!, settingsFileName); + var settingsFile = IOPath.IsPathRooted(settingsFileName) + ? settingsFileName + : IOPath.Combine(projectDirectory!, settingsFileName); if (!File.Exists(settingsFile)) { @@ -1434,7 +1552,10 @@ internal static SchemaEndpointConfiguration ReadEndpointConfiguration( } var projectDirectory = IOPath.GetDirectoryName(projectPath); - var schemaFile = IOPath.Combine(projectDirectory!, fileName ?? "schema.graphql"); + var schemaPath = fileName ?? "schema.graphqls"; + var schemaFile = IOPath.IsPathRooted(schemaPath) + ? schemaPath + : IOPath.Combine(projectDirectory!, schemaPath); if (!File.Exists(schemaFile)) { @@ -1469,42 +1590,19 @@ internal static SchemaEndpointConfiguration ReadEndpointConfiguration( } } - [SuppressMessage( - "Trimming", - "IL2075:\'this\' argument does not satisfy \'DynamicallyAccessedMembersAttribute\' " - + "in call to target method. The return value of the source method does not have matching annotations.")] private string? GetProjectPath(IResourceWithEndpoints resource) { - // Check if this is a ProjectResource - if (resource is not ProjectResource projectResource) + try { - return null; + return GraphQLResourceModel.GetProjectPath(resource); } - - // Get the project metadata from the ProjectResource - // The metadata is typically stored as an annotation or property - var metadataAnnotation = projectResource.Annotations - .FirstOrDefault(a => a.GetType().GetInterfaces().Contains(typeof(IProjectMetadata))); - - if (metadataAnnotation is IProjectMetadata projectMetadata) + catch (InvalidOperationException) { - return projectMetadata.ProjectPath; - } - - // Alternative approach: look for the metadata in the resource's type or properties - // Sometimes the metadata might be accessible through reflection on the resource itself - var metadataProperty = projectResource.GetType() - .GetProperties() - .FirstOrDefault(p => p.PropertyType.GetInterfaces().Contains(typeof(IProjectMetadata))); - - if (metadataProperty != null) - { - var metadata = metadataProperty.GetValue(projectResource) as IProjectMetadata; - return metadata?.ProjectPath; + logger.LogWarning( + "Could not find project metadata for resource {ResourceName}", + resource.Name); + return null; } - - logger.LogWarning("Could not find project metadata for resource {ResourceName}", resource.Name); - return null; } private async Task ComposeSchemaAsync( diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/SourceSchemaLocationType.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/SourceSchemaLocationType.cs index 2a2ba82cdf0..3251baff00e 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Aspire/SourceSchemaLocationType.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/SourceSchemaLocationType.cs @@ -3,5 +3,6 @@ namespace HotChocolate.Fusion.Aspire; internal enum SourceSchemaLocationType { ProjectDirectory, - SchemaEndpoint + SchemaEndpoint, + CommandLineExport } diff --git a/src/HotChocolate/Fusion/src/Fusion.Packaging.Shared/ArchiveEntryStorage.cs b/src/HotChocolate/Fusion/src/Fusion.Packaging.Shared/ArchiveEntryStorage.cs new file mode 100644 index 00000000000..49f05060317 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Packaging.Shared/ArchiveEntryStorage.cs @@ -0,0 +1,353 @@ +using System.Buffers; + +namespace HotChocolate.Fusion.Packaging.Storage; + +internal enum ArchiveEntryStorageKind +{ + Memory, + TempFile +} + +internal abstract class ArchiveEntryStorage : IDisposable +{ + public abstract long Length { get; } + + public abstract Stream OpenRead(); + + public abstract Stream OpenWrite(int maximumLength, Action? completed = null); + + public abstract Task CopyToAsync(Stream destination, CancellationToken cancellationToken); + + public abstract void Dispose(); + + public static ArchiveEntryStorage Create(ArchiveEntryStorageKind kind) + => kind switch + { + ArchiveEntryStorageKind.Memory => new PooledMemoryArchiveEntryStorage(), + ArchiveEntryStorageKind.TempFile => new TempFileArchiveEntryStorage(), + _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null) + }; +} + +internal sealed class TempFileArchiveEntryStorage : ArchiveEntryStorage +{ + private readonly string _path = Path.Combine( + Path.GetTempPath(), + $"hotchocolate-fusion-{Environment.ProcessId}-{Guid.NewGuid():N}.tmp"); + + public override long Length + => File.Exists(_path) ? new FileInfo(_path).Length : 0; + + public override Stream OpenRead() + => File.OpenRead(_path); + + public override Stream OpenWrite(int maximumLength, Action? completed = null) + => File.Open(_path, FileMode.Create, FileAccess.Write); + + public override async Task CopyToAsync( + Stream destination, + CancellationToken cancellationToken) + { + await using var source = OpenRead(); + await source.CopyToAsync(destination, cancellationToken); + } + + public override void Dispose() + { + if (!File.Exists(_path)) + { + return; + } + + try + { + File.Delete(_path); + } + catch + { + // Temp-file cleanup is best effort. + } + } +} + +internal sealed class PooledMemoryArchiveEntryStorage : ArchiveEntryStorage +{ + private readonly PooledBuffer _buffer; + + public PooledMemoryArchiveEntryStorage() + : this(ArrayPool.Shared) + { + } + + internal PooledMemoryArchiveEntryStorage(ArrayPool pool) + { + _buffer = new PooledBuffer(pool); + } + + public override long Length => _buffer.Length; + + public override Stream OpenRead() + => new PooledBufferReadStream(_buffer.WrittenMemory); + + public override Stream OpenWrite(int maximumLength, Action? completed = null) + { + _buffer.Reset(maximumLength); + return new PooledBufferWriteStream(_buffer, completed); + } + + public override Task CopyToAsync( + Stream destination, + CancellationToken cancellationToken) + => destination.WriteAsync(_buffer.WrittenMemory, cancellationToken).AsTask(); + + public override void Dispose() + => _buffer.Dispose(); +} + +internal sealed class PooledBuffer : IDisposable +{ + private readonly ArrayPool _pool; + private byte[]? _buffer; + private int _length; + private int _maximumLength; + private bool _disposed; + + public PooledBuffer(ArrayPool pool) + { + ArgumentNullException.ThrowIfNull(pool); + _pool = pool; + } + + public int Length => _length; + + public ReadOnlyMemory WrittenMemory + => _buffer is null ? ReadOnlyMemory.Empty : _buffer.AsMemory(0, _length); + + public void Reset(int maximumLength) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentOutOfRangeException.ThrowIfNegative(maximumLength); + + if (_buffer is not null && _length > 0) + { + _buffer.AsSpan(0, _length).Clear(); + } + + _length = 0; + _maximumLength = maximumLength; + } + + public void Write(ReadOnlySpan source) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + var requiredLength = checked(_length + source.Length); + + if (requiredLength > _maximumLength) + { + throw new InvalidOperationException( + $"File is too large and exceeds the allowed size of {_maximumLength}."); + } + + EnsureCapacity(requiredLength); + source.CopyTo(_buffer.AsSpan(_length)); + _length = requiredLength; + } + + private void EnsureCapacity(int requiredLength) + { + if (requiredLength is 0 || (_buffer?.Length ?? 0) >= requiredLength) + { + return; + } + + var currentLength = _buffer?.Length ?? 0; + var newLength = Math.Min( + _maximumLength, + Math.Max(requiredLength, currentLength is 0 ? 4096 : checked(currentLength * 2))); + var newBuffer = _pool.Rent(newLength); + + if (_buffer is not null) + { + _buffer.AsSpan(0, _length).CopyTo(newBuffer); + _pool.Return(_buffer, clearArray: true); + } + + _buffer = newBuffer; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + if (_buffer is not null) + { + _pool.Return(_buffer, clearArray: true); + _buffer = null; + } + + _length = 0; + _disposed = true; + } +} + +internal sealed class PooledBufferWriteStream( + PooledBuffer buffer, + Action? completed) : Stream +{ + private bool _disposed; + + public override bool CanRead => false; + + public override bool CanSeek => false; + + public override bool CanWrite => !_disposed; + + public override long Length => buffer.Length; + + public override long Position + { + get => buffer.Length; + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override Task FlushAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.CompletedTask; + } + + public override int Read(byte[] buffer, int offset, int count) + => throw new NotSupportedException(); + + public override long Seek(long offset, SeekOrigin origin) + => throw new NotSupportedException(); + + public override void SetLength(long value) + => throw new NotSupportedException(); + + public override void Write(byte[] source, int offset, int count) + => Write(source.AsSpan(offset, count)); + + public override void Write(ReadOnlySpan source) + { + ObjectDisposedException.ThrowIf(_disposed, this); + buffer.Write(source); + } + + public override Task WriteAsync( + byte[] source, + int offset, + int count, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Write(source.AsSpan(offset, count)); + return Task.CompletedTask; + } + + public override ValueTask WriteAsync( + ReadOnlyMemory source, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + Write(source.Span); + return ValueTask.CompletedTask; + } + + protected override void Dispose(bool disposing) + { + if (disposing && !_disposed) + { + _disposed = true; + completed?.Invoke(buffer.Length); + } + + base.Dispose(disposing); + } +} + +internal sealed class PooledBufferReadStream(ReadOnlyMemory memory) : Stream +{ + private int _position; + + public override bool CanRead => true; + + public override bool CanSeek => true; + + public override bool CanWrite => false; + + public override long Length => memory.Length; + + public override long Position + { + get => _position; + set + { + ArgumentOutOfRangeException.ThrowIfNegative(value); + ArgumentOutOfRangeException.ThrowIfGreaterThan(value, memory.Length); + + _position = checked((int)value); + } + } + + public override void Flush() + { + } + + public override int Read(byte[] destination, int offset, int count) + => Read(destination.AsSpan(offset, count)); + + public override int Read(Span destination) + { + var bytesToCopy = Math.Min(destination.Length, memory.Length - _position); + memory.Span.Slice(_position, bytesToCopy).CopyTo(destination); + _position += bytesToCopy; + return bytesToCopy; + } + + public override Task ReadAsync( + byte[] destination, + int offset, + int count, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(Read(destination.AsSpan(offset, count))); + } + + public override ValueTask ReadAsync( + Memory destination, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return ValueTask.FromResult(Read(destination.Span)); + } + + public override long Seek(long offset, SeekOrigin origin) + { + var position = origin switch + { + SeekOrigin.Begin => offset, + SeekOrigin.Current => checked(_position + offset), + SeekOrigin.End => checked(memory.Length + offset), + _ => throw new ArgumentOutOfRangeException(nameof(origin), origin, null) + }; + + Position = position; + return position; + } + + public override void SetLength(long value) + => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) + => throw new NotSupportedException(); +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Packaging/ArchiveSession.cs b/src/HotChocolate/Fusion/src/Fusion.Packaging/ArchiveSession.cs index 96771aa75bd..8c6f8bbe778 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Packaging/ArchiveSession.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Packaging/ArchiveSession.cs @@ -1,5 +1,6 @@ using System.Buffers; using System.IO.Compression; +using HotChocolate.Fusion.Packaging.Storage; namespace HotChocolate.Fusion.Packaging; @@ -8,16 +9,23 @@ internal sealed class ArchiveSession : IDisposable private readonly Dictionary _files = []; private readonly ZipArchive _archive; private readonly FusionArchiveReadOptions _readOptions; + private readonly ArchiveEntryStorageKind _storageKind; private FusionArchiveMode _mode; + private long _storedBytes; private bool _disposed; - public ArchiveSession(ZipArchive archive, FusionArchiveMode mode, FusionArchiveReadOptions readOptions) + public ArchiveSession( + ZipArchive archive, + FusionArchiveMode mode, + FusionArchiveReadOptions readOptions, + ArchiveEntryStorageKind storageKind) { ArgumentNullException.ThrowIfNull(archive); _archive = archive; _mode = mode; _readOptions = readOptions; + _storageKind = storageKind; } public bool HasUncommittedChanges @@ -25,19 +33,19 @@ public bool HasUncommittedChanges public IEnumerable GetFiles() { - var tempFiles = _files.Where(file => file.Value.State is not FileState.Deleted).Select(file => file.Key); + var stagedFiles = _files + .Where(file => file.Value.State is not FileState.Deleted) + .Select(file => file.Key); if (_mode is FusionArchiveMode.Create) { - return tempFiles; + return stagedFiles; } - var files = new HashSet(tempFiles); + var files = new HashSet(stagedFiles); foreach (var entry in _archive.Entries) { - // Skip entries that are explicitly marked Deleted in this session; - // they are still in the underlying ZipArchive but logically gone. if (_files.TryGetValue(entry.FullName, out var tracked) && tracked.State is FileState.Deleted) { @@ -50,7 +58,10 @@ public IEnumerable GetFiles() return files; } - public async Task ExistsAsync(string path, FileKind kind, CancellationToken cancellationToken) + public async Task ExistsAsync( + string path, + FileKind kind, + CancellationToken cancellationToken) { if (_files.TryGetValue(path, out var file)) { @@ -59,8 +70,7 @@ public async Task ExistsAsync(string path, FileKind kind, CancellationToke if (_mode is not FusionArchiveMode.Create && _archive.GetEntry(path) is { } entry) { - file = FileEntry.Read(path); - await ExtractFileAsync(entry, file, GetAllowedSize(kind), cancellationToken); + file = await ExtractFileAsync(entry, path, GetAllowedSize(kind), cancellationToken); _files.Add(path, file); return true; } @@ -78,7 +88,10 @@ public bool Exists(string path) return _mode is not FusionArchiveMode.Create && _archive.GetEntry(path) is not null; } - public async Task OpenReadAsync(string path, FileKind kind, CancellationToken cancellationToken) + public async Task OpenReadAsync( + string path, + FileKind kind, + CancellationToken cancellationToken) { if (_files.TryGetValue(path, out var file)) { @@ -87,16 +100,14 @@ public async Task OpenReadAsync(string path, FileKind kind, Cancellation throw new FileNotFoundException(path); } - return File.OpenRead(file.TempPath); + return file.Storage.OpenRead(); } if (_mode is not FusionArchiveMode.Create && _archive.GetEntry(path) is { } entry) { - file = FileEntry.Read(path); - await ExtractFileAsync(entry, file, GetAllowedSize(kind), cancellationToken); - var stream = File.OpenRead(file.TempPath); + file = await ExtractFileAsync(entry, path, GetAllowedSize(kind), cancellationToken); _files.Add(path, file); - return stream; + return file.Storage.OpenRead(); } throw new FileNotFoundException(path); @@ -109,22 +120,34 @@ public Stream OpenWrite(string path) throw new InvalidOperationException("Cannot write to a read-only archive."); } - if (_files.TryGetValue(path, out var file)) + if (!_files.TryGetValue(path, out var file)) + { + var state = _mode is not FusionArchiveMode.Create && _archive.GetEntry(path) is not null + ? FileState.Replaced + : FileState.Created; + file = new FileEntry(path, ArchiveEntryStorage.Create(_storageKind), state); + _files.Add(path, file); + } + else { file.MarkMutated(); - return File.Open(file.TempPath, FileMode.Create, FileAccess.Write); } - if (_mode is not FusionArchiveMode.Create && _archive.GetEntry(path) is not null) + if (_storageKind is ArchiveEntryStorageKind.TempFile) { - file = FileEntry.Read(path); - file.MarkMutated(); + return file.Storage.OpenWrite(int.MaxValue); } - file ??= FileEntry.Created(path); - var stream = File.Open(file.TempPath, FileMode.Create, FileAccess.Write); - _files.Add(path, file); - return stream; + var previousLength = file.Storage.Length; + var remainingSessionBytes = checked( + _readOptions.MaxAllowedInMemorySessionSize - _storedBytes + previousLength); + var maximumLength = (int)Math.Min( + Math.Max(0, remainingSessionBytes), + GetAllowedSize(FileNames.GetFileKind(path))); + + return file.Storage.OpenWrite( + maximumLength, + length => _storedBytes = checked(_storedBytes - previousLength + length)); } public void Delete(string path) @@ -143,39 +166,32 @@ public void Delete(string path) if (file.State is FileState.Created) { - // File was added in this uncommitted session and never existed - // in the original archive: drop it entirely. - TryDeleteTempFile(file); + RemoveStorage(file); _files.Remove(path); return; } - // File was previously read or replaced (extracted to a temp file). - // Clean up the temp file now since Dispose skips Deleted entries. - TryDeleteTempFile(file); + RemoveStorage(file); file.MarkDeleted(); return; } if (_mode is not FusionArchiveMode.Create && _archive.GetEntry(path) is not null) { - _files.Add(path, FileEntry.Deleted(path)); + _files.Add( + path, + new FileEntry(path, ArchiveEntryStorage.Create(_storageKind), FileState.Deleted)); } } - private static void TryDeleteTempFile(FileEntry file) + private void RemoveStorage(FileEntry file) { - if (File.Exists(file.TempPath)) + if (_storageKind is ArchiveEntryStorageKind.Memory) { - try - { - File.Delete(file.TempPath); - } - catch - { - // ignore - } + _storedBytes -= file.Storage.Length; } + + file.Storage.Dispose(); } public void SetMode(FusionArchiveMode mode) @@ -190,12 +206,12 @@ public async Task CommitAsync(CancellationToken cancellationToken) switch (file.State) { case FileState.Created: - await CreateEntryFromFileAsync(file.TempPath, file.Path, cancellationToken); + await CreateEntryFromStorageAsync(file, cancellationToken); break; case FileState.Replaced: _archive.GetEntry(file.Path)?.Delete(); - await CreateEntryFromFileAsync(file.TempPath, file.Path, cancellationToken); + await CreateEntryFromStorageAsync(file, cancellationToken); break; case FileState.Deleted: @@ -207,41 +223,43 @@ public async Task CommitAsync(CancellationToken cancellationToken) } } - /// - /// Creates a ZIP entry from a file with a deterministic timestamp. - /// Using a fixed timestamp ensures binary reproducibility of the archive. - /// - private async Task CreateEntryFromFileAsync( - string sourceFileName, - string entryName, + private async Task CreateEntryFromStorageAsync( + FileEntry file, CancellationToken cancellationToken) { - var entry = _archive.CreateEntry(entryName); - // Use a fixed timestamp to ensure deterministic archive output + var entry = _archive.CreateEntry(file.Path); entry.LastWriteTime = new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero); - await using var source = File.OpenRead(sourceFileName); #if NET10_0_OR_GREATER await using var destination = await entry.OpenAsync(cancellationToken); #else await using var destination = entry.Open(); #endif - await source.CopyToAsync(destination, cancellationToken); + await file.Storage.CopyToAsync(destination, cancellationToken); } - private static async Task ExtractFileAsync( + private async Task ExtractFileAsync( ZipArchiveEntry zipEntry, - FileEntry fileEntry, + string path, int maxAllowedSize, CancellationToken cancellationToken) { + var file = new FileEntry( + path, + ArchiveEntryStorage.Create(_storageKind), + FileState.Read); var buffer = ArrayPool.Shared.Rent(4096); - var consumed = 0; + var consumed = 0L; try { await using var readStream = zipEntry.Open(); - await using var writeStream = File.Open(fileEntry.TempPath, FileMode.Create, FileAccess.Write); + await using var writeStream = file.Storage.OpenWrite( + _storageKind is ArchiveEntryStorageKind.Memory + ? (int)Math.Min( + maxAllowedSize, + Math.Max(0L, _readOptions.MaxAllowedInMemorySessionSize - _storedBytes)) + : int.MaxValue); int read; while ((read = await readStream.ReadAsync(buffer, cancellationToken)) > 0) @@ -254,12 +272,32 @@ private static async Task ExtractFileAsync( $"File is too large and exceeds the allowed size of {maxAllowedSize}."); } + if (_storageKind is ArchiveEntryStorageKind.Memory + && _storedBytes + consumed > _readOptions.MaxAllowedInMemorySessionSize) + { + throw new InvalidOperationException( + "The archive exceeds the allowed in-memory session size of " + + $"{_readOptions.MaxAllowedInMemorySessionSize}."); + } + await writeStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken); } + + if (_storageKind is ArchiveEntryStorageKind.Memory) + { + _storedBytes += file.Storage.Length; + } + + return file; + } + catch + { + file.Storage.Dispose(); + throw; } finally { - ArrayPool.Shared.Return(buffer); + ArrayPool.Shared.Return(buffer, clearArray: true); } } @@ -282,36 +320,23 @@ public void Dispose() foreach (var file in _files.Values) { - if (file.State is not FileState.Deleted && File.Exists(file.TempPath)) - { - try - { - File.Delete(file.TempPath); - } - catch - { - // ignore - } - } + file.Storage.Dispose(); } + _storedBytes = 0; _disposed = true; } - private class FileEntry + private sealed class FileEntry( + string path, + ArchiveEntryStorage storage, + FileState state) { - private FileEntry(string path, string tempPath, FileState state) - { - Path = path; - TempPath = tempPath; - State = state; - } - - public string Path { get; } + public string Path { get; } = path; - public string TempPath { get; } + public ArchiveEntryStorage Storage { get; } = storage; - public FileState State { get; private set; } + public FileState State { get; private set; } = state; public void MarkMutated() { @@ -330,22 +355,6 @@ public void MarkRead() { State = FileState.Read; } - - public static FileEntry Created(string path) - => new(path, GetRandomTempFileName(), FileState.Created); - - public static FileEntry Read(string path) - => new(path, GetRandomTempFileName(), FileState.Read); - - public static FileEntry Deleted(string path) - => new(path, GetRandomTempFileName(), FileState.Deleted); - - private static string GetRandomTempFileName() - { - var tempDir = System.IO.Path.GetTempPath(); - var fileName = System.IO.Path.GetRandomFileName(); - return System.IO.Path.Combine(tempDir, fileName); - } } private enum FileState diff --git a/src/HotChocolate/Fusion/src/Fusion.Packaging/FusionArchive.cs b/src/HotChocolate/Fusion/src/Fusion.Packaging/FusionArchive.cs index 1f2627e0a44..ccf13af30b0 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Packaging/FusionArchive.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Packaging/FusionArchive.cs @@ -8,6 +8,7 @@ using System.Text; using System.Text.Json; using HotChocolate.Fusion.Packaging.Serializers; +using HotChocolate.Fusion.Packaging.Storage; namespace HotChocolate.Fusion.Packaging; @@ -30,13 +31,14 @@ private FusionArchive( Stream stream, FusionArchiveMode mode, bool leaveOpen, - FusionArchiveReadOptions options) + FusionArchiveReadOptions options, + ArchiveEntryStorageKind storageKind) { _stream = stream; _mode = mode; _leaveOpen = leaveOpen; _archive = new ZipArchive(stream, (ZipArchiveMode)mode, leaveOpen); - _session = new ArchiveSession(_archive, mode, options); + _session = new ArchiveSession(_archive, mode, options, storageKind); } /// @@ -48,7 +50,12 @@ private FusionArchive( public static FusionArchive Create(string filename) { ArgumentNullException.ThrowIfNull(filename); - return Create(File.Create(filename)); + return new FusionArchive( + File.Create(filename), + FusionArchiveMode.Create, + leaveOpen: false, + FusionArchiveReadOptions.Default, + ArchiveEntryStorageKind.TempFile); } /// @@ -61,7 +68,29 @@ public static FusionArchive Create(string filename) public static FusionArchive Create(Stream stream, bool leaveOpen = false) { ArgumentNullException.ThrowIfNull(stream); - return new FusionArchive(stream, FusionArchiveMode.Create, leaveOpen, FusionArchiveReadOptions.Default); + return new FusionArchive( + stream, + FusionArchiveMode.Create, + leaveOpen, + FusionArchiveReadOptions.Default, + ArchiveEntryStorageKind.Memory); + } + + /// + /// Creates a new Fusion Archive using the provided stream and options. + /// + public static FusionArchive Create( + Stream stream, + FusionArchiveOptions options, + bool leaveOpen = false) + { + ArgumentNullException.ThrowIfNull(stream); + return new FusionArchive( + stream, + FusionArchiveMode.Create, + leaveOpen, + CreateReadOptions(options), + ArchiveEntryStorageKind.Memory); } /// @@ -80,9 +109,19 @@ public static FusionArchive Open( return mode switch { - FusionArchiveMode.Read => Open(File.OpenRead(filename), mode), - FusionArchiveMode.Create => Create(File.Create(filename)), - FusionArchiveMode.Update => Open(File.Open(filename, FileMode.Open, FileAccess.ReadWrite), mode), + FusionArchiveMode.Read => new FusionArchive( + File.OpenRead(filename), + mode, + leaveOpen: false, + FusionArchiveReadOptions.Default, + ArchiveEntryStorageKind.TempFile), + FusionArchiveMode.Create => Create(filename), + FusionArchiveMode.Update => new FusionArchive( + File.Open(filename, FileMode.Open, FileAccess.ReadWrite), + mode, + leaveOpen: false, + FusionArchiveReadOptions.Default, + ArchiveEntryStorageKind.TempFile), _ => throw new ArgumentException("Invalid mode.", nameof(mode)) }; } @@ -103,12 +142,21 @@ public static FusionArchive Open( FusionArchiveOptions options = default) { ArgumentNullException.ThrowIfNull(stream); - var readOptions = new FusionArchiveReadOptions( + return new FusionArchive( + stream, + mode, + leaveOpen, + CreateReadOptions(options), + ArchiveEntryStorageKind.Memory); + } + + private static FusionArchiveReadOptions CreateReadOptions(FusionArchiveOptions options) + => new( options.MaxAllowedSchemaSize ?? FusionArchiveReadOptions.Default.MaxAllowedSchemaSize, options.MaxAllowedSettingsSize ?? FusionArchiveReadOptions.Default.MaxAllowedSettingsSize, - options.MaxAllowedLegacyArchiveSize ?? FusionArchiveReadOptions.Default.MaxAllowedLegacyArchiveSize); - return new FusionArchive(stream, mode, leaveOpen, readOptions); - } + options.MaxAllowedLegacyArchiveSize ?? FusionArchiveReadOptions.Default.MaxAllowedLegacyArchiveSize, + options.MaxAllowedInMemorySessionSize + ?? FusionArchiveReadOptions.Default.MaxAllowedInMemorySessionSize); /// /// Sets the archive metadata containing format version and schema information. diff --git a/src/HotChocolate/Fusion/src/Fusion.Packaging/FusionArchiveOptions.cs b/src/HotChocolate/Fusion/src/Fusion.Packaging/FusionArchiveOptions.cs index 99390563f5a..ff9744df370 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Packaging/FusionArchiveOptions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Packaging/FusionArchiveOptions.cs @@ -19,4 +19,9 @@ public struct FusionArchiveOptions /// Gets or sets the maximum allowed size of the legacy archive in the archive. /// public int? MaxAllowedLegacyArchiveSize { get; set; } + + /// + /// Gets or sets the maximum total size of entries staged in memory for a stream-based archive. + /// + public int? MaxAllowedInMemorySessionSize { get; set; } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Packaging/FusionArchiveReadOptions.cs b/src/HotChocolate/Fusion/src/Fusion.Packaging/FusionArchiveReadOptions.cs index 60e17aa2689..0060e8e3881 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Packaging/FusionArchiveReadOptions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Packaging/FusionArchiveReadOptions.cs @@ -6,7 +6,8 @@ namespace HotChocolate.Fusion.Packaging; internal readonly record struct FusionArchiveReadOptions( int MaxAllowedSchemaSize, int MaxAllowedSettingsSize, - int MaxAllowedLegacyArchiveSize) + int MaxAllowedLegacyArchiveSize, + int MaxAllowedInMemorySessionSize) { /// /// Gets the default read options. @@ -14,5 +15,6 @@ internal readonly record struct FusionArchiveReadOptions( public static FusionArchiveReadOptions Default { get; } = new( 50_000_000, 512_000, - 100_000_000); + 100_000_000, + 512_000_000); } diff --git a/src/HotChocolate/Fusion/src/Fusion.Packaging/HotChocolate.Fusion.Packaging.csproj b/src/HotChocolate/Fusion/src/Fusion.Packaging/HotChocolate.Fusion.Packaging.csproj index 4423e0f6d80..309074d0c98 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Packaging/HotChocolate.Fusion.Packaging.csproj +++ b/src/HotChocolate/Fusion/src/Fusion.Packaging/HotChocolate.Fusion.Packaging.csproj @@ -10,4 +10,9 @@ + + + + + diff --git a/src/HotChocolate/Fusion/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchive.cs b/src/HotChocolate/Fusion/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchive.cs index 84325dcdc61..b50031089ca 100644 --- a/src/HotChocolate/Fusion/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchive.cs +++ b/src/HotChocolate/Fusion/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchive.cs @@ -2,6 +2,7 @@ using System.IO.Compression; using System.IO.Pipelines; using System.Text.Json; +using HotChocolate.Fusion.Packaging.Storage; using HotChocolate.Fusion.SourceSchema.Packaging.Serializers; namespace HotChocolate.Fusion.SourceSchema.Packaging; @@ -26,13 +27,14 @@ private FusionSourceSchemaArchive( Stream stream, FusionSourceSchemaArchiveMode mode, bool leaveOpen, - FusionSourceSchemaArchiveReadOptions options) + FusionSourceSchemaArchiveReadOptions options, + ArchiveEntryStorageKind storageKind) { _stream = stream; _mode = mode; _leaveOpen = leaveOpen; _archive = new ZipArchive(stream, (ZipArchiveMode)mode, leaveOpen); - _session = new FusionSourceSchemaArchiveSession(_archive, mode, options); + _session = new FusionSourceSchemaArchiveSession(_archive, mode, options, storageKind); } /// @@ -44,7 +46,12 @@ private FusionSourceSchemaArchive( public static FusionSourceSchemaArchive Create(string filename) { ArgumentNullException.ThrowIfNull(filename); - return Create(File.Create(filename)); + return new FusionSourceSchemaArchive( + File.Create(filename), + FusionSourceSchemaArchiveMode.Create, + leaveOpen: false, + FusionSourceSchemaArchiveReadOptions.Default, + ArchiveEntryStorageKind.TempFile); } /// @@ -57,7 +64,29 @@ public static FusionSourceSchemaArchive Create(string filename) public static FusionSourceSchemaArchive Create(Stream stream, bool leaveOpen = false) { ArgumentNullException.ThrowIfNull(stream); - return new FusionSourceSchemaArchive(stream, FusionSourceSchemaArchiveMode.Create, leaveOpen, FusionSourceSchemaArchiveReadOptions.Default); + return new FusionSourceSchemaArchive( + stream, + FusionSourceSchemaArchiveMode.Create, + leaveOpen, + FusionSourceSchemaArchiveReadOptions.Default, + ArchiveEntryStorageKind.Memory); + } + + /// + /// Creates a new Fusion source schema archive using the provided stream and options. + /// + public static FusionSourceSchemaArchive Create( + Stream stream, + FusionSourceSchemaArchiveOptions options, + bool leaveOpen = false) + { + ArgumentNullException.ThrowIfNull(stream); + return new FusionSourceSchemaArchive( + stream, + FusionSourceSchemaArchiveMode.Create, + leaveOpen, + CreateReadOptions(options), + ArchiveEntryStorageKind.Memory); } /// @@ -76,9 +105,19 @@ public static FusionSourceSchemaArchive Open( return mode switch { - FusionSourceSchemaArchiveMode.Read => Open(File.OpenRead(filename), mode), - FusionSourceSchemaArchiveMode.Create => Create(File.Create(filename)), - FusionSourceSchemaArchiveMode.Update => Open(File.Open(filename, FileMode.Open, FileAccess.ReadWrite), mode), + FusionSourceSchemaArchiveMode.Read => new FusionSourceSchemaArchive( + File.OpenRead(filename), + mode, + leaveOpen: false, + FusionSourceSchemaArchiveReadOptions.Default, + ArchiveEntryStorageKind.TempFile), + FusionSourceSchemaArchiveMode.Create => Create(filename), + FusionSourceSchemaArchiveMode.Update => new FusionSourceSchemaArchive( + File.Open(filename, FileMode.Open, FileAccess.ReadWrite), + mode, + leaveOpen: false, + FusionSourceSchemaArchiveReadOptions.Default, + ArchiveEntryStorageKind.TempFile), _ => throw new ArgumentException("Invalid mode.", nameof(mode)) }; } @@ -99,12 +138,24 @@ public static FusionSourceSchemaArchive Open( FusionSourceSchemaArchiveOptions options = default) { ArgumentNullException.ThrowIfNull(stream); - var readOptions = new FusionSourceSchemaArchiveReadOptions( - options.MaxAllowedSchemaSize ?? FusionSourceSchemaArchiveReadOptions.Default.MaxAllowedSchemaSize, - options.MaxAllowedSettingsSize ?? FusionSourceSchemaArchiveReadOptions.Default.MaxAllowedSettingsSize); - return new FusionSourceSchemaArchive(stream, mode, leaveOpen, readOptions); + return new FusionSourceSchemaArchive( + stream, + mode, + leaveOpen, + CreateReadOptions(options), + ArchiveEntryStorageKind.Memory); } + private static FusionSourceSchemaArchiveReadOptions CreateReadOptions( + FusionSourceSchemaArchiveOptions options) + => new( + options.MaxAllowedSchemaSize + ?? FusionSourceSchemaArchiveReadOptions.Default.MaxAllowedSchemaSize, + options.MaxAllowedSettingsSize + ?? FusionSourceSchemaArchiveReadOptions.Default.MaxAllowedSettingsSize, + options.MaxAllowedInMemorySessionSize + ?? FusionSourceSchemaArchiveReadOptions.Default.MaxAllowedInMemorySessionSize); + /// /// Sets the archive metadata. /// diff --git a/src/HotChocolate/Fusion/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchiveOptions.cs b/src/HotChocolate/Fusion/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchiveOptions.cs index 0cf28a8bf3f..1931376fa7b 100644 --- a/src/HotChocolate/Fusion/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchiveOptions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchiveOptions.cs @@ -14,4 +14,9 @@ public struct FusionSourceSchemaArchiveOptions /// Gets or sets the maximum allowed size of the settings in the archive. /// public int? MaxAllowedSettingsSize { get; set; } + + /// + /// Gets or sets the maximum total size of entries staged in memory for a stream-based archive. + /// + public int? MaxAllowedInMemorySessionSize { get; set; } } diff --git a/src/HotChocolate/Fusion/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchiveReadOptions.cs b/src/HotChocolate/Fusion/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchiveReadOptions.cs index 4365ff7eb99..6fa86a1a2b5 100644 --- a/src/HotChocolate/Fusion/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchiveReadOptions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchiveReadOptions.cs @@ -5,11 +5,12 @@ namespace HotChocolate.Fusion.SourceSchema.Packaging; /// internal readonly record struct FusionSourceSchemaArchiveReadOptions( int MaxAllowedSchemaSize, - int MaxAllowedSettingsSize) + int MaxAllowedSettingsSize, + int MaxAllowedInMemorySessionSize) { /// /// Gets the default read options. /// public static FusionSourceSchemaArchiveReadOptions Default { get; } - = new(50_000_000, 512_000); + = new(50_000_000, 512_000, 128_000_000); } diff --git a/src/HotChocolate/Fusion/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchiveSession.cs b/src/HotChocolate/Fusion/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchiveSession.cs index bd5bbebd717..25527ae804f 100644 --- a/src/HotChocolate/Fusion/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchiveSession.cs +++ b/src/HotChocolate/Fusion/src/Fusion.SourceSchema.Packaging/FusionSourceSchemaArchiveSession.cs @@ -1,5 +1,6 @@ using System.Buffers; using System.IO.Compression; +using HotChocolate.Fusion.Packaging.Storage; namespace HotChocolate.Fusion.SourceSchema.Packaging; @@ -8,19 +9,23 @@ internal sealed class FusionSourceSchemaArchiveSession : IDisposable private readonly Dictionary _files = []; private readonly ZipArchive _archive; private readonly FusionSourceSchemaArchiveReadOptions _readOptions; + private readonly ArchiveEntryStorageKind _storageKind; private FusionSourceSchemaArchiveMode _mode; + private long _storedBytes; private bool _disposed; public FusionSourceSchemaArchiveSession( ZipArchive archive, FusionSourceSchemaArchiveMode mode, - FusionSourceSchemaArchiveReadOptions readOptions) + FusionSourceSchemaArchiveReadOptions readOptions, + ArchiveEntryStorageKind storageKind) { ArgumentNullException.ThrowIfNull(archive); _archive = archive; _mode = mode; _readOptions = readOptions; + _storageKind = storageKind; } public bool HasUncommittedChanges @@ -28,14 +33,16 @@ public bool HasUncommittedChanges public IEnumerable GetFiles() { - var tempFiles = _files.Where(file => file.Value.State is not FileState.Deleted).Select(file => file.Key); + var stagedFiles = _files + .Where(file => file.Value.State is not FileState.Deleted) + .Select(file => file.Key); if (_mode is FusionSourceSchemaArchiveMode.Create) { - return tempFiles; + return stagedFiles; } - var files = new HashSet(tempFiles); + var files = new HashSet(stagedFiles); foreach (var entry in _archive.Entries) { @@ -45,7 +52,10 @@ public IEnumerable GetFiles() return files; } - public async Task ExistsAsync(string path, FileKind kind, CancellationToken cancellationToken) + public async Task ExistsAsync( + string path, + FileKind kind, + CancellationToken cancellationToken) { if (_files.TryGetValue(path, out var file)) { @@ -54,8 +64,7 @@ public async Task ExistsAsync(string path, FileKind kind, CancellationToke if (_mode is not FusionSourceSchemaArchiveMode.Create && _archive.GetEntry(path) is { } entry) { - file = FileEntry.Read(path); - await ExtractFileAsync(entry, file, GetAllowedSize(kind), cancellationToken); + file = await ExtractFileAsync(entry, path, GetAllowedSize(kind), cancellationToken); _files.Add(path, file); return true; } @@ -73,7 +82,10 @@ public bool Exists(string path) return _mode is not FusionSourceSchemaArchiveMode.Create && _archive.GetEntry(path) is not null; } - public async Task OpenReadAsync(string path, FileKind kind, CancellationToken cancellationToken) + public async Task OpenReadAsync( + string path, + FileKind kind, + CancellationToken cancellationToken) { if (_files.TryGetValue(path, out var file)) { @@ -82,16 +94,14 @@ public async Task OpenReadAsync(string path, FileKind kind, Cancellation throw new FileNotFoundException(path); } - return File.OpenRead(file.TempPath); + return file.Storage.OpenRead(); } if (_mode is not FusionSourceSchemaArchiveMode.Create && _archive.GetEntry(path) is { } entry) { - file = FileEntry.Read(path); - await ExtractFileAsync(entry, file, GetAllowedSize(kind), cancellationToken); - var stream = File.OpenRead(file.TempPath); + file = await ExtractFileAsync(entry, path, GetAllowedSize(kind), cancellationToken); _files.Add(path, file); - return stream; + return file.Storage.OpenRead(); } throw new FileNotFoundException(path); @@ -104,23 +114,35 @@ public Stream OpenWrite(string path) throw new InvalidOperationException("Cannot write to a read-only archive."); } - if (_files.TryGetValue(path, out var file)) + if (!_files.TryGetValue(path, out var file)) + { + var state = _mode is not FusionSourceSchemaArchiveMode.Create + && _archive.GetEntry(path) is not null + ? FileState.Replaced + : FileState.Created; + file = new FileEntry(path, ArchiveEntryStorage.Create(_storageKind), state); + _files.Add(path, file); + } + else { file.MarkMutated(); - return File.Open(file.TempPath, FileMode.Create, FileAccess.Write); } - if (_mode is not FusionSourceSchemaArchiveMode.Create && _archive.GetEntry(path) is not null) + if (_storageKind is ArchiveEntryStorageKind.TempFile) { - file = FileEntry.Read(path); - file.MarkMutated(); + return file.Storage.OpenWrite(int.MaxValue); } - file ??= FileEntry.Created(path); - var stream = File.Open(file.TempPath, FileMode.Create, FileAccess.Write); - _files.Add(path, file); + var previousLength = file.Storage.Length; + var remainingSessionBytes = checked( + _readOptions.MaxAllowedInMemorySessionSize - _storedBytes + previousLength); + var maximumLength = (int)Math.Min( + Math.Max(0, remainingSessionBytes), + GetAllowedSize(GetFileKind(path))); - return stream; + return file.Storage.OpenWrite( + maximumLength, + length => _storedBytes = checked(_storedBytes - previousLength + length)); } public void SetMode(FusionSourceSchemaArchiveMode mode) @@ -135,12 +157,12 @@ public async Task CommitAsync(CancellationToken cancellationToken) switch (file.State) { case FileState.Created: - await CreateEntryFromFileAsync(file.TempPath, file.Path, cancellationToken); + await CreateEntryFromStorageAsync(file, cancellationToken); break; case FileState.Replaced: _archive.GetEntry(file.Path)?.Delete(); - await CreateEntryFromFileAsync(file.TempPath, file.Path, cancellationToken); + await CreateEntryFromStorageAsync(file, cancellationToken); break; case FileState.Deleted: @@ -152,41 +174,43 @@ public async Task CommitAsync(CancellationToken cancellationToken) } } - /// - /// Creates a ZIP entry from a file with a deterministic timestamp. - /// Using a fixed timestamp ensures binary reproducibility of the archive. - /// - private async Task CreateEntryFromFileAsync( - string sourceFileName, - string entryName, + private async Task CreateEntryFromStorageAsync( + FileEntry file, CancellationToken cancellationToken) { - var entry = _archive.CreateEntry(entryName); - // Use a fixed timestamp to ensure deterministic archive output + var entry = _archive.CreateEntry(file.Path); entry.LastWriteTime = new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero); - await using var source = File.OpenRead(sourceFileName); #if NET10_0_OR_GREATER await using var destination = await entry.OpenAsync(cancellationToken); #else await using var destination = entry.Open(); #endif - await source.CopyToAsync(destination, cancellationToken); + await file.Storage.CopyToAsync(destination, cancellationToken); } - private static async Task ExtractFileAsync( + private async Task ExtractFileAsync( ZipArchiveEntry zipEntry, - FileEntry fileEntry, + string path, int maxAllowedSize, CancellationToken cancellationToken) { + var file = new FileEntry( + path, + ArchiveEntryStorage.Create(_storageKind), + FileState.Read); var buffer = ArrayPool.Shared.Rent(4096); - var consumed = 0; + var consumed = 0L; try { await using var readStream = zipEntry.Open(); - await using var writeStream = File.Open(fileEntry.TempPath, FileMode.Create, FileAccess.Write); + await using var writeStream = file.Storage.OpenWrite( + _storageKind is ArchiveEntryStorageKind.Memory + ? (int)Math.Min( + maxAllowedSize, + Math.Max(0L, _readOptions.MaxAllowedInMemorySessionSize - _storedBytes)) + : int.MaxValue); int read; while ((read = await readStream.ReadAsync(buffer, cancellationToken)) > 0) @@ -199,25 +223,52 @@ private static async Task ExtractFileAsync( $"File is too large and exceeds the allowed size of {maxAllowedSize}."); } + if (_storageKind is ArchiveEntryStorageKind.Memory + && _storedBytes + consumed > _readOptions.MaxAllowedInMemorySessionSize) + { + throw new InvalidOperationException( + "The archive exceeds the allowed in-memory session size of " + + $"{_readOptions.MaxAllowedInMemorySessionSize}."); + } + await writeStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken); } + + if (_storageKind is ArchiveEntryStorageKind.Memory) + { + _storedBytes += file.Storage.Length; + } + + return file; + } + catch + { + file.Storage.Dispose(); + throw; } finally { - ArrayPool.Shared.Return(buffer); + ArrayPool.Shared.Return(buffer, clearArray: true); } } private int GetAllowedSize(FileKind kind) => kind switch { - FileKind.GraphQLSchema - => _readOptions.MaxAllowedSchemaSize, - FileKind.Settings or FileKind.Metadata - => _readOptions.MaxAllowedSettingsSize, + FileKind.GraphQLSchema => _readOptions.MaxAllowedSchemaSize, + FileKind.Settings or FileKind.Metadata => _readOptions.MaxAllowedSettingsSize, _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null) }; + private static FileKind GetFileKind(string path) + => Path.GetFileName(path) switch + { + FileNames.GraphQLSchema or FileNames.GraphQLSchemaExtensions => FileKind.GraphQLSchema, + FileNames.ArchiveMetadata => FileKind.Metadata, + FileNames.Settings => FileKind.Settings, + _ => FileKind.Settings + }; + public void Dispose() { if (_disposed) @@ -227,36 +278,23 @@ public void Dispose() foreach (var file in _files.Values) { - if (file.State is not FileState.Deleted && File.Exists(file.TempPath)) - { - try - { - File.Delete(file.TempPath); - } - catch - { - // ignore - } - } + file.Storage.Dispose(); } + _storedBytes = 0; _disposed = true; } - private class FileEntry + private sealed class FileEntry( + string path, + ArchiveEntryStorage storage, + FileState state) { - private FileEntry(string path, string tempPath, FileState state) - { - Path = path; - TempPath = tempPath; - State = state; - } + public string Path { get; } = path; - public string Path { get; } + public ArchiveEntryStorage Storage { get; } = storage; - public string TempPath { get; } - - public FileState State { get; private set; } + public FileState State { get; private set; } = state; public void MarkMutated() { @@ -270,19 +308,6 @@ public void MarkRead() { State = FileState.Read; } - - public static FileEntry Created(string path) - => new(path, GetRandomTempFileName(), FileState.Created); - - public static FileEntry Read(string path) - => new(path, GetRandomTempFileName(), FileState.Read); - - private static string GetRandomTempFileName() - { - var tempDir = System.IO.Path.GetTempPath(); - var fileName = System.IO.Path.GetRandomFileName(); - return System.IO.Path.Combine(tempDir, fileName); - } } private enum FileState diff --git a/src/HotChocolate/Fusion/src/Fusion.SourceSchema.Packaging/HotChocolate.Fusion.SourceSchema.Packaging.csproj b/src/HotChocolate/Fusion/src/Fusion.SourceSchema.Packaging/HotChocolate.Fusion.SourceSchema.Packaging.csproj index f258e4e6093..c479a1a6899 100644 --- a/src/HotChocolate/Fusion/src/Fusion.SourceSchema.Packaging/HotChocolate.Fusion.SourceSchema.Packaging.csproj +++ b/src/HotChocolate/Fusion/src/Fusion.SourceSchema.Packaging/HotChocolate.Fusion.SourceSchema.Packaging.csproj @@ -9,4 +9,8 @@ + + + + diff --git a/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/AspireCompositionHelperTests.cs b/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/AspireCompositionHelperTests.cs index 853e8c1a794..cd720931093 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/AspireCompositionHelperTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/AspireCompositionHelperTests.cs @@ -1,7 +1,11 @@ +using System.Text; using System.Text.Json; +using HotChocolate.Fusion.Aspire.Nitro; using HotChocolate.Fusion.Options; using HotChocolate.Fusion.Packaging; +using HotChocolate.Fusion.SourceSchema.Packaging; using Microsoft.Extensions.Logging.Abstractions; +using IOPath = System.IO.Path; namespace HotChocolate.Fusion.Aspire; @@ -9,6 +13,152 @@ public sealed class AspireCompositionHelperTests { private const string ProductsSchemaText = "type Query { product: String }"; + [Fact] + public async Task TryComposeArchivesAsync_Should_ResolveSettings_WhenEnvironmentIsExplicit() + { + using var directory = new TestDirectory(); + var sourceArchivePath = IOPath.Combine(directory.Path, "products.zip"); + var stagingArchivePath = IOPath.Combine(directory.Path, "staging.far"); + var productionArchivePath = IOPath.Combine(directory.Path, "production.far"); + await CreateSourceArchiveAsync(sourceArchivePath); + var sourceArchives = + new[] { new SourceSchemaArchiveInfo("Products", sourceArchivePath) }; + var compositionSettings = new GraphQLCompositionSettings + { + EnvironmentName = "Aspire" + }; + + var stagingSuccess = await AspireCompositionHelper.TryComposeArchivesAsync( + stagingArchivePath, + sourceArchives, + "Staging", + compositionSettings, + NullLogger.Instance, + TestContext.Current.CancellationToken); + var productionSuccess = await AspireCompositionHelper.TryComposeArchivesAsync( + productionArchivePath, + sourceArchives, + "Production", + compositionSettings, + NullLogger.Instance, + TestContext.Current.CancellationToken); + + Assert.True(stagingSuccess); + Assert.True(productionSuccess); + var stagingSettings = await ReadGatewaySettingsAsync(stagingArchivePath); + var productionSettings = await ReadGatewaySettingsAsync(productionArchivePath); + string.Join( + Environment.NewLine, + "## Staging", + stagingSettings, + "", + "## Production", + productionSettings) + .MatchInlineSnapshot( + """ + ## Staging + { + "sourceSchemas": { + "Products": { + "transports": { + "http": { + "url": "https://staging.products.example.com/graphql", + "capabilities": { + "subscriptions": { + "supported": true + } + } + } + }, + "extensions": { + "timeout": 5000, + "label": "staging-green" + } + } + } + } + + ## Production + { + "sourceSchemas": { + "Products": { + "transports": { + "http": { + "url": "https://products.example.com/graphql", + "capabilities": { + "subscriptions": { + "supported": false + } + } + } + }, + "extensions": { + "timeout": 10000, + "label": "production-blue" + } + } + } + } + """); + } + + [Theory] + [InlineData("staging")] + [InlineData("Preview")] + public async Task TryComposeArchivesAsync_Should_Fail_WhenEnvironmentDoesNotProvideVariables( + string environmentName) + { + using var directory = new TestDirectory(); + var sourceArchivePath = IOPath.Combine(directory.Path, "products.zip"); + var fusionArchivePath = IOPath.Combine(directory.Path, "gateway.far"); + await CreateSourceArchiveAsync(sourceArchivePath); + + var exception = await Assert.ThrowsAsync( + () => AspireCompositionHelper.TryComposeArchivesAsync( + fusionArchivePath, + [new SourceSchemaArchiveInfo("Products", sourceArchivePath)], + environmentName, + default, + NullLogger.Instance, + TestContext.Current.CancellationToken)); + + Assert.Equal( + "Variable 'BASE_URL' not found in environment", + exception.Message); + } + + [Fact] + public void ResolveSourceSchemaSettings_Should_RemoveEnvironmentMap_WhenEnvironmentIsResolved() + { + using var sourceSettings = CreateSourceSettings(); + using var resolved = AspireCompositionHelper.ResolveSourceSchemaSettings( + sourceSettings, + "Staging"); + + JsonSerializer.Serialize( + resolved.RootElement, + new JsonSerializerOptions { WriteIndented = true }) + .MatchInlineSnapshot( + """ + { + "transports": { + "http": { + "url": "https://staging.products.example.com/graphql", + "capabilities": { + "subscriptions": { + "supported": true + } + } + } + }, + "extensions": { + "timeout": 5000, + "label": "staging-green" + } + } + """); + } + [Theory] [InlineData(null)] [InlineData(NodeResolution.Gateway)] @@ -22,7 +172,9 @@ public void CreateCompositionSettings_Should_MapNodeResolution( NodeResolution = nodeResolution }; - var compositionSettings = AspireCompositionHelper.CreateCompositionSettings(settings); + var compositionSettings = AspireCompositionHelper.CreateCompositionSettings( + settings, + stageSettings: null); Assert.True(compositionSettings.Merger.EnableGlobalObjectIdentification); Assert.Equal(nodeResolution, compositionSettings.Merger.NodeResolution); @@ -40,7 +192,9 @@ public void CreateCompositionSettings_Should_MapShareableFieldRuntimeTypeRouting ShareableFieldRuntimeTypeRouting = routing }; - var compositionSettings = AspireCompositionHelper.CreateCompositionSettings(settings); + var compositionSettings = AspireCompositionHelper.CreateCompositionSettings( + settings, + stageSettings: null); Assert.Equal( routing, @@ -59,7 +213,9 @@ public void CreateCompositionSettings_Should_MapAllowNonResolvableInterfaceObjec AllowNonResolvableInterfaceObjects = allow }; - var compositionSettings = AspireCompositionHelper.CreateCompositionSettings(settings); + var compositionSettings = AspireCompositionHelper.CreateCompositionSettings( + settings, + stageSettings: null); Assert.Equal( allow, @@ -81,7 +237,9 @@ public void CreateCompositionSettings_Should_MapAllUserFacingSettings() ShareableFieldRuntimeTypeRouting.CommonRuntimeTypes, TagMergeBehavior = DirectiveMergeBehavior.Include }; - var compositionSettings = AspireCompositionHelper.CreateCompositionSettings(settings); + var compositionSettings = AspireCompositionHelper.CreateCompositionSettings( + settings, + stageSettings: null); using var document = JsonSerializer.SerializeToDocument( compositionSettings, SettingsJsonSerializerContext.Default.CompositionSettings); @@ -116,6 +274,123 @@ public void CreateCompositionSettings_Should_MapAllUserFacingSettings() """); } + [Fact] + public void CreateCompositionSettings_Should_UseStageSettings_When_SettingsAreUnset() + { + // arrange + var settings = new GraphQLCompositionSettings + { + TagMergeBehavior = DirectiveMergeBehavior.Include + }; + var stageSettings = new CompositionSettings + { + Merger = new CompositionSettings.MergerSettings + { + CacheControlMergeBehavior = DirectiveMergeBehavior.IncludePrivate, + EnableGlobalObjectIdentification = true, + NodeResolution = NodeResolution.SourceSchema, + RemoveUnreferencedDefinitions = true, + TagMergeBehavior = DirectiveMergeBehavior.Ignore + }, + Preprocessor = new CompositionSettings.PreprocessorSettings + { + ExcludeByTag = ["internal"] + } + }; + + // act + var compositionSettings = AspireCompositionHelper.CreateCompositionSettings( + settings, + stageSettings); + + // assert + SerializeSettings(compositionSettings) + .MatchInlineSnapshot( + """ + { + "preprocessor": { + "excludeByTag": [ + "internal" + ] + }, + "merger": { + "addFusionDefinitions": null, + "cacheControlMergeBehavior": "IncludePrivate", + "enableGlobalObjectIdentification": true, + "nodeResolution": "SourceSchema", + "removeUnreferencedDefinitions": true, + "tagMergeBehavior": "Include" + }, + "satisfiability": { + "includeSatisfiabilityPaths": null + }, + "apolloFederationCompatibility": { + "allowNonResolvableInterfaceObjects": null, + "shareableFieldRuntimeTypeRouting": null + } + } + """); + } + + [Fact] + public void CreateCompositionSettings_Should_KeepSettings_When_StageSettingsDeclareThemToo() + { + // arrange + var settings = new GraphQLCompositionSettings + { + CacheControlMergeBehavior = DirectiveMergeBehavior.Ignore, + EnableGlobalObjectIdentification = false, + ExcludeByTag = new HashSet { "local" }, + NodeResolution = NodeResolution.Gateway + }; + var stageSettings = new CompositionSettings + { + Merger = new CompositionSettings.MergerSettings + { + CacheControlMergeBehavior = DirectiveMergeBehavior.IncludePrivate, + EnableGlobalObjectIdentification = true, + NodeResolution = NodeResolution.SourceSchema + }, + Preprocessor = new CompositionSettings.PreprocessorSettings + { + ExcludeByTag = ["stage"] + } + }; + + // act + var compositionSettings = AspireCompositionHelper.CreateCompositionSettings( + settings, + stageSettings); + + // assert + SerializeSettings(compositionSettings) + .MatchInlineSnapshot( + """ + { + "preprocessor": { + "excludeByTag": [ + "local" + ] + }, + "merger": { + "addFusionDefinitions": null, + "cacheControlMergeBehavior": "Ignore", + "enableGlobalObjectIdentification": false, + "nodeResolution": "Gateway", + "removeUnreferencedDefinitions": null, + "tagMergeBehavior": null + }, + "satisfiability": { + "includeSatisfiabilityPaths": null + }, + "apolloFederationCompatibility": { + "allowNonResolvableInterfaceObjects": null, + "shareableFieldRuntimeTypeRouting": null + } + } + """); + } + [Fact] public void BuildLocalUrlOverrides_Should_UseConfiguredPath_When_SettingsDefineHttpUrl() { @@ -316,6 +591,7 @@ public async Task TryComposeAsync_Should_ComposeLocalUrlAndDevUrl_When_SchemasAr } } """); + var products = CreateSourceSchema( "Products", "http://localhost:5001", @@ -507,6 +783,113 @@ type Product @key(fields: "id") @extends { } } + private static string SerializeSettings(CompositionSettings settings) + { + using var document = JsonSerializer.SerializeToDocument( + settings, + SettingsJsonSerializerContext.Default.CompositionSettings); + + return JsonSerializer.Serialize( + document.RootElement, + new JsonSerializerOptions { WriteIndented = true }); + } + + private static async Task CreateSourceArchiveAsync(string archivePath) + { + using var settings = CreateSourceSettings(); + using var archive = FusionSourceSchemaArchive.Create(archivePath); + await archive.SetArchiveMetadataAsync( + new HotChocolate.Fusion.SourceSchema.Packaging.ArchiveMetadata(), + TestContext.Current.CancellationToken); + await archive.SetSchemaAsync( + Encoding.UTF8.GetBytes( + """ + type Query { + product: Product + } + + type Product { + id: ID! + name: String! + } + """), + TestContext.Current.CancellationToken); + await archive.SetSettingsAsync( + settings, + TestContext.Current.CancellationToken); + await archive.CommitAsync(TestContext.Current.CancellationToken); + } + + private static JsonDocument CreateSourceSettings() + => JsonDocument.Parse( + """ + { + "name": "Products", + "transports": { + "http": { + "url": "{{BASE_URL}}/graphql", + "capabilities": { + "subscriptions": { + "supported": "{{SUBSCRIPTIONS_ENABLED}}" + } + } + } + }, + "extensions": { + "timeout": "{{TIMEOUT}}", + "label": "{{ENVIRONMENT}}-{{COLOR}}" + }, + "environments": { + "Staging": { + "BASE_URL": "https://staging.products.example.com", + "SUBSCRIPTIONS_ENABLED": true, + "TIMEOUT": 5000, + "ENVIRONMENT": "staging", + "COLOR": "green" + }, + "Production": { + "BASE_URL": "https://products.example.com", + "SUBSCRIPTIONS_ENABLED": false, + "TIMEOUT": 10000, + "ENVIRONMENT": "production", + "COLOR": "blue" + } + } + } + """); + + private static async Task ReadGatewaySettingsAsync( + string fusionArchivePath) + { + using var archive = FusionArchive.Open(fusionArchivePath); + using var configuration = await archive.TryGetGatewayConfigurationAsync( + new Version(99, 0), + TestContext.Current.CancellationToken); + Assert.NotNull(configuration); + return JsonSerializer.Serialize( + configuration.Settings.RootElement, + new JsonSerializerOptions { WriteIndented = true }); + } + + private sealed class TestDirectory : IDisposable + { + public TestDirectory() + { + Path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + "fusion-aspire-tests", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + Directory.Delete(Path, recursive: true); + } + } + [Fact] public async Task TryComposeAsync_Should_KeepTheEnvironmentsOfASourceSchema_When_ItIsStored() { @@ -535,6 +918,7 @@ public async Task TryComposeAsync_Should_KeepTheEnvironmentsOfASourceSchema_When } } """); + var products = CreateSourceSchema( "Products", allocatedHttpEndpointUrl: null, @@ -626,7 +1010,7 @@ await AspireCompositionHelper.TryComposeAsync( using (var seedArchive = FusionArchive.Create(seedArchivePath)) { await seedArchive.SetArchiveMetadataAsync( - new ArchiveMetadata + new HotChocolate.Fusion.Packaging.ArchiveMetadata { SupportedGatewayFormats = [WellKnownVersions.LatestGatewayFormatVersion], SourceSchemas = [] diff --git a/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/CommandLineSchemaExporterTests.cs b/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/CommandLineSchemaExporterTests.cs new file mode 100644 index 00000000000..b3f90857b69 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/CommandLineSchemaExporterTests.cs @@ -0,0 +1,337 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace HotChocolate.Fusion.Aspire; + +public sealed class CommandLineSchemaExporterTests +{ + [Fact] + public void CreateStartInfo_Should_UseExplicitArgumentSafeBuildInputs() + { + var projectPath = GetTestProjectFile(); + var schemaPath = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + "schema export", + "schema.graphqls"); + + var startInfo = CommandLineSchemaExporter.CreateStartInfo( + projectPath, + schemaPath, + "Products Schema", + "Release", + "net9.0", + "linux-x64"); + + string.Join(Environment.NewLine, startInfo.ArgumentList).MatchInlineSnapshot( + $$""" + run + --project + {{projectPath}} + --configuration + Release + --framework + net9.0 + --runtime + linux-x64 + --no-launch-profile + -- + schema + export + --output + {{schemaPath}} + --schema-name + Products Schema + """); + } + + [Fact] + public void CreateStartInfo_Should_OmitRuntimeArgument_When_ExportIsExplicitlyPortable() + { + var startInfo = CommandLineSchemaExporter.CreateStartInfo( + GetTestProjectFile(), + System.IO.Path.Combine(System.IO.Path.GetTempPath(), "schema.graphqls"), + "Products", + "Release", + "net9.0", + runtimeIdentifier: null); + + Assert.Equal( + [ + "run", + "--project", + GetTestProjectFile(), + "--configuration", + "Release", + "--framework", + "net9.0", + "--no-launch-profile", + "--", + "schema", + "export", + "--output", + System.IO.Path.Combine(System.IO.Path.GetTempPath(), "schema.graphqls"), + "--schema-name", + "Products" + ], + startInfo.ArgumentList); + } + + [Fact] + public void ApplyEnvironmentAllowList_Should_RemoveSecretsAndKeepOsBootstrapVariables() + { + var startInfo = new ProcessStartInfo(); + startInfo.Environment.Clear(); + startInfo.Environment["PATH"] = "dotnet-path"; + startInfo.Environment["DOTNET_ROOT"] = "dotnet-root"; + startInfo.Environment["NUGET_PACKAGES"] = "packages"; + startInfo.Environment["NITRO_API_KEY"] = "secret"; + + if (OperatingSystem.IsWindows()) + { + startInfo.Environment["SystemRoot"] = "system-root"; + startInfo.Environment["USERPROFILE"] = "user-profile"; + } + else + { + startInfo.Environment["HOME"] = "user-home"; + startInfo.Environment["path"] = "wrong-case-path"; + } + + CommandLineSchemaExporter.ApplyEnvironmentAllowList(startInfo); + + var expected = OperatingSystem.IsWindows() + ? new Dictionary + { + ["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1", + ["DOTNET_NOLOGO"] = "1", + ["DOTNET_ROOT"] = "dotnet-root", + ["NUGET_PACKAGES"] = "packages", + ["PATH"] = "dotnet-path", + ["SystemRoot"] = "system-root", + ["USERPROFILE"] = "user-profile" + } + : new Dictionary + { + ["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1", + ["DOTNET_NOLOGO"] = "1", + ["DOTNET_ROOT"] = "dotnet-root", + ["HOME"] = "user-home", + ["NUGET_PACKAGES"] = "packages", + ["PATH"] = "dotnet-path" + }; + + Assert.Equal( + expected.OrderBy(t => t.Key), + startInfo.Environment.OrderBy(t => t.Key)); + } + + [Fact] + public async Task RunProcessAsync_Should_DrainPipesAndBoundRetainedOutput_When_OutputIsLarge() + { + var startInfo = CreateLargeOutputProcess(); + + var result = await CommandLineSchemaExporter.RunProcessAsync( + startInfo, + TimeSpan.FromSeconds(30), + "products", + TestContext.Current.CancellationToken); + + $$""" + Exit code: {{result.ExitCode}} + Standard output length: {{result.StandardOutput.Length}} + Standard output truncated: {{result.StandardOutputWasTruncated}} + Standard error characters: {{result.StandardErrorCharacterCount > 32 * 1024}} + Secret retained: {{result.ToString().Contains("stderr-secret", StringComparison.Ordinal)}} + """.MatchInlineSnapshot( + """ + Exit code: 7 + Standard output length: 32768 + Standard output truncated: True + Standard error characters: True + Secret retained: False + """); + } + + [Fact] + public async Task RunProcessAsync_Should_KillProcessTreeAndPreserveToken_When_Canceled() + { + var startInfo = CreateLongRunningProcess(); + using var cancellation = new CancellationTokenSource(TimeSpan.FromMilliseconds(500)); + var timer = Stopwatch.StartNew(); + + var exception = await Assert.ThrowsAnyAsync( + () => CommandLineSchemaExporter.RunProcessAsync( + startInfo, + TimeSpan.FromMinutes(1), + "products", + cancellation.Token)); + + Assert.Equal(cancellation.Token, exception.CancellationToken); + Assert.True( + timer.Elapsed < TimeSpan.FromSeconds(10), + $"Cancellation cleanup took {timer.Elapsed}."); + } + + [Fact] + public async Task ValidateArtifactsAsync_Should_AcceptExactNameAndValidSchema() + { + using var directory = new TemporaryDirectory(); + var schemaPath = directory.WriteFile( + "schema.graphqls", + "type Query { product: String }"); + var settingsPath = directory.WriteFile( + "schema-settings.json", + """{ "name": "Products" }"""); + + await CommandLineSchemaExporter.ValidateArtifactsAsync( + "products", + "Products", + schemaPath, + settingsPath, + TestContext.Current.CancellationToken); + } + + [Fact] + public async Task ValidateArtifactsAsync_Should_RejectNameMismatch() + { + using var directory = new TemporaryDirectory(); + var schemaPath = directory.WriteFile( + "schema.graphqls", + "type Query { product: String }"); + var settingsPath = directory.WriteFile( + "schema-settings.json", + """{ "name": "Inventory" }"""); + + var exception = await Assert.ThrowsAsync( + () => CommandLineSchemaExporter.ValidateArtifactsAsync( + "products", + "Products", + schemaPath, + settingsPath, + TestContext.Current.CancellationToken)); + + Assert.Equal( + "The configured source schema name 'Products' for resource 'products' " + + "does not match schema-settings.json name 'Inventory'.", + exception.Message); + } + + [Theory] + [InlineData(" ", "Schema export for resource 'products' produced empty GraphQL SDL.")] + [InlineData( + "type Query {", + "Schema for resource 'products' is not valid GraphQL SDL.")] + public async Task ValidateArtifactsAsync_Should_RejectInvalidSchema( + string schema, + string expectedMessage) + { + using var directory = new TemporaryDirectory(); + var schemaPath = directory.WriteFile("schema.graphqls", schema); + var settingsPath = directory.WriteFile( + "schema-settings.json", + """{ "name": "Products" }"""); + + var exception = await Assert.ThrowsAsync( + () => CommandLineSchemaExporter.ValidateArtifactsAsync( + "products", + "Products", + schemaPath, + settingsPath, + TestContext.Current.CancellationToken)); + + Assert.Equal(expectedMessage, exception.Message); + } + + private static ProcessStartInfo CreateLargeOutputProcess() + { + if (OperatingSystem.IsWindows()) + { + return CreateProcess( + "cmd.exe", + "/D", + "/S", + "/C", + "(for /L %i in (1,1,4000) do @echo 01234567890123456789)" + + "&(for /L %i in (1,1,4000) do @echo stderr-secret-0123456789 1>&2)" + + "&exit /b 7"); + } + + return CreateProcess( + "/bin/sh", + "-c", + "i=0; while [ $i -lt 4000 ]; do " + + "printf '01234567890123456789\\n'; " + + "printf 'stderr-secret-0123456789\\n' >&2; " + + "i=$((i + 1)); done; exit 7"); + } + + private static ProcessStartInfo CreateLongRunningProcess() + { + if (OperatingSystem.IsWindows()) + { + return CreateProcess( + "cmd.exe", + "/D", + "/S", + "/C", + "ping -t 127.0.0.1 >nul"); + } + + return CreateProcess( + "/bin/sh", + "-c", + "while true; do sleep 1; done"); + } + + private static ProcessStartInfo CreateProcess( + string fileName, + params string[] arguments) + { + var startInfo = new ProcessStartInfo + { + FileName = fileName, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + return startInfo; + } + + private static string GetTestProjectFile([CallerFilePath] string sourceFile = "") + => System.IO.Path.GetFullPath( + System.IO.Path.Combine( + System.IO.Path.GetDirectoryName(sourceFile)!, + "HotChocolate.Fusion.Aspire.Tests.csproj")); + + private sealed class TemporaryDirectory : IDisposable + { + private readonly string _path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + "fusion-aspire-tests", + Guid.NewGuid().ToString("N")); + + public TemporaryDirectory() + { + Directory.CreateDirectory(_path); + } + + public string WriteFile(string fileName, string content) + { + var path = System.IO.Path.Combine(_path, fileName); + File.WriteAllText(path, content); + return path; + } + + public void Dispose() + { + Directory.Delete(_path, recursive: true); + } + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/FusionPipelineTests.cs b/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/FusionPipelineTests.cs new file mode 100644 index 00000000000..912767c8cd6 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/FusionPipelineTests.cs @@ -0,0 +1,729 @@ +using System.Net; +using System.Text.Json; +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Pipelines; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using IOPath = System.IO.Path; + +namespace HotChocolate.Fusion.Aspire; + +#pragma warning disable ASPIREPIPELINES001 + +public sealed class FusionPipelineTests +{ + [Fact] + public async Task SelectStages_Should_SelectTheStage_That_TheParameterNames() + { + // arrange + var builder = DistributedApplication.CreateBuilder(); + var stage = builder.AddParameter("stage", "production"); + var nitro = builder + .AddNitroPublishTarget("nitro") + .WithNitroCloudUrl("https://api.chillicream.com") + .WithNitroApiId("products") + .WithStageParameter(stage) + .WithConfigurationTag("release-1"); + nitro.AddStage("production"); + nitro.AddStage("staging"); + var model = new DistributedApplicationModel(builder.Resources); + + // act + var stages = await FusionPipeline.SelectStagesAsync( + model, + TestContext.Current.CancellationToken); + + // assert + Assert.Equal(["production"], stages.Select(x => x.StageName)); + } + + [Fact] + public async Task SelectStages_Should_Fail_When_TheStageIsNotDeclared() + { + // arrange + var builder = DistributedApplication.CreateBuilder(); + var stage = builder.AddParameter("stage", "prod"); + var nitro = builder + .AddNitroPublishTarget("nitro") + .WithNitroCloudUrl("https://api.chillicream.com") + .WithNitroApiId("products") + .WithStageParameter(stage) + .WithConfigurationTag("release-1"); + nitro.AddStage("production"); + nitro.AddStage("staging"); + var model = new DistributedApplicationModel(builder.Resources); + + // act + var exception = await Assert.ThrowsAsync( + () => FusionPipeline.SelectStagesAsync( + model, + TestContext.Current.CancellationToken)); + + // assert + Assert.Equal( + "Nitro target 'nitro' does not declare the stage 'prod'. " + + "Declared stages: production, staging.", + exception.Message); + } + + [Fact] + public void SelectTargets_Should_Fail_When_NoStageParameterIsConfigured() + { + // arrange + var builder = DistributedApplication.CreateBuilder(); + builder + .AddNitroPublishTarget("nitro") + .WithNitroCloudUrl("https://api.chillicream.com") + .WithNitroApiId("products") + .WithConfigurationTag("release-1") + .AddStage("production"); + var model = new DistributedApplicationModel(builder.Resources); + + // act + var exception = Assert.Throws( + () => FusionPipeline.SelectTargets(model)); + + // assert + Assert.Equal( + "Nitro target 'nitro' must specify the parameter that selects the stage.", + exception.Message); + } + + [Fact] + public void WithCloudUrl_Should_Fail_WhenUrlContainsCaseSensitivePath() + { + var builder = DistributedApplication.CreateBuilder(); + + var exception = Assert.Throws( + () => builder + .AddNitroPublishTarget("nitro") + .WithNitroCloudUrl( + "https://api.chillicream.com/CaseSensitivePath")); + + Assert.Equal( + "The Nitro cloud URL must be an absolute HTTPS origin without " + + "a path, query, fragment, or user information. (Parameter 'cloudUrl')", + exception.Message); + } + + [Fact] + public void GetSourceNames_Should_Fail_WhenEffectiveNamesAreDuplicated() + { + using var testDirectory = new TestDirectory(); + var productsProject = IOPath.Combine( + testDirectory.Path, + "Products.csproj"); + var reviewsProject = IOPath.Combine( + testDirectory.Path, + "Reviews.csproj"); + var gatewayProject = IOPath.Combine( + testDirectory.Path, + "Gateway.csproj"); + File.WriteAllText(productsProject, ""); + File.WriteAllText(reviewsProject, ""); + File.WriteAllText(gatewayProject, ""); + var builder = DistributedApplication.CreateBuilder(); + var products = builder + .AddProject("products", productsProject) + .WithGraphQLSchemaFile(sourceSchemaName: "shared"); + var reviews = builder + .AddProject("reviews", reviewsProject) + .WithGraphQLSchemaFile(sourceSchemaName: "shared"); + builder + .AddProject("gateway", gatewayProject) + .WithReference(products) + .WithReference(reviews) + .WithGraphQLSchemaComposition(); + var model = new DistributedApplicationModel(builder.Resources); + + var exception = Assert.Throws( + () => FusionPipelineExecutor.GetSourceNames(model)); + + Assert.Equal( + "Multiple provider resources map to Fusion source 'shared'.", + exception.Message); + } + + [Fact] + public void CreateSteps_Should_WireArtifactAndRemoteRoots() + { + // arrange + var resource = new FusionPipelineResource("fusion-pipeline"); + + // act + var steps = FusionPipeline.CreateSteps( + CreatePipelineStepFactoryContext(resource), + new FusionPipelineTopology()); + + // assert + string.Join( + Environment.NewLine, + steps.Select(step => + $"{step.Name}: depends=[{string.Join(", ", step.DependsOnSteps)}]; " + + $"requiredBy=[{string.Join(", ", step.RequiredBySteps)}]")) + .MatchInlineSnapshot( + """ + fusion-artifacts: depends=[]; requiredBy=[publish] + fusion-upload: depends=[fusion-artifacts]; requiredBy=[] + fusion-download: depends=[]; requiredBy=[] + fusion-compose: depends=[fusion-download]; requiredBy=[] + fusion-readiness: depends=[fusion-compose]; requiredBy=[] + fusion-publish-stage: depends=[fusion-readiness]; requiredBy=[] + fusion-publish: depends=[fusion-publish-stage]; requiredBy=[deploy] + """); + } + + [Fact] + public void CreateSteps_Should_IsolatePublishFromUploadSteps() + { + // arrange + var resource = new FusionPipelineResource("fusion-pipeline"); + + // act + var steps = FusionPipeline.CreateSteps( + CreatePipelineStepFactoryContext(resource), + new FusionPipelineTopology()); + + // assert + var stepsByName = steps.ToDictionary( + step => step.Name, + StringComparer.Ordinal); + string.Join( + Environment.NewLine, + GetTransitiveDependencies( + stepsByName, + FusionPipeline.PublishStepName) + .Order(StringComparer.Ordinal)) + .MatchInlineSnapshot( + """ + fusion-compose + fusion-download + fusion-publish-stage + fusion-readiness + """); + } + + [Fact] + public void WireGatewayDeployment_Should_PublishStageBeforeGatewayStarts() + { + var resource = new FusionPipelineResource("fusion-pipeline"); + var stagePublication = CreatePipelineStep( + FusionPipeline.PublishStageStepName, + resource, + "fusion"); + var gatewayDeployment = CreatePipelineStep( + "deploy-gateway", + resource, + WellKnownPipelineTags.DeployCompute); + var publication = CreatePipelineStep( + FusionPipeline.PublishStepName, + resource, + "fusion"); + publication.DependsOn(stagePublication); + + FusionPipeline.WireGatewayDeployment( + stagePublication, + publication, + [gatewayDeployment]); + + string.Join( + Environment.NewLine, + new[] { stagePublication, gatewayDeployment, publication } + .Select(step => + $"{step.Name}: depends=[{string.Join(", ", step.DependsOnSteps)}]")) + .MatchInlineSnapshot( + """ + fusion-publish-stage: depends=[] + deploy-gateway: depends=[fusion-publish-stage] + fusion-publish: depends=[fusion-publish-stage, deploy-gateway] + """); + } + + [Fact] + public void SelectResourceDeploymentSteps_Should_PreferDirectDeployComputeSteps() + { + var source = new FusionPipelineResource("products"); + var steps = new[] + { + CreatePipelineStep( + "deploy-products", + source, + WellKnownPipelineTags.DeployCompute), + CreatePipelineStep( + "provision-products", + source, + WellKnownPipelineTags.ProvisionInfrastructure) + }; + + string.Join( + Environment.NewLine, + FusionPipeline + .SelectResourceDeploymentSteps( + CreatePipelineConfigurationContext(steps), + source) + .Select(step => step.Name)) + .MatchInlineSnapshot( + """ + deploy-products + """); + } + + [Fact] + public void SelectResourceDeploymentSteps_Should_UseDeploymentTargetDeployComputeSteps() + { + var source = new FusionPipelineResource("products"); + var deploymentTarget = new FusionPipelineResource( + "products-containerapp"); + source.Annotations.Add( + new DeploymentTargetAnnotation(deploymentTarget)); + var steps = new[] + { + CreatePipelineStep( + "build-products", + source, + WellKnownPipelineTags.BuildCompute), + CreatePipelineStep( + "deploy-products", + deploymentTarget, + WellKnownPipelineTags.DeployCompute), + CreatePipelineStep( + "provision-products-containerapp", + deploymentTarget, + WellKnownPipelineTags.ProvisionInfrastructure) + }; + + string.Join( + Environment.NewLine, + FusionPipeline + .SelectResourceDeploymentSteps( + CreatePipelineConfigurationContext(steps), + source) + .Select(step => step.Name)) + .MatchInlineSnapshot( + """ + deploy-products + """); + } + + [Fact] + public void SelectResourceDeploymentSteps_Should_NotUseProvisionInfrastructureAsTerminal() + { + var source = new FusionPipelineResource("products"); + var deploymentTarget = new FusionPipelineResource( + "products-containerapp"); + source.Annotations.Add( + new DeploymentTargetAnnotation(deploymentTarget)); + var steps = new[] + { + CreatePipelineStep( + "build-products", + source, + WellKnownPipelineTags.BuildCompute), + CreatePipelineStep( + "provision-products-containerapp", + deploymentTarget, + WellKnownPipelineTags.ProvisionInfrastructure) + }; + + string.Join( + Environment.NewLine, + FusionPipeline + .SelectResourceDeploymentSteps( + CreatePipelineConfigurationContext(steps), + source) + .Select(step => step.Name)) + .MatchInlineSnapshot( + ""); + } + + [Fact] + public void EnsureResourceDeploymentOrdering_Should_Fail_WhenExternalResourceHasNoDeploymentStep() + { + var exception = Assert.Throws( + () => FusionPipeline.EnsureResourceDeploymentOrdering( + ["external-products"])); + + Assert.Equal( + "Fusion publication cannot prove compute deployment ordering for resources: " + + "external-products", + exception.Message); + } + + [Fact] + public void ResolveSourceSchemaSettings_Should_UseDifferentEnvironmentOverrides_WhenSourceIsShared() + { + using var sourceSettings = JsonDocument.Parse( + """ + { + "name": "products", + "transports": { + "http": { + "url": "{{PRODUCTS_URL}}/graphql" + } + }, + "environments": { + "staging": { + "PRODUCTS_URL": "https://products.staging.example.com" + }, + "production": { + "PRODUCTS_URL": "https://products.example.com" + } + } + } + """); + + using var staging = + FusionPipelineExecutor.ResolveSourceSchemaSettings( + sourceSettings, + "staging"); + using var production = + FusionPipelineExecutor.ResolveSourceSchemaSettings( + sourceSettings, + "production"); + + string.Join( + Environment.NewLine, + "source: products@release-1", + $"staging: {GetHttpUrl(staging)}", + $"production: {GetHttpUrl(production)}") + .MatchInlineSnapshot( + """ + source: products@release-1 + staging: https://products.staging.example.com/graphql + production: https://products.example.com/graphql + """); + } + + [Fact] + public void ResolveCompositionEnvironment_Should_UseStage_WhenNoOverrideExists() + { + var deployment = new FusionStageResource( + "nitro-production", + "production", + new NitroPublishTargetResource("nitro")); + + var environment = + FusionPipelineExecutor.ResolveCompositionEnvironment( + deployment, + new GraphQLCompositionSettings()); + + Assert.Equal("production", environment); + } + + [Fact] + public async Task VerifyFileDigestAsync_Should_Fail_WhenComposedArchiveChanges() + { + using var testDirectory = new TestDirectory(); + var farPath = IOPath.Combine( + testDirectory.Path, + "fusion-configuration.far"); + await File.WriteAllTextAsync( + farPath, + "tampered", + TestContext.Current.CancellationToken); + + var exception = await Assert.ThrowsAsync( + () => FusionPipelineExecutor.VerifyFileDigestAsync( + farPath, + new string('0', 64), + "composed Fusion archive", + TestContext.Current.CancellationToken)); + + Assert.Equal( + "The composed Fusion archive SHA-256 does not match prepared state.", + exception.Message); + } + + [Fact] + public void TransferComposition_Should_ClearArchive_When_CanceledBeforeTransfer() + { + // arrange + using var state = new FusionDeploymentSessionState( + "release-1", + "https://api.chillicream.com", + "products", + []); + byte[] fusionArchive = [1, 2, 3]; + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + // act + Assert.Throws( + () => FusionPipelineExecutor.Instance.TransferComposition( + state, + "development", + fusionArchive, + cancellation.Token)); + + // assert + Assert.Equal(new byte[3], fusionArchive); + Assert.Throws(() => state.FusionArchive); + } + + [Fact] + public void GetTransportEndpoint_Should_Fail_WhenProductionBindingIsMissing() + { + using var settings = JsonDocument.Parse( + """ + { + "name": "products" + } + """); + + var exception = Assert.Throws( + () => FusionPipelineExecutor.GetTransportEndpoint(settings)); + + Assert.Equal( + "Fusion deployment settings must specify an absolute production " + + "transports.http.url.", + exception.Message); + } + + [Fact] + public async Task WaitForReadiness_Should_Retry_WhenEndpointIsTransientlyUnavailable() + { + using var handler = new StubHttpMessageHandler( + (attempt, _, _) => Task.FromResult( + new HttpResponseMessage( + attempt == 1 + ? HttpStatusCode.ServiceUnavailable + : HttpStatusCode.NoContent))); + using var httpClient = new HttpClient(handler); + + await FusionPipelineExecutor.WaitForReadinessAsync( + httpClient, + "products", + new Uri("https://products.example.com/graphql"), + TimeSpan.FromSeconds(1), + TimeSpan.Zero, + CancellationToken.None); + + Assert.Equal(2, handler.Attempts); + } + + [Fact] + public async Task WaitForReadiness_Should_FailWithContext_WhenDeadlineExpires() + { + using var handler = new StubHttpMessageHandler( + async (_, _, cancellationToken) => + { + await Task.Delay( + Timeout.InfiniteTimeSpan, + cancellationToken); + return new HttpResponseMessage(HttpStatusCode.NoContent); + }); + using var httpClient = new HttpClient(handler); + + var exception = await Assert.ThrowsAsync( + () => FusionPipelineExecutor.WaitForReadinessAsync( + httpClient, + "products", + new Uri("https://products.example.com/graphql"), + TimeSpan.FromMilliseconds(50), + TimeSpan.Zero, + CancellationToken.None)); + + Assert.Equal( + "Fusion source 'products' at 'https://products.example.com/graphql' " + + "did not pass its production readiness check within 00:00:00.0500000.", + exception.Message); + } + + [Fact] + public void ReplaceDirectoryAtomically_Should_RemoveSource_WhenSourceWasRemoved() + { + using var testDirectory = new TestDirectory(); + var destination = IOPath.Combine(testDirectory.Path, "production"); + var replacement = IOPath.Combine(testDirectory.Path, "replacement"); + WriteArtifactFile(destination, "sources/products/schema.graphqls"); + WriteArtifactFile(destination, "sources/reviews/schema.graphqls"); + WriteArtifactFile(destination, "nitro-deployment.json"); + WriteArtifactFile(replacement, "sources/products/schema.graphqls"); + WriteArtifactFile(replacement, "nitro-deployment-template.json"); + + FusionPipelineExecutor.ReplaceDirectoryAtomically( + replacement, + destination); + + GetArtifactFiles(destination) + .MatchInlineSnapshot( + """ + nitro-deployment-template.json + sources/products/schema.graphqls + """); + } + + [Fact] + public void ReplaceDirectoryAtomically_Should_RemoveExtensions_WhenExtensionsWereRemoved() + { + using var testDirectory = new TestDirectory(); + var destination = IOPath.Combine(testDirectory.Path, "production"); + var replacement = IOPath.Combine(testDirectory.Path, "replacement"); + WriteArtifactFile(destination, "sources/products/schema.graphqls"); + WriteArtifactFile( + destination, + "sources/products/schema-extensions.graphqls"); + WriteArtifactFile(replacement, "sources/products/schema.graphqls"); + + FusionPipelineExecutor.ReplaceDirectoryAtomically( + replacement, + destination); + + GetArtifactFiles(destination) + .MatchInlineSnapshot("sources/products/schema.graphqls"); + } + + [Theory] + [InlineData("release/1")] + [InlineData(@"release\1")] + [InlineData("release:1")] + [InlineData("..")] + public void ValidatePathSegment_Should_Fail_WhenValueIsNotPortable( + string value) + { + var exception = Assert.Throws( + () => FusionPipelineExecutor.ValidatePathSegment( + value, + "configuration tag")); + + Assert.Equal( + $"Fusion configuration tag '{value}' cannot be used as a portable path segment.", + exception.Message); + } + + private static void WriteArtifactFile( + string root, + string relativePath) + { + var path = IOPath.Combine( + root, + relativePath.Replace('/', IOPath.DirectorySeparatorChar)); + Directory.CreateDirectory(IOPath.GetDirectoryName(path)!); + File.WriteAllText(path, "artifact"); + } + + private static string GetArtifactFiles(string root) + => string.Join( + Environment.NewLine, + Directory + .EnumerateFiles(root, "*", SearchOption.AllDirectories) + .Select(path => IOPath.GetRelativePath(root, path).Replace('\\', '/')) + .Order()); + + private static HashSet GetTransitiveDependencies( + IReadOnlyDictionary steps, + string stepName) + { + var dependencies = new HashSet(StringComparer.Ordinal); + + Visit(stepName); + return dependencies; + + void Visit(string current) + { + foreach (var dependency in steps[current].DependsOnSteps) + { + if (dependencies.Add(dependency)) + { + Visit(dependency); + } + } + } + } + + private static PipelineStep CreatePipelineStep( + string name, + IResource resource, + string tag) + => new() + { + Name = name, + Description = name, + Resource = resource, + Tags = [tag], + Action = _ => Task.CompletedTask + }; + + private static PipelineStepFactoryContext CreatePipelineStepFactoryContext( + IResource resource) + { + var builder = DistributedApplication.CreateBuilder(); + var services = builder.Services.BuildServiceProvider(); + + return new PipelineStepFactoryContext + { + PipelineContext = new PipelineContext( + new DistributedApplicationModel(builder.Resources), + new DistributedApplicationExecutionContext( + DistributedApplicationOperation.Publish), + services, + NullLogger.Instance, + TestContext.Current.CancellationToken), + Resource = resource + }; + } + + private static PipelineConfigurationContext + CreatePipelineConfigurationContext( + IReadOnlyList steps) + { + var builder = DistributedApplication.CreateBuilder(); + + return new PipelineConfigurationContext + { + Services = builder.Services.BuildServiceProvider(), + Steps = steps, + Model = new DistributedApplicationModel(builder.Resources) + }; + } + + private static string GetHttpUrl(JsonDocument settings) + => settings.RootElement + .GetProperty("transports") + .GetProperty("http") + .GetProperty("url") + .GetString()!; + + private sealed class TestDirectory : IDisposable + { + public TestDirectory() + { + Path = IOPath.Combine( + IOPath.GetTempPath(), + "chilicream-nitro-aspire-tests", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + Directory.Delete(Path, recursive: true); + } + } + + private sealed class StubHttpMessageHandler( + Func< + int, + HttpRequestMessage, + CancellationToken, + Task> sendAsync) + : HttpMessageHandler + { + private int _attempts; + + public int Attempts => _attempts; + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + => sendAsync( + Interlocked.Increment(ref _attempts), + request, + cancellationToken); + } +} + +#pragma warning restore ASPIREPIPELINES001 diff --git a/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/FusionReleaseAcceptanceTests.cs b/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/FusionReleaseAcceptanceTests.cs new file mode 100644 index 00000000000..667d689cf3a --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/FusionReleaseAcceptanceTests.cs @@ -0,0 +1,1380 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Pipelines; +using HotChocolate.Fusion.Aspire.Nitro; +using HotChocolate.Fusion; +using HotChocolate.Fusion.Packaging; +using HotChocolate.Fusion.SourceSchema.Packaging; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using IOPath = System.IO.Path; + +namespace HotChocolate.Fusion.Aspire; + +#pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREPIPELINES004 + +public sealed class FusionReleaseAcceptanceTests +{ + [Fact] + public async Task Release_Should_UploadOnceAndPublishFromNitroAcrossRunners() + { + // arrange + using var testDirectory = new TestDirectory(); + var sourceCheckout = IOPath.Combine( + testDirectory.Path, + "runner-a-checkout"); + var runnerAOutput = IOPath.Combine( + testDirectory.Path, + "runner-a-output"); + var runnerB = IOPath.Combine(testDirectory.Path, "unrelated-runner-b"); + var runnerC = IOPath.Combine(testDirectory.Path, "unrelated-runner-c"); + Directory.CreateDirectory(sourceCheckout); + + var productsProjectPath = await CreateSourceCheckoutAsync( + sourceCheckout, + "Products", + "products", + "Product"); + var reviewsProjectPath = await CreateSourceCheckoutAsync( + sourceCheckout, + "Reviews", + "reviews", + "Review"); + var gatewayDirectory = IOPath.Combine(sourceCheckout, "Gateway"); + Directory.CreateDirectory(gatewayDirectory); + var gatewayProjectPath = IOPath.Combine( + gatewayDirectory, + "Gateway.csproj"); + await File.WriteAllTextAsync( + gatewayProjectPath, + "", + TestContext.Current.CancellationToken); + + using var nitro = new FakeNitro(); + var executor = FusionPipelineExecutor.Instance; + var buildModel = CreateModel( + productsProjectPath, + reviewsProjectPath, + gatewayProjectPath); + var buildContext = CreateContext( + buildModel, + "Development", + runnerAOutput, + nitro); + + // act + await executor.CreateArtifactsAsync(buildContext); + await executor.UploadAsync(buildContext); + + // assert + Assert.Equal( + [ + new FusionSourceSchemaVersion("products", "release-1"), + new FusionSourceSchemaVersion("reviews", "release-1") + ], + nitro.Uploads.OrderBy(source => source.Name, StringComparer.Ordinal)); + + var runnerBProjects = await CreateAppHostProjectStubsAsync(runnerB); + var runnerCProjects = await CreateAppHostProjectStubsAsync(runnerC); + var runnerBTree = SnapshotTree(runnerB); + var runnerCTree = SnapshotTree(runnerC); + Directory.Delete(sourceCheckout, recursive: true); + Assert.False(Directory.Exists(sourceCheckout)); + + var stagingModel = CreateModel( + runnerBProjects.ProductsProjectPath, + runnerBProjects.ReviewsProjectPath, + runnerBProjects.GatewayProjectPath); + var stagingContext = CreateContext( + stagingModel, + "Development", + outputPath: null, + nitro: nitro); + using var stagingSession = new FusionPipelineSession( + stagingContext.CancellationToken); + await executor.PreflightAsync(stagingContext, stagingSession); + + var productionContext = CreateContext( + CreateModel( + runnerCProjects.ProductsProjectPath, + runnerCProjects.ReviewsProjectPath, + runnerCProjects.GatewayProjectPath, + stageName: "test"), + "Test", + outputPath: null, + nitro: nitro); + using var productionSession = new FusionPipelineSession( + productionContext.CancellationToken); + await executor.PreflightAsync(productionContext, productionSession); + + Assert.Equal(1, stagingSession.DeploymentCount); + Assert.Equal(1, productionSession.DeploymentCount); + var stagingDeployment = Assert.Single( + await FusionPipeline.SelectStagesAsync( + stagingModel, + TestContext.Current.CancellationToken)); + var preflightState = stagingSession.GetState(stagingDeployment); + Assert.Equal(0, preflightState.SourceArchiveBytes); + Assert.Throws( + () => preflightState.Sources); + + await executor.DownloadAsync(stagingContext, stagingSession); + await executor.ComposeAsync(stagingContext, stagingSession); + await executor.DownloadAsync(productionContext, productionSession); + await executor.ComposeAsync(productionContext, productionSession); + await executor.PublishAsync(stagingContext, stagingSession); + + Assert.Equal(0, stagingSession.DeploymentCount); + Assert.Equal(1, productionSession.DeploymentCount); + + await executor.PublishAsync(productionContext, productionSession); + + Assert.Equal(0, productionSession.DeploymentCount); + + // every Fusion source is read back from Nitro once per reconciliation and + // once per preflight and composition of both deployments. + $""" + Uploads: {nitro.Uploads.Count} + Downloads: {DescribeDownloads(nitro)} + """.MatchInlineSnapshot( + """ + Uploads: 2 + Downloads: products@release-1 x5, reviews@release-1 x5 + """); + + var publications = nitro.Publications + .OrderBy(publication => publication.Stage, StringComparer.Ordinal) + .ToArray(); + Assert.Collection( + publications, + development => + { + Assert.Equal("development", development.Stage); + Assert.Equal( + new Dictionary + { + ["products"] = "https://products.development.example.com/graphql", + ["reviews"] = "https://reviews.development.example.com/graphql" + }, + ReadSourceUrls(development.FusionArchive)); + Assert.Equal( + [ + new FusionSourceSchemaVersion("products", "release-1"), + new FusionSourceSchemaVersion("reviews", "release-1") + ], + development.Sources); + }, + test => + { + Assert.Equal("test", test.Stage); + Assert.Equal( + new Dictionary + { + ["products"] = "https://products.test.example.com/graphql", + ["reviews"] = "https://reviews.test.example.com/graphql" + }, + ReadSourceUrls(test.FusionArchive)); + Assert.Equal( + [ + new FusionSourceSchemaVersion("products", "release-1"), + new FusionSourceSchemaVersion("reviews", "release-1") + ], + test.Sources); + }); + Assert.NotEqual( + Convert.ToBase64String(nitro.Publications[0].FusionArchive), + Convert.ToBase64String(nitro.Publications[1].FusionArchive)); + Assert.Equal(runnerBTree, SnapshotTree(runnerB)); + Assert.Equal(runnerCTree, SnapshotTree(runnerC)); + + using var repeatedStagingSession = new FusionPipelineSession( + stagingContext.CancellationToken); + await executor.PreflightAsync(stagingContext, repeatedStagingSession); + await executor.DownloadAsync(stagingContext, repeatedStagingSession); + await executor.ComposeAsync(stagingContext, repeatedStagingSession); + await executor.PublishAsync(stagingContext, repeatedStagingSession); + + Assert.Equal(0, repeatedStagingSession.DeploymentCount); + Assert.Equal(3, nitro.Publications.Count); + Assert.Equal( + Convert.ToBase64String(nitro.Publications[0].FusionArchive), + Convert.ToBase64String(nitro.Publications[2].FusionArchive)); + Assert.Equal(runnerBTree, SnapshotTree(runnerB)); + } + + [Fact] + public async Task Release_Should_PublishToTheStage_That_TheParameterSupplies() + { + // arrange + // one deployment declaration that is not bound to an environment publishes to the stage + // that each publish supplies, so the same AppHost serves every stage. + using var testDirectory = new TestDirectory(); + using var nitro = await CreateSeededNitroAsync(); + var executor = FusionPipelineExecutor.Instance; + var projects = await CreateAppHostProjectStubsAsync(testDirectory.Path); + + // act + (string Stage, string Environment)[] publishes = + [ + ("development", "Development"), + ("test", "Production") + ]; + + foreach (var (stage, environment) in publishes) + { + var context = CreateContext( + CreateModel(projects, stage), + environment, + outputPath: null, + nitro: nitro); + using var session = new FusionPipelineSession( + context.CancellationToken); + await executor.PreflightAsync(context, session); + await executor.DownloadAsync(context, session); + await executor.ComposeAsync(context, session); + await executor.PublishAsync(context, session); + } + + // assert + Assert.Equal( + ["development", "test"], + nitro.Publications.Select(publication => publication.Stage)); + Assert.All( + nitro.Publications, + publication => Assert.Equal( + [ + new FusionSourceSchemaVersion("products", "release-1"), + new FusionSourceSchemaVersion("reviews", "release-1") + ], + publication.Sources)); + } + + [Fact] + public async Task CreateArtifacts_Should_WriteAnOutputDirectoryThatUploadReadsBack() + { + // arrange + // the source checkout is deleted before the upload so the upload can only + // succeed by reading the publish output written by the artifacts step. + using var testDirectory = new TestDirectory(); + var sourceCheckout = IOPath.Combine(testDirectory.Path, "checkout"); + var output = IOPath.Combine(testDirectory.Path, "output"); + Directory.CreateDirectory(sourceCheckout); + var productsProjectPath = await CreateSourceCheckoutAsync( + sourceCheckout, + "Products", + "products", + "Product"); + var reviewsProjectPath = await CreateSourceCheckoutAsync( + sourceCheckout, + "Reviews", + "reviews", + "Review"); + var gatewayDirectory = IOPath.Combine(sourceCheckout, "Gateway"); + Directory.CreateDirectory(gatewayDirectory); + var gatewayProjectPath = IOPath.Combine( + gatewayDirectory, + "Gateway.csproj"); + await File.WriteAllTextAsync( + gatewayProjectPath, + "", + TestContext.Current.CancellationToken); + using var nitro = new FakeNitro(); + var executor = FusionPipelineExecutor.Instance; + var model = CreateModel( + productsProjectPath, + reviewsProjectPath, + gatewayProjectPath); + + // act + await executor.CreateArtifactsAsync( + CreateContext(model, "Development", output, nitro)); + var artifactTree = SnapshotTree(output); + Directory.Delete(sourceCheckout, recursive: true); + await executor.UploadAsync( + CreateContext(model, "Development", output, nitro)); + + // assert + NormalizeTree(artifactTree).MatchInlineSnapshot( + """ + fusion + fusion/nitro + fusion/nitro/nitro-deployment-template.json + fusion/nitro/sources + fusion/nitro/sources/products + fusion/nitro/sources/products/provenance.json + fusion/nitro/sources/products/schema-settings.template.json + fusion/nitro/sources/products/schema.graphqls + fusion/nitro/sources/reviews + fusion/nitro/sources/reviews/provenance.json + fusion/nitro/sources/reviews/schema-settings.template.json + fusion/nitro/sources/reviews/schema.graphqls + """); + NormalizeTree(SnapshotTree(output)).MatchInlineSnapshot( + """ + fusion + fusion/nitro + fusion/nitro/materialized + fusion/nitro/materialized/products-release-1.zip + fusion/nitro/materialized/reviews-release-1.zip + fusion/nitro/nitro-deployment-template.json + fusion/nitro/sources + fusion/nitro/sources/products + fusion/nitro/sources/products/provenance.json + fusion/nitro/sources/products/schema-settings.template.json + fusion/nitro/sources/products/schema.graphqls + fusion/nitro/sources/reviews + fusion/nitro/sources/reviews/provenance.json + fusion/nitro/sources/reviews/schema-settings.template.json + fusion/nitro/sources/reviews/schema.graphqls + """); + Assert.Equal( + [ + new FusionSourceSchemaVersion("products", "release-1"), + new FusionSourceSchemaVersion("reviews", "release-1") + ], + nitro.Uploads.OrderBy(source => source.Name, StringComparer.Ordinal)); + } + + [Fact] + public async Task Publish_Should_ClearSessionBuffers_WhenNitroFailsToOpenTheRequest() + { + // arrange + using var testDirectory = new TestDirectory(); + using var nitro = await CreateSeededNitroAsync(); + var context = CreateContext( + CreateModel( + await CreateAppHostProjectStubsAsync(testDirectory.Path)), + "Development", + outputPath: null, + nitro: nitro); + using var session = new FusionPipelineSession(context.CancellationToken); + var executor = FusionPipelineExecutor.Instance; + await executor.PreflightAsync(context, session); + await executor.DownloadAsync(context, session); + await executor.ComposeAsync(context, session); + var deployment = Assert.Single( + await FusionPipeline.SelectStagesAsync( + context.Model, + TestContext.Current.CancellationToken)); + var state = session.GetState(deployment); + var sourceBuffers = state.Sources + .Select(source => source.Archive) + .ToArray(); + var fusionArchiveBuffer = state.FusionArchive; + nitro.BeginException = new HttpRequestException("connection refused"); + + // act + var exception = + await Assert.ThrowsAsync( + () => executor.PublishAsync(context, session)); + + // assert + Assert.Equal( + "The Fusion publication request may have been created, but Nitro did " + + "not return a verifiable result.", + exception.Message); + Assert.Equal(0, session.DeploymentCount); + Assert.All( + sourceBuffers, + buffer => Assert.Equal(new byte[buffer.Length], buffer)); + Assert.Equal( + new byte[fusionArchiveBuffer.Length], + fusionArchiveBuffer); + } + + [Fact] + public async Task Publish_Should_ClearSessionBuffers_WhenThePipelineIsCanceled() + { + // arrange + using var testDirectory = new TestDirectory(); + using var nitro = await CreateSeededNitroAsync(); + using var publishCancellation = new CancellationTokenSource(); + var context = CreateContext( + CreateModel( + await CreateAppHostProjectStubsAsync(testDirectory.Path)), + "Development", + outputPath: null, + nitro: nitro, + cancellationToken: publishCancellation.Token); + using var session = new FusionPipelineSession(publishCancellation.Token); + var executor = FusionPipelineExecutor.Instance; + await executor.PreflightAsync(context, session); + await executor.DownloadAsync(context, session); + await executor.ComposeAsync(context, session); + var deployment = Assert.Single( + await FusionPipeline.SelectStagesAsync( + context.Model, + TestContext.Current.CancellationToken)); + var state = session.GetState(deployment); + var sourceBuffers = state.Sources + .Select(source => source.Archive) + .ToArray(); + var fusionArchiveBuffer = state.FusionArchive; + nitro.BlockNextPublication(); + + // act + var publish = executor.PublishAsync(context, session); + await nitro.WaitForBlockedPublicationAsync(); + await publishCancellation.CancelAsync(); + nitro.ContinueBlockedPublication(); + + // assert + await Assert.ThrowsAnyAsync(() => publish); + Assert.Empty(nitro.Publications); + Assert.Equal(0, session.DeploymentCount); + Assert.All( + sourceBuffers, + buffer => Assert.Equal(new byte[buffer.Length], buffer)); + Assert.Equal( + new byte[fusionArchiveBuffer.Length], + fusionArchiveBuffer); + } + + [Fact] + public async Task Download_Should_ClearSession_WhenExactSourceIsMissing() + { + // arrange + using var testDirectory = new TestDirectory(); + using var nitro = new FakeNitro(); + var context = CreateContext( + CreateModel( + await CreateAppHostProjectStubsAsync(testDirectory.Path)), + "Development", + outputPath: null, + nitro: nitro); + using var session = new FusionPipelineSession( + context.CancellationToken); + + // act + var exception = await Assert.ThrowsAsync( + () => FusionPipelineExecutor.Instance.PreflightAsync( + context, + session)); + + // assert + Assert.Equal( + "Fusion source 'products' version 'release-1' does not exist on target 'products'.", + exception.Message); + Assert.Equal(0, session.DeploymentCount); + Assert.Empty(nitro.Uploads); + Assert.Empty(nitro.Publications); + } + + [Fact] + public async Task Download_Should_RejectSourceArchive_WhenPerSourceLimitIsExceeded() + { + // arrange + using var testDirectory = new TestDirectory(); + using var nitro = await CreateSeededNitroAsync(); + var context = CreateContext( + CreateModel( + await CreateAppHostProjectStubsAsync(testDirectory.Path)), + "Development", + outputPath: null, + nitro: nitro); + var limits = new FusionPipelineMemoryLimits( + SourceArchiveBytes: 2, + TotalSourceArchiveBytes: 100); + var executor = new FusionPipelineExecutor(limits); + using var session = new FusionPipelineSession( + context.CancellationToken, + limits); + + // act + var exception = await Assert.ThrowsAsync( + () => executor.PreflightAsync(context, session)); + + // assert + Assert.Equal( + "Downloaded Fusion source 'products@release-1' exceeds the " + + "2-byte per-source in-memory size limit.", + exception.Message); + Assert.Equal(0, session.DeploymentCount); + } + + [Fact] + public async Task Download_Should_RejectSourceArchives_WhenAggregateLimitIsExceeded() + { + // arrange + using var testDirectory = new TestDirectory(); + using var nitro = await CreateSeededNitroAsync(); + var context = CreateContext( + CreateModel( + await CreateAppHostProjectStubsAsync(testDirectory.Path)), + "Development", + outputPath: null, + nitro: nitro); + var limits = new FusionPipelineMemoryLimits( + SourceArchiveBytes: 100_000, + TotalSourceArchiveBytes: 2); + var executor = new FusionPipelineExecutor(limits); + using var session = new FusionPipelineSession( + context.CancellationToken, + limits); + + // act + var exception = await Assert.ThrowsAsync( + () => executor.PreflightAsync(context, session)); + + // assert + Assert.Equal( + "The downloaded Fusion sources exceed the 2-byte aggregate " + + "in-memory size limit.", + exception.Message); + Assert.Equal(0, session.DeploymentCount); + } + + [Fact] + public async Task Download_Should_FailBeforeComposition_WhenExactSourceChangesAfterPreflight() + { + // arrange + using var testDirectory = new TestDirectory(); + using var nitro = await CreateSeededNitroAsync(); + var context = CreateContext( + CreateModel( + await CreateAppHostProjectStubsAsync(testDirectory.Path)), + "Development", + outputPath: null, + nitro: nitro); + using var session = new FusionPipelineSession( + context.CancellationToken); + var executor = FusionPipelineExecutor.Instance; + await executor.PreflightAsync(context, session); + nitro.Seed( + "products", + "release-1", + await CreateSourceArchiveAsync("products", "ChangedProduct")); + + // act + var exception = await Assert.ThrowsAsync( + () => executor.DownloadAsync(context, session)); + + // assert + Assert.Equal( + "Fusion source 'products@release-1' changed between preflight and composition.", + exception.Message); + Assert.Equal(0, session.DeploymentCount); + Assert.Empty(nitro.Publications); + } + + [Fact] + public async Task Download_Should_ClearSession_WhenLaterSourceIsMissing() + { + // arrange + using var testDirectory = new TestDirectory(); + using var nitro = await CreateSeededNitroAsync(); + var context = CreateContext( + CreateModel( + await CreateAppHostProjectStubsAsync(testDirectory.Path)), + "Development", + outputPath: null, + nitro: nitro); + using var session = new FusionPipelineSession( + context.CancellationToken); + var executor = FusionPipelineExecutor.Instance; + await executor.PreflightAsync(context, session); + nitro.Remove("reviews", "release-1"); + + // act + var exception = await Assert.ThrowsAsync( + () => executor.DownloadAsync(context, session)); + + // assert + Assert.Equal( + "Fusion source 'reviews' version 'release-1' does not exist on target 'products'.", + exception.Message); + Assert.Equal(0, session.DeploymentCount); + } + + [Fact] + public async Task SetAll_Should_ClearOwnedBuffers_WhenCancellationPrecedesTransfer() + { + // arrange + using var testDirectory = new TestDirectory(); + var model = CreateModel( + await CreateAppHostProjectStubsAsync(testDirectory.Path)); + var deployment = Assert.Single( + await FusionPipeline.SelectStagesAsync( + model, + TestContext.Current.CancellationToken)); + using var cancellationSource = new CancellationTokenSource(); + using var session = new FusionPipelineSession( + cancellationSource.Token); + var sourceArchive = new byte[] { 1, 2, 3 }; + var state = CreateSessionState(sourceArchive, fusionArchive: null); + + // act + await cancellationSource.CancelAsync(); + + // assert + Assert.Throws( + () => session.SetAll([(deployment, state)])); + Assert.Equal(0, session.DeploymentCount); + Assert.Equal(new byte[3], sourceArchive); + } + + [Fact] + public async Task Cancel_Should_ClearOwnedBuffers_WhenStateWasTransferred() + { + // arrange + using var testDirectory = new TestDirectory(); + var model = CreateModel( + await CreateAppHostProjectStubsAsync(testDirectory.Path)); + var deployment = Assert.Single( + await FusionPipeline.SelectStagesAsync( + model, + TestContext.Current.CancellationToken)); + using var cancellationSource = new CancellationTokenSource(); + using var session = new FusionPipelineSession( + cancellationSource.Token); + var sourceArchive = new byte[] { 1, 2, 3 }; + var fusionArchive = new byte[] { 4, 5, 6 }; + session.SetAll( + [(deployment, CreateSessionState(sourceArchive, fusionArchive))]); + + // act + await cancellationSource.CancelAsync(); + + // assert + Assert.Equal(0, session.DeploymentCount); + Assert.Equal(new byte[3], sourceArchive); + Assert.Equal(new byte[3], fusionArchive); + Assert.Throws( + () => session.GetState(deployment)); + } + + private static FusionDeploymentSessionState CreateSessionState( + byte[] sourceArchive, + byte[]? fusionArchive) + { + var state = new FusionDeploymentSessionState( + "release-1", + "https://api.chillicream.com", + "products", + [ + new FusionSessionSourceIdentity( + "products", + "release-1", + "digest") + ]); + state.SetSources( + [ + new FusionSessionSource( + "products", + "release-1", + sourceArchive, + "digest") + ]); + + if (fusionArchive is not null) + { + state.SetComposition( + "development", + fusionArchive, + "far-digest"); + } + + return state; + } + + private static async Task CreateSeededNitroAsync() + { + var nitro = new FakeNitro(); + nitro.Seed( + "products", + "release-1", + await CreateSourceArchiveAsync("products", "Product")); + nitro.Seed( + "reviews", + "release-1", + await CreateSourceArchiveAsync("reviews", "Review")); + return nitro; + } + + private static string DescribeDownloads(FakeNitro nitro) + => string.Join( + ", ", + nitro.Downloads + .GroupBy(source => $"{source.Name}@{source.Version}") + .OrderBy(group => group.Key, StringComparer.Ordinal) + .Select(group => $"{group.Key} x{group.Count()}")); + + private static IReadOnlyDictionary ReadSourceUrls( + byte[] fusionArchive) + { + using var stream = new MemoryStream(fusionArchive, writable: false); + using var archive = FusionArchive.Open(stream); + using var configuration = archive + .TryGetGatewayConfigurationAsync( + WellKnownVersions.LatestGatewayFormatVersion, + TestContext.Current.CancellationToken) + .GetAwaiter() + .GetResult() + ?? throw new InvalidDataException( + "The composed Fusion archive has no gateway configuration."); + + return configuration.Settings.RootElement + .GetProperty("sourceSchemas") + .EnumerateObject() + .ToDictionary( + source => source.Name, + source => source.Value + .GetProperty("transports") + .GetProperty("http") + .GetProperty("url") + .GetString() + ?? throw new InvalidDataException( + "The composed source URL is missing."), + StringComparer.Ordinal); + } + + private static DistributedApplicationModel CreateModel( + (string ProductsProjectPath, + string ReviewsProjectPath, + string GatewayProjectPath) projects, + string stageName = "development") + => CreateModel( + projects.ProductsProjectPath, + projects.ReviewsProjectPath, + projects.GatewayProjectPath, + stageName); + + private static DistributedApplicationModel CreateModel( + string productsProjectPath, + string reviewsProjectPath, + string gatewayProjectPath, + string stageName = "development") + { + var builder = DistributedApplication.CreateBuilder(); + var tag = builder.AddParameter("tag", "release-1"); + var stage = builder.AddParameter("stage", stageName); + var apiKey = builder.AddParameter( + "nitroApiKey", + "test-api-key", + secret: true); + var products = builder + .AddProject("products", productsProjectPath) + .WithGraphQLSchemaFile(); + var reviews = builder + .AddProject("reviews", reviewsProjectPath) + .WithGraphQLSchemaFile(); + builder + .AddProject("gateway", gatewayProjectPath) + .WithReference(products) + .WithReference(reviews) + .WithGraphQLSchemaComposition(); + var nitro = builder + .AddNitroPublishTarget("nitro") + .WithNitroCloudUrl("https://api.chillicream.com") + .WithNitroApiId("products") + .WithNitroApiKey(apiKey) + .WithStageParameter(stage) + .WithConfigurationTag(tag); + nitro + .AddStage("development") + .WithCompositionEnvironment("development"); + nitro + .AddStage("test") + .WithCompositionEnvironment("test"); + + return new DistributedApplicationModel(builder.Resources); + } + + private static PipelineStepContext CreateContext( + DistributedApplicationModel model, + string environmentName, + string? outputPath, + FakeNitro nitro, + CancellationToken? cancellationToken = null) + { + var services = new ServiceCollection() + .AddSingleton( + new TestHostEnvironment(environmentName)) + .AddSingleton(nitro.Workflow) + .AddSingleton( + new ConfigurationBuilder().Build()); + services.AddSingleton( + outputPath is null + ? new ThrowingPipelineOutputService() + : new TestPipelineOutputService(outputPath)); + var schemaCompositionType = + typeof(GraphQLCompositionSettings).Assembly.GetType( + "HotChocolate.Fusion.Aspire.SchemaComposition", + throwOnError: true)!; + var loggerType = typeof(ILogger<>).MakeGenericType( + schemaCompositionType); + var nullLogger = Activator.CreateInstance( + typeof(NullLogger<>).MakeGenericType( + schemaCompositionType))!; + services.AddSingleton(loggerType, nullLogger); + var serviceProvider = services.BuildServiceProvider(); + var pipelineContext = new PipelineContext( + model, + new DistributedApplicationExecutionContext( + DistributedApplicationOperation.Publish), + serviceProvider, + NullLogger.Instance, + cancellationToken + ?? TestContext.Current.CancellationToken); + + return new PipelineStepContext + { + PipelineContext = pipelineContext, + ReportingStep = null! + }; + } + + private static async Task CreateSourceCheckoutAsync( + string checkout, + string projectName, + string sourceName, + string typeName) + { + var sourceDirectory = IOPath.Combine(checkout, projectName); + Directory.CreateDirectory(sourceDirectory); + var projectPath = IOPath.Combine( + sourceDirectory, + $"{projectName}.csproj"); + await File.WriteAllTextAsync( + projectPath, + "", + TestContext.Current.CancellationToken); + await File.WriteAllTextAsync( + IOPath.Combine(sourceDirectory, "schema.graphqls"), + $$""" + type Query { + {{sourceName}}: {{typeName}} + } + + type {{typeName}} { + id: ID! + } + """, + TestContext.Current.CancellationToken); + var settings = """ + { + "name": "SOURCE_NAME", + "transports": { + "http": { + "url": "{{SOURCE_URL}}/graphql" + } + }, + "environments": { + "development": { + "SOURCE_URL": "https://SOURCE_NAME.development.example.com" + }, + "test": { + "SOURCE_URL": "https://SOURCE_NAME.test.example.com" + } + } + } + """.Replace( + "SOURCE_NAME", + sourceName, + StringComparison.Ordinal); + await File.WriteAllTextAsync( + IOPath.Combine(sourceDirectory, "schema-settings.json"), + settings, + TestContext.Current.CancellationToken); + return projectPath; + } + + private static async Task CreateSourceArchiveAsync( + string sourceName, + string typeName) + { + await using var stream = new MemoryStream(); + using (var archive = FusionSourceSchemaArchive.Create( + stream, + leaveOpen: true)) + { + await archive.SetArchiveMetadataAsync( + new HotChocolate.Fusion.SourceSchema.Packaging.ArchiveMetadata(), + TestContext.Current.CancellationToken); + await archive.SetSchemaAsync( + Encoding.UTF8.GetBytes( + $"type Query {{ {sourceName}: {typeName} }} " + + $"type {typeName} {{ id: ID! }}"), + TestContext.Current.CancellationToken); + using var settings = JsonDocument.Parse( + $$""" + { + "name": "{{sourceName}}", + "transports": { + "http": { + "url": "https://{{sourceName}}.example.com/graphql" + } + } + } + """); + await archive.SetSettingsAsync( + settings, + TestContext.Current.CancellationToken); + await archive.CommitAsync( + TestContext.Current.CancellationToken); + } + + return stream.ToArray(); + } + + private static async Task<( + string ProductsProjectPath, + string ReviewsProjectPath, + string GatewayProjectPath)> CreateAppHostProjectStubsAsync( + string runnerPath) + { + var appHostDirectory = IOPath.Combine(runnerPath, "apphost-model"); + Directory.CreateDirectory(appHostDirectory); + var productsDirectory = IOPath.Combine(appHostDirectory, "Products"); + var reviewsDirectory = IOPath.Combine(appHostDirectory, "Reviews"); + var gatewayDirectory = IOPath.Combine(appHostDirectory, "Gateway"); + Directory.CreateDirectory(productsDirectory); + Directory.CreateDirectory(reviewsDirectory); + Directory.CreateDirectory(gatewayDirectory); + var productsProjectPath = IOPath.Combine( + productsDirectory, + "Products.csproj"); + var reviewsProjectPath = IOPath.Combine( + reviewsDirectory, + "Reviews.csproj"); + var gatewayProjectPath = IOPath.Combine( + gatewayDirectory, + "Gateway.csproj"); + await File.WriteAllTextAsync( + productsProjectPath, + "", + TestContext.Current.CancellationToken); + await File.WriteAllTextAsync( + reviewsProjectPath, + "", + TestContext.Current.CancellationToken); + await File.WriteAllTextAsync( + gatewayProjectPath, + "", + TestContext.Current.CancellationToken); + + Assert.Empty( + Directory.GetFiles( + runnerPath, + "schema.graphqls", + SearchOption.AllDirectories)); + Assert.Empty( + Directory.GetFiles( + runnerPath, + "schema-settings.json", + SearchOption.AllDirectories)); + return ( + productsProjectPath, + reviewsProjectPath, + gatewayProjectPath); + } + + private static string NormalizeTree(IReadOnlyList tree) + => string.Join('\n', tree).Replace('\\', '/'); + + private static IReadOnlyList SnapshotTree(string path) + => Directory.EnumerateFileSystemEntries( + path, + "*", + SearchOption.AllDirectories) + .Select(entry => IOPath.GetRelativePath(path, entry)) + .Order(StringComparer.Ordinal) + .ToArray(); + + private sealed record RecordedPublication( + string Stage, + IReadOnlyList Sources, + byte[] FusionArchive); + + /// + /// An in-memory Nitro API that stores immutable source schema versions and answers the + /// publication protocol with a successful terminal result. + /// + private sealed class FakeNitro : HttpMessageHandler + { + private readonly Dictionary _sources = + new(StringComparer.Ordinal); + private readonly HttpClient _httpClient; + private readonly NitroFusionApi _api; + private (string Name, string Version)? _missingVersion; + private (string Stage, IReadOnlyList Sources)? _openRequest; + private TaskCompletionSource? _publishStarted; + private TaskCompletionSource? _continuePublish; + + public FakeNitro() + { + _httpClient = new HttpClient(this, disposeHandler: false) + { + Timeout = Timeout.InfiniteTimeSpan + }; + _api = new NitroFusionApi(_httpClient, disposeHttpClient: false); + Workflow = new FusionDeploymentWorkflow(_api); + } + + public FusionDeploymentWorkflow Workflow { get; } + + public List Downloads { get; } = []; + + public List Uploads { get; } = []; + + public List Publications { get; } = []; + + public Exception? BeginException { get; set; } + + /// + /// Gets or sets the composition settings that the stage declares, as JSON. + /// + public string StageCompositionSettingsJson { get; set; } = "null"; + + public void Seed(string name, string version, byte[] archive) + => _sources[$"{name}@{version}"] = archive; + + public void Remove(string name, string version) + => _sources.Remove($"{name}@{version}"); + + public void BlockNextPublication() + { + _publishStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + _continuePublish = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + } + + public Task WaitForBlockedPublicationAsync() + => _publishStarted?.Task + ?? throw new InvalidOperationException( + "No publication is blocked."); + + public void ContinueBlockedPublication() + => (_continuePublish + ?? throw new InvalidOperationException( + "No publication is blocked.")) + .TrySetResult(); + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + if (request.Method == HttpMethod.Get) + { + return Download(request); + } + + var (operationName, variables, file) = + await ReadRequestAsync(request, cancellationToken); + + switch (operationName) + { + case NitroOperationDocuments.UploadSourceSchemaOperationName: + return Upload(variables, file); + + case NitroOperationDocuments.BeginDeploymentOperationName: + await BlockAsync(cancellationToken); + if (BeginException is not null) + { + throw BeginException; + } + + return Begin(variables); + + case NitroOperationDocuments.WatchDeploymentOperationName: + return Watch(); + + case NitroOperationDocuments.ClaimDeploymentOperationName: + return Json(CommandResult("startFusionConfigurationComposition")); + + case NitroOperationDocuments.ValidateDeploymentOperationName: + AssertFileName(file, "gateway.fgp"); + return Json(CommandResult("validateFusionConfigurationComposition")); + + case NitroOperationDocuments.CommitDeploymentOperationName: + AssertFileName(file, "gateway.far"); + return Commit(file); + + case NitroOperationDocuments.ReleaseDeploymentOperationName: + return Json(CommandResult("cancelFusionConfigurationComposition")); + + case NitroOperationDocuments.GetCompositionSettingsOperationName: + return Json(StageCompositionSettings(StageCompositionSettingsJson)); + + default: + throw new InvalidOperationException( + $"The Nitro operation '{operationName}' is not scripted."); + } + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _api.Dispose(); + _httpClient.Dispose(); + } + + base.Dispose(disposing); + } + + private async Task BlockAsync(CancellationToken cancellationToken) + { + if (_publishStarted is null || _continuePublish is null) + { + return; + } + + _publishStarted.TrySetResult(); + await _continuePublish.Task; + cancellationToken.ThrowIfCancellationRequested(); + } + + private HttpResponseMessage Download(HttpRequestMessage request) + { + var segments = request.RequestUri!.AbsolutePath.Split('/'); + var name = Uri.UnescapeDataString(segments[6]); + var version = Uri.UnescapeDataString(segments[8]); + Downloads.Add(new FusionSourceSchemaVersion(name, version)); + + if (!_sources.TryGetValue($"{name}@{version}", out var archive)) + { + _missingVersion = (name, version); + return new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent(string.Empty) + }; + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(archive.ToArray()) + }; + } + + private HttpResponseMessage Upload( + JsonElement variables, + UploadedFile? file) + { + AssertFileName(file, "source-schema.zip"); + var version = variables + .GetProperty("input") + .GetProperty("tag") + .GetString()!; + var name = _missingVersion?.Name + ?? throw new InvalidOperationException( + "Nitro received an upload for a version that was never read back."); + _sources[$"{name}@{version}"] = file!.Content; + Uploads.Add(new FusionSourceSchemaVersion(name, version)); + + return Json( + """ + {"data":{"uploadFusionSubgraph":{"fusionSubgraphVersion":{"id":"version-1"},"errors":[]}}} + """); + } + + private HttpResponseMessage Begin(JsonElement variables) + { + var input = variables.GetProperty("input"); + _openRequest = ( + input.GetProperty("stageName").GetString()!, + input.GetProperty("subgraphs") + .EnumerateArray() + .Select(source => new FusionSourceSchemaVersion( + source.GetProperty("name").GetString()!, + source.GetProperty("tag").GetString()!)) + .ToArray()); + + return Json( + """ + {"data":{"beginFusionConfigurationPublish":{"requestId":"request-1","errors":[]}}} + """); + } + + private HttpResponseMessage Commit(UploadedFile? file) + { + var openRequest = _openRequest + ?? throw new InvalidOperationException( + "Nitro received a commit without an open publication request."); + Publications.Add( + new RecordedPublication( + openRequest.Stage, + openRequest.Sources, + file!.Content)); + _openRequest = null; + + return Json(CommandResult("commitFusionConfigurationPublish")); + } + + private static HttpResponseMessage Watch() + => new(HttpStatusCode.OK) + { + Content = new StringContent( + BuildEventStream( + "ProcessingTaskIsReady", + "FusionConfigurationValidationSuccess", + "FusionConfigurationPublishingSuccess"), + Encoding.UTF8, + "text/event-stream") + }; + + private static string BuildEventStream(params string[] typeNames) + { + var builder = new StringBuilder(); + + foreach (var typeName in typeNames) + { + builder + .Append("event: next\n") + .Append("data: ") + .Append( + "{\"data\":{\"onFusionConfigurationPublishingTaskChanged\":{" + + "\"__typename\":\"" + typeName + "\"," + + "\"state\":\"PROCESSING\",\"errors\":[]}}}") + .Append("\n\n"); + } + + return builder.Append("event: complete\n\n").ToString(); + } + + private static void AssertFileName(UploadedFile? file, string expected) + { + Assert.NotNull(file); + Assert.Equal(expected, file.FileName); + } + + private static string StageCompositionSettings(string settingsJson) + => "{\"data\":{\"node\":{\"stage\":{\"compositionSettings\":" + + settingsJson + + "}}}}"; + + private static string CommandResult(string fieldName) + => "{\"data\":{\"" + fieldName + "\":{\"errors\":[]}}}"; + + private static HttpResponseMessage Json(string json) + => new(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + private static async Task<( + string OperationName, + JsonElement Variables, + UploadedFile? File)> ReadRequestAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + string operations; + UploadedFile? file = null; + + if (request.Content is MultipartFormDataContent multipart) + { + operations = string.Empty; + foreach (var part in multipart) + { + var disposition = part.Headers.ContentDisposition; + if (disposition?.FileName is { } fileName) + { + file = new UploadedFile( + fileName.Trim('"'), + await part.ReadAsByteArrayAsync(cancellationToken)); + } + else if (disposition?.Name?.Trim('"') is "operations") + { + operations = await part.ReadAsStringAsync(cancellationToken); + } + } + } + else + { + operations = await request.Content!.ReadAsStringAsync( + cancellationToken); + } + + using var document = JsonDocument.Parse(operations); + var operationName = document.RootElement + .GetProperty("operationName") + .GetString()!; + var variables = document.RootElement + .GetProperty("variables") + .Clone(); + + return (operationName, variables, file); + } + + private sealed record UploadedFile(string FileName, byte[] Content); + } + + private sealed class ThrowingPipelineOutputService + : IPipelineOutputService + { + private static InvalidOperationException CreateException() + => new("Publish-only Fusion steps must not resolve pipeline output directories."); + + public string GetOutputDirectory() => throw CreateException(); + + public string GetOutputDirectory(IResource resource) + => throw CreateException(); + + public string GetTempDirectory() => throw CreateException(); + + public string GetTempDirectory(IResource resource) + => throw CreateException(); + } + + private sealed class TestPipelineOutputService(string outputPath) + : IPipelineOutputService + { + public string GetOutputDirectory() => outputPath; + + public string GetOutputDirectory(IResource resource) + => IOPath.Combine(outputPath, resource.Name); + + public string GetTempDirectory() + => IOPath.Combine(outputPath, ".temp"); + + public string GetTempDirectory(IResource resource) + => IOPath.Combine(outputPath, ".temp", resource.Name); + } + + private sealed class TestHostEnvironment(string environmentName) + : IHostEnvironment + { + public string EnvironmentName { get; set; } = environmentName; + + public string ApplicationName { get; set; } = + nameof(FusionReleaseAcceptanceTests); + + public string ContentRootPath { get; set; } = + Directory.GetCurrentDirectory(); + + public IFileProvider ContentRootFileProvider { get; set; } = + new NullFileProvider(); + } + + private sealed class TestDirectory : IDisposable + { + public TestDirectory() + { + Path = IOPath.Combine( + IOPath.GetTempPath(), + "chilicream-nitro-aspire-acceptance-tests", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + Directory.Delete(Path, recursive: true); + } + } +} + +#pragma warning restore ASPIREPIPELINES001 +#pragma warning restore ASPIREPIPELINES004 diff --git a/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/GraphQLResourceBuilderExtensionsTests.cs b/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/GraphQLResourceBuilderExtensionsTests.cs index 34896663e9a..de243372373 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/GraphQLResourceBuilderExtensionsTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/GraphQLResourceBuilderExtensionsTests.cs @@ -50,6 +50,62 @@ public void WithGraphQLSchemaEndpoint_Should_RejectPath_When_PathIsNotRooted() exception.Message); } + [Fact] + public void WithGraphQLSchemaExport_Should_PreserveExplicitBuildInputs() + { + var builder = DistributedApplication.CreateBuilder(); + var resource = builder + .AddProject("products", GetTestProjectFile()) + .WithGraphQLSchemaExport( + "Products", + "Release", + "net9.0", + "linux-x64", + TimeSpan.FromMinutes(2)); + + var annotation = Assert.Single( + resource.Resource.Annotations.OfType()); + + $$""" + Source schema name: {{annotation.SourceSchemaName}} + Export schema name: {{annotation.ExportSchemaName}} + Configuration: {{annotation.ExportConfiguration}} + Target framework: {{annotation.ExportTargetFramework}} + Runtime identifier: {{annotation.ExportRuntimeIdentifier}} + Timeout: {{annotation.ExportTimeout}} + Location: {{annotation.Location}} + """.MatchInlineSnapshot( + """ + Source schema name: Products + Export schema name: Products + Configuration: Release + Target framework: net9.0 + Runtime identifier: linux-x64 + Timeout: 00:02:00 + Location: CommandLineExport + """); + } + + [Fact] + public void WithGraphQLSchemaExport_Should_RejectWhitespaceRuntimeIdentifier() + { + var builder = DistributedApplication.CreateBuilder(); + var resource = builder.AddProject("products", GetTestProjectFile()); + + var exception = Assert.Throws( + () => resource.WithGraphQLSchemaExport( + "Products", + "Release", + "net9.0", + " ", + TimeSpan.FromMinutes(2))); + + Assert.Equal( + "The value cannot be an empty string or composed entirely of whitespace. " + + "(Parameter 'runtimeIdentifier')", + exception.Message); + } + [Theory] [InlineData( null, diff --git a/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/GraphQLResourceModelTests.cs b/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/GraphQLResourceModelTests.cs new file mode 100644 index 00000000000..baf66123cad --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/GraphQLResourceModelTests.cs @@ -0,0 +1,121 @@ +using System.Runtime.CompilerServices; +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; + +namespace HotChocolate.Fusion.Aspire; + +public sealed class GraphQLResourceModelTests +{ + [Fact] + public void GetReferencedSourceSchemas_Should_ReturnSharedResourceAndDeclaration() + { + var builder = DistributedApplication.CreateBuilder(); + var products = builder + .AddProject("products", GetTestProjectFile()) + .WithGraphQLSchemaExport( + "Products", + "Release", + "net9.0", + runtimeIdentifier: null, + TimeSpan.FromMinutes(1)); + var unrelated = builder + .AddProject("unrelated", GetTestProjectFile()) + .WithGraphQLSchemaFile(); + var unreferenced = builder + .AddProject("unreferenced", GetTestProjectFile()) + .WithGraphQLSchemaFile(); + var gateway = builder + .AddProject("gateway", GetTestProjectFile()) + .WithReference(products) + .WithReference(unrelated) + .WithGraphQLSchemaComposition(); + var model = new DistributedApplicationModel( + [ + products.Resource, + unrelated.Resource, + unreferenced.Resource, + gateway.Resource + ]); + + var sources = GraphQLResourceModel.GetReferencedSourceSchemas( + gateway.Resource, + model); + + Assert.Collection( + sources, + source => + { + Assert.Same(products.Resource, source.Resource); + Assert.Same( + GraphQLResourceModel.GetSourceSchema(products.Resource), + source.Declaration); + }, + source => + { + Assert.Same(unrelated.Resource, source.Resource); + Assert.Same( + GraphQLResourceModel.GetSourceSchema(unrelated.Resource), + source.Declaration); + }); + } + + [Fact] + public void GetReferencedSourceSchemas_Should_FailClosed_When_DeclarationIsAmbiguous() + { + var builder = DistributedApplication.CreateBuilder(); + var products = builder + .AddProject("products", GetTestProjectFile()) + .WithGraphQLSchemaFile(); + products.Resource.Annotations.Add( + new GraphQLSourceSchemaAnnotation + { + Location = SourceSchemaLocationType.SchemaEndpoint + }); + var gateway = builder + .AddProject("gateway", GetTestProjectFile()) + .WithReference(products) + .WithGraphQLSchemaComposition(); + var model = new DistributedApplicationModel( + [products.Resource, gateway.Resource]); + + Assert.Throws( + () => GraphQLResourceModel.GetReferencedSourceSchemas( + gateway.Resource, + model)); + } + + [Fact] + public void GetProjectSchemaPaths_Should_ResolveSettingsBesideNestedSchema() + { + var builder = DistributedApplication.CreateBuilder(); + var products = builder + .AddProject("products", GetTestProjectFile()) + .WithGraphQLSchemaFile( + System.IO.Path.Combine("schemas", "products.graphqls"), + "Products"); + var declaration = GraphQLResourceModel.GetSourceSchema(products.Resource); + + var paths = GraphQLResourceModel.GetProjectSchemaPaths( + products.Resource, + declaration); + + var projectDirectory = System.IO.Path.GetDirectoryName(GetTestProjectFile())!; + $$""" + Project: {{System.IO.Path.GetRelativePath(projectDirectory, paths.ProjectPath)}} + Schema: {{System.IO.Path.GetRelativePath(projectDirectory, paths.SchemaPath)}} + Settings: {{System.IO.Path.GetRelativePath(projectDirectory, paths.SettingsPath)}} + Extensions: {{System.IO.Path.GetRelativePath(projectDirectory, paths.ExtensionsPath)}} + """.MatchInlineSnapshot( + """ + Project: HotChocolate.Fusion.Aspire.Tests.csproj + Schema: schemas/products.graphqls + Settings: schemas/products-settings.json + Extensions: schemas/products-extensions.graphqls + """); + } + + private static string GetTestProjectFile([CallerFilePath] string sourceFile = "") + => System.IO.Path.Combine( + System.IO.Path.GetDirectoryName(sourceFile)!, + "HotChocolate.Fusion.Aspire.Tests.csproj"); +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/Nitro/FusionDeploymentWorkflowTests.cs b/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/Nitro/FusionDeploymentWorkflowTests.cs new file mode 100644 index 00000000000..94a4ecbd3a4 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/Nitro/FusionDeploymentWorkflowTests.cs @@ -0,0 +1,1095 @@ +using System.IO.Compression; +using System.Net; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using HotChocolate.Fusion.SourceSchema.Packaging; +using IOPath = System.IO.Path; + +namespace HotChocolate.Fusion.Aspire.Nitro; + +public sealed class FusionDeploymentWorkflowTests +{ + [Fact] + public void Assembly_Should_ExposeOnlySeedUpdateOptions_When_Inspected() + { + // act + var exportedTypes = typeof(FusionDeploymentWorkflow) + .Assembly + .GetExportedTypes() + .Where(type => type.Namespace is "HotChocolate.Fusion.Aspire.Nitro") + .Select(type => type.FullName!) + .Order(StringComparer.Ordinal) + .ToArray(); + + // assert + // NitroSeedUpdateOptions is the configuration surface of AddNitro, everything else that + // drives the deployment workflow stays internal. + Assert.Equal( + ["HotChocolate.Fusion.Aspire.Nitro.NitroSeedUpdateOptions"], + exportedTypes); + } + + [Fact] + public void PublishAsync_Should_AcceptFusionArchiveFromMemory_When_Inspected() + { + typeof(FusionDeploymentWorkflow) + .GetMethod(nameof(FusionDeploymentWorkflow.PublishAsync))! + .ToString() + .MatchInlineSnapshot( + """ + System.Threading.Tasks.Task PublishAsync(HotChocolate.Fusion.Aspire.Nitro.FusionPublicationRequest, System.ReadOnlyMemory`1[System.Byte], System.Threading.CancellationToken) + """); + } + + [Fact] + public void Dispose_Should_ClearOwnedArchiveAndRejectAccess_When_Called() + { + byte[] archive = [1, 2, 3]; + var download = new FusionSourceSchemaDownload( + "products", + "20260730", + archive, + new string('A', 64)); + + Assert.Equal(new byte[] { 1, 2, 3 }, download.Archive.ToArray()); + + download.Dispose(); + + Assert.Equal(new byte[3], archive); + Assert.Throws(() => download.Archive); + + archive[0] = 9; + download.Dispose(); + Assert.Equal(new byte[] { 9, 0, 0 }, archive); + } + + [Fact] + public async Task ComputeSha256Async_Should_ReturnSameDigest_When_ContentIsNormalized() + { + var directory = CreateTemporaryDirectory(); + try + { + var firstPath = IOPath.Combine(directory, "first.fss"); + var secondPath = IOPath.Combine(directory, "second.fss"); + await CreateArchiveAsync( + firstPath, + "products", + "type Query { product: String }", + """{"name":"products","transports":{"http":{"url":"https://example.com"}}}"""); + await CreateArchiveAsync( + secondPath, + "products", + """ + type Query { + product: String + } + """, + """{"transports":{"http":{"url":"https://example.com"}},"name":"products"}"""); + + var first = await FusionSourceSchemaContent.ComputeSha256Async( + firstPath, + "products", + TestContext.Current.CancellationToken); + var second = await FusionSourceSchemaContent.ComputeSha256Async( + secondPath, + "products", + TestContext.Current.CancellationToken); + + Assert.Equal(first, second); + first.MatchInlineSnapshot( + """ + 619211A45A3F75EC35BC88FAF88BA74173FE42C157B7F3020CBC896198CFE224 + """); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public async Task DownloadSourceSchemaAsync_Should_ReturnArchiveAndDigest_When_VersionExists() + { + // arrange + var directory = CreateTemporaryDirectory(); + try + { + var remotePath = IOPath.Combine(directory, "remote.fss"); + await CreateArchiveAsync( + remotePath, + "products", + """ + type Query { + product: String + } + """, + """{"transports":{"http":{"url":"https://example.com"}},"name":"products"}"""); + var remoteArchive = await File.ReadAllBytesAsync( + remotePath, + TestContext.Current.CancellationToken); + var nitro = new FakeNitro { RemoteArchive = remoteArchive }; + var workflow = CreateWorkflow(nitro); + + // act + using var result = await workflow.DownloadSourceSchemaAsync( + CreateTarget(), + new FusionSourceSchemaVersion("products", "20260730"), + TestContext.Current.CancellationToken); + + // assert + Assert.NotNull(result); + var ownedArchive = GetUnderlyingArray(result.Archive); + $""" + Name: {result.Name} + Version: {result.Version} + Content SHA-256: {result.ContentSha256} + Archive matches: {result.Archive.ToArray().SequenceEqual(remoteArchive)} + Requested: {nitro.LastDownloadName}/{nitro.LastDownloadVersion} + API key: {nitro.LastApiKey} + """.MatchInlineSnapshot( + """ + Name: products + Version: 20260730 + Content SHA-256: 619211A45A3F75EC35BC88FAF88BA74173FE42C157B7F3020CBC896198CFE224 + Archive matches: True + Requested: products/20260730 + API key: secret + """); + + result.Dispose(); + Assert.Equal(new byte[ownedArchive.Length], ownedArchive); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public async Task DownloadSourceSchemaAsync_Should_ReturnNull_When_VersionDoesNotExist() + { + // arrange + var nitro = new FakeNitro(); + var workflow = CreateWorkflow(nitro); + + // act + var result = await workflow.DownloadSourceSchemaAsync( + CreateTarget(), + new FusionSourceSchemaVersion("products", "missing"), + TestContext.Current.CancellationToken); + + // assert + Assert.Null(result); + Assert.Equal(1, nitro.DownloadCount); + Assert.Equal("products", nitro.LastDownloadName); + Assert.Equal("missing", nitro.LastDownloadVersion); + } + + [Fact] + public async Task DownloadSourceSchemaAsync_Should_Throw_When_NitroRejectsTheApiKey() + { + // arrange + var nitro = new FakeNitro { DownloadStatusCode = HttpStatusCode.Unauthorized }; + var workflow = CreateWorkflow(nitro); + + // act + var exception = await Assert.ThrowsAsync( + () => workflow.DownloadSourceSchemaAsync( + CreateTarget(), + new FusionSourceSchemaVersion("products", "20260730"), + TestContext.Current.CancellationToken)); + + // assert + Assert.Equal("Nitro rejected the supplied API key.", exception.Message); + } + + [Fact] + public async Task DownloadSourceSchemaAsync_Should_RejectArchive_When_NameDiffers() + { + // arrange + var directory = CreateTemporaryDirectory(); + try + { + var remotePath = IOPath.Combine(directory, "remote.fss"); + await CreateArchiveAsync( + remotePath, + "inventory", + "type Query { product: String }", + """{"name":"inventory"}"""); + var nitro = new FakeNitro + { + RemoteArchive = await File.ReadAllBytesAsync( + remotePath, + TestContext.Current.CancellationToken) + }; + var workflow = CreateWorkflow(nitro); + + // act + var exception = await Assert.ThrowsAsync( + () => workflow.DownloadSourceSchemaAsync( + CreateTarget(), + new FusionSourceSchemaVersion("products", "20260730"), + TestContext.Current.CancellationToken)); + + // assert + Assert.Equal( + "The source schema settings name must exactly match 'products'.", + exception.Message); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public async Task DownloadSourceSchemaAsync_Should_RejectArchive_When_DecompressedEntryIsTooLarge() + { + // arrange + var directory = CreateTemporaryDirectory(); + try + { + var remotePath = IOPath.Combine(directory, "remote.fss"); + await CreateOversizedSettingsArchiveAsync(remotePath); + var nitro = new FakeNitro + { + RemoteArchive = await File.ReadAllBytesAsync( + remotePath, + TestContext.Current.CancellationToken) + }; + var workflow = CreateWorkflow(nitro); + + // act + var exception = await Assert.ThrowsAsync( + () => workflow.DownloadSourceSchemaAsync( + CreateTarget(), + new FusionSourceSchemaVersion("products", "20260730"), + TestContext.Current.CancellationToken)); + + // assert + Assert.Equal( + "The Fusion source schema archive is invalid.", + exception.Message); + Assert.IsType(exception.InnerException); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public async Task ReconcileSourceSchemaAsync_Should_NoOp_When_NormalizedContentMatches() + { + // arrange + var directory = CreateTemporaryDirectory(); + try + { + var localPath = IOPath.Combine(directory, "local.fss"); + var remotePath = IOPath.Combine(directory, "remote.fss"); + await CreateArchiveAsync( + localPath, + "products", + "type Query { product: String }", + """{"name":"products","transports":{"http":{"url":"https://example.com"}}}"""); + await CreateArchiveAsync( + remotePath, + "products", + """ + type Query { + product: String + } + """, + """{"transports":{"http":{"url":"https://example.com"}},"name":"products"}"""); + var nitro = new FakeNitro + { + RemoteArchive = await File.ReadAllBytesAsync( + remotePath, + TestContext.Current.CancellationToken) + }; + var workflow = CreateWorkflow(nitro); + + // act + await workflow.ReconcileSourceSchemaAsync( + CreateTarget(), + await CreateUploadAsync(localPath), + TestContext.Current.CancellationToken); + + // assert + Assert.Equal(0, nitro.UploadCount); + Assert.Equal(1, nitro.DownloadCount); + Assert.Empty(nitro.Calls); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public async Task ReconcileSourceSchemaAsync_Should_ThrowCollision_When_ContentDiffers() + { + // arrange + var directory = CreateTemporaryDirectory(); + try + { + var localPath = IOPath.Combine(directory, "local.fss"); + var remotePath = IOPath.Combine(directory, "remote.fss"); + await CreateArchiveAsync( + localPath, + "products", + "type Query { product: String }", + """{"name":"products"}"""); + await CreateArchiveAsync( + remotePath, + "products", + "type Query { product: Int }", + """{"name":"products"}"""); + var nitro = new FakeNitro + { + RemoteArchive = await File.ReadAllBytesAsync( + remotePath, + TestContext.Current.CancellationToken) + }; + var workflow = CreateWorkflow(nitro); + var upload = await CreateUploadAsync(localPath); + + // act + var exception = await Assert.ThrowsAsync( + () => workflow.ReconcileSourceSchemaAsync( + CreateTarget(), + upload, + TestContext.Current.CancellationToken)); + + // assert + Assert.Equal( + "Source schema 'products' version '20260730' already exists " + + "with different normalized schema, settings, or extensions.", + exception.Message); + Assert.Equal(0, nitro.UploadCount); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public async Task ReconcileSourceSchemaAsync_Should_UploadArchive_When_VersionIsMissing() + { + // arrange + var directory = CreateTemporaryDirectory(); + try + { + var archivePath = IOPath.Combine(directory, "products.fss"); + await CreateArchiveAsync( + archivePath, + "products", + "type Query { product: String }", + """{"name":"products"}"""); + var nitro = new FakeNitro(); + var workflow = CreateWorkflow(nitro); + + // act + await workflow.ReconcileSourceSchemaAsync( + CreateTarget(), + await CreateUploadAsync(archivePath), + TestContext.Current.CancellationToken); + + // assert + $""" + Calls: {string.Join(", ", nitro.Calls)} + Uploads: {nitro.UploadCount} + Multipart: {nitro.LastContentType} + File name: {nitro.UploadBody!.Contains("source-schema.zip", StringComparison.Ordinal)} + Tag: {nitro.UploadBody!.Contains("20260730", StringComparison.Ordinal)} + API key: {nitro.LastApiKey} + """.MatchInlineSnapshot( + """ + Calls: UploadFusionSourceSchema + Uploads: 1 + Multipart: multipart/form-data + File name: True + Tag: True + API key: secret + """); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public async Task ReconcileSourceSchemaAsync_Should_VerifyReadBack_When_UploadIsUncertain() + { + // arrange + var directory = CreateTemporaryDirectory(); + try + { + var archivePath = IOPath.Combine(directory, "products.fss"); + await CreateArchiveAsync( + archivePath, + "products", + "type Query { product: String }", + """{"name":"products"}"""); + var archive = await File.ReadAllBytesAsync( + archivePath, + TestContext.Current.CancellationToken); + var nitro = new FakeNitro + { + UploadException = new IOException("Connection reset."), + RemoteArchiveAfterUpload = archive + }; + var workflow = CreateWorkflow(nitro); + + // act + await workflow.ReconcileSourceSchemaAsync( + CreateTarget(), + await CreateUploadAsync(archivePath), + TestContext.Current.CancellationToken); + + // assert + Assert.Equal(1, nitro.UploadCount); + Assert.Equal(2, nitro.DownloadCount); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public async Task ReconcileSourceSchemaAsync_Should_VerifyReadBack_When_TagIsDuplicated() + { + // arrange + var directory = CreateTemporaryDirectory(); + try + { + var archivePath = IOPath.Combine(directory, "products.fss"); + await CreateArchiveAsync( + archivePath, + "products", + "type Query { product: String }", + """{"name":"products"}"""); + var nitro = new FakeNitro + { + UploadErrorTypeName = "DuplicatedTagError", + RemoteArchiveAfterUpload = await File.ReadAllBytesAsync( + archivePath, + TestContext.Current.CancellationToken) + }; + var workflow = CreateWorkflow(nitro); + + // act + await workflow.ReconcileSourceSchemaAsync( + CreateTarget(), + await CreateUploadAsync(archivePath), + TestContext.Current.CancellationToken); + + // assert + Assert.Equal(1, nitro.UploadCount); + Assert.Equal(2, nitro.DownloadCount); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public async Task ReconcileSourceSchemaAsync_Should_Fail_When_UploadIsRejected() + { + // arrange + var directory = CreateTemporaryDirectory(); + try + { + var archivePath = IOPath.Combine(directory, "products.fss"); + await CreateArchiveAsync( + archivePath, + "products", + "type Query { product: String }", + """{"name":"products"}"""); + var nitro = new FakeNitro + { + UploadErrorTypeName = "InvalidSourceMetadataInputError", + UploadErrorMessage = "The source metadata is invalid." + }; + var workflow = CreateWorkflow(nitro); + var upload = await CreateUploadAsync(archivePath); + + // act + var exception = await Assert.ThrowsAsync( + () => workflow.ReconcileSourceSchemaAsync( + CreateTarget(), + upload, + TestContext.Current.CancellationToken)); + + // assert + Assert.Equal( + "Nitro rejected the Fusion source schema upload. " + + "The source metadata is invalid.", + exception.Message); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public async Task PublishAsync_Should_Commit_When_ValidationSucceeds() + { + // arrange + var fusionArchive = Encoding.UTF8.GetBytes("fusion archive"); + var nitro = new FakeNitro + { + Events = + [ + new WatchEvent("ProcessingTaskIsReady"), + new WatchEvent("FusionConfigurationValidationSuccess"), + new WatchEvent("FusionConfigurationPublishingSuccess") + ] + }; + var workflow = CreateWorkflow(nitro); + + // act + await workflow.PublishAsync( + CreatePublicationRequest(force: false), + fusionArchive, + TestContext.Current.CancellationToken); + + // assert + $""" + Calls: {string.Join(", ", nitro.Calls)} + Validated archive: {DescribeConfiguration(nitro.ValidateBody)} + Committed archive: {DescribeConfiguration(nitro.CommitBody)} + """.MatchInlineSnapshot( + """ + Calls: BeginFusionDeployment, WatchFusionDeployment, ClaimFusionDeployment, ValidateFusionDeployment, CommitFusionDeployment + Validated archive: gateway.fgp, fusion archive + Committed archive: gateway.far, fusion archive + """); + } + + [Fact] + public async Task PublishAsync_Should_ReleaseAndFail_When_ValidationFailsWithoutForce() + { + // arrange + var fusionArchive = Encoding.UTF8.GetBytes("fusion archive"); + var nitro = new FakeNitro + { + Events = + [ + new WatchEvent("ProcessingTaskIsReady"), + new WatchEvent( + "FusionConfigurationValidationFailed", + ["Breaking schema change."]) + ] + }; + var workflow = CreateWorkflow(nitro); + + // act + var exception = await Assert.ThrowsAsync( + () => workflow.PublishAsync( + CreatePublicationRequest(force: false), + fusionArchive, + TestContext.Current.CancellationToken)); + + // assert + Assert.Equal( + "Nitro rejected the Fusion configuration validation. " + + "Breaking schema change.", + exception.Message); + Assert.Equal( + [ + "BeginFusionDeployment", + "WatchFusionDeployment", + "ClaimFusionDeployment", + "ValidateFusionDeployment", + "ReleaseFusionDeployment" + ], + nitro.Calls); + } + + [Fact] + public async Task PublishAsync_Should_Commit_When_ValidationFailsWithForce() + { + // arrange + var fusionArchive = Encoding.UTF8.GetBytes("fusion archive"); + var nitro = new FakeNitro + { + Events = + [ + new WatchEvent("ProcessingTaskIsReady"), + new WatchEvent( + "FusionConfigurationValidationFailed", + ["Breaking schema change."]), + new WatchEvent("FusionConfigurationPublishingSuccess") + ] + }; + var workflow = CreateWorkflow(nitro); + + // act + await workflow.PublishAsync( + CreatePublicationRequest(force: true), + fusionArchive, + TestContext.Current.CancellationToken); + + // assert + Assert.Equal( + [ + "BeginFusionDeployment", + "WatchFusionDeployment", + "ClaimFusionDeployment", + "ValidateFusionDeployment", + "CommitFusionDeployment" + ], + nitro.Calls); + } + + [Fact] + public async Task PublishAsync_Should_Fail_When_NitroRejectsTheRequest() + { + // arrange + var nitro = new FakeNitro + { + BeginErrorMessage = "The stage does not exist." + }; + var workflow = CreateWorkflow(nitro); + + // act + var exception = await Assert.ThrowsAsync( + () => workflow.PublishAsync( + CreatePublicationRequest(force: false), + Encoding.UTF8.GetBytes("fusion archive"), + TestContext.Current.CancellationToken)); + + // assert + Assert.Equal( + "Nitro rejected the Fusion publication request. The stage does not exist.", + exception.Message); + Assert.Equal(["BeginFusionDeployment"], nitro.Calls); + } + + [Fact] + public async Task PublishAsync_Should_Fail_When_TheSubscriptionEndsWithoutATerminalResult() + { + // arrange + var nitro = new FakeNitro + { + Events = [new WatchEvent("ProcessingTaskIsQueued")] + }; + var workflow = CreateWorkflow(nitro); + + // act + var exception = await Assert.ThrowsAsync( + () => workflow.PublishAsync( + CreatePublicationRequest(force: false), + Encoding.UTF8.GetBytes("fusion archive"), + TestContext.Current.CancellationToken)); + + // assert + Assert.Equal( + "The Fusion publication event stream ended before Nitro reported " + + "a terminal result.", + exception.Message); + Assert.Equal("request-id", exception.RequestId); + } + + [Fact] + public async Task PublishAsync_Should_Fail_When_TheSubscriptionIsNotAnEventStream() + { + // arrange + // A server that ignores the pinned Accept header answers with a single JSON result. + var nitro = new FakeNitro + { + Events = [new WatchEvent("ProcessingTaskIsReady")], + WatchIsEventStream = false + }; + var workflow = CreateWorkflow(nitro); + + // act + var exception = await Assert.ThrowsAsync( + () => workflow.PublishAsync( + CreatePublicationRequest(force: false), + Encoding.UTF8.GetBytes("fusion archive"), + TestContext.Current.CancellationToken)); + + // assert + Assert.Equal( + "The Fusion publication event stream ended with an error.", + exception.Message); + Assert.Equal( + "Nitro answered the subscription with the content type " + + "'application/json' instead of text/event-stream.", + exception.InnerException!.Message); + } + + private static FusionDeploymentWorkflow CreateWorkflow(FakeNitro nitro) + => new(new NitroFusionApi(new HttpClient(nitro), disposeHttpClient: true)); + + private static FusionTarget CreateTarget() + => new( + new Uri("https://api.chillicream.com"), + "api-id", + "secret"); + + private static FusionPublicationRequest CreatePublicationRequest(bool force) + => new( + CreateTarget(), + "production", + "20260730", + [new FusionSourceSchemaVersion("products", "20260730")], + WaitForApproval: false, + Force: force, + OperationTimeout: TimeSpan.FromMinutes(1), + ApprovalTimeout: TimeSpan.FromMinutes(1)); + + private static string DescribeConfiguration(string? body) + { + if (body is null) + { + return "-"; + } + + var fileName = body.Contains("gateway.fgp", StringComparison.Ordinal) + ? "gateway.fgp" + : body.Contains("gateway.far", StringComparison.Ordinal) + ? "gateway.far" + : "unknown file"; + var content = body.Contains("fusion archive", StringComparison.Ordinal) + ? "fusion archive" + : "unknown content"; + + return $"{fileName}, {content}"; + } + + private static byte[] GetUnderlyingArray(ReadOnlyMemory archive) + { + Assert.True(MemoryMarshal.TryGetArray(archive, out ArraySegment segment)); + return segment.Array!; + } + + private static async Task CreateUploadAsync( + string archivePath) + { + await using var stream = File.OpenRead(archivePath); + var sha256 = Convert.ToHexString( + await SHA256.HashDataAsync( + stream, + TestContext.Current.CancellationToken)); + return new FusionSourceSchemaUpload( + "products", + "20260730", + archivePath, + sha256); + } + + private static async Task CreateArchiveAsync( + string path, + string name, + string schema, + string settings) + { + using var archive = FusionSourceSchemaArchive.Create(path); + await archive.SetArchiveMetadataAsync( + new ArchiveMetadata(), + TestContext.Current.CancellationToken); + await archive.SetSchemaAsync( + Encoding.UTF8.GetBytes(schema), + TestContext.Current.CancellationToken); + using var settingsDocument = JsonDocument.Parse(settings); + await archive.SetSettingsAsync( + settingsDocument, + TestContext.Current.CancellationToken); + await archive.CommitAsync(TestContext.Current.CancellationToken); + } + + private static async Task CreateOversizedSettingsArchiveAsync(string path) + { + await using var stream = File.Create(path); +#if NET10_0_OR_GREATER + await using var archive = new ZipArchive( +#else + using var archive = new ZipArchive( +#endif + stream, + ZipArchiveMode.Create, + leaveOpen: true); + await using (var schema = archive.CreateEntry("schema.graphqls").Open()) + { + await schema.WriteAsync( + Encoding.UTF8.GetBytes("type Query { product: String }"), + TestContext.Current.CancellationToken); + } + + await using (var settings = + archive.CreateEntry("schema-settings.json").Open()) + { + await settings.WriteAsync( + Encoding.UTF8.GetBytes( + $$"""{"name":"products","padding":"{{new string('a', 512_000)}}"}"""), + TestContext.Current.CancellationToken); + } + } + + private static string CreateTemporaryDirectory() + { + var path = IOPath.Combine( + IOPath.GetTempPath(), + "nitro-fusion-tests", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } + + private sealed record WatchEvent(string TypeName) + { + public WatchEvent(string typeName, IReadOnlyList errors) + : this(typeName) + { + Errors = errors; + } + + public IReadOnlyList Errors { get; } = []; + } + + private sealed class FakeNitro : HttpMessageHandler + { + private const string ApiKeyHeader = "CCC-api-key"; + + public byte[]? RemoteArchive { get; set; } + + public byte[]? RemoteArchiveAfterUpload { get; set; } + + public HttpStatusCode? DownloadStatusCode { get; set; } + + public Exception? UploadException { get; set; } + + public string? UploadErrorTypeName { get; set; } + + public string UploadErrorMessage { get; set; } = "The upload was rejected."; + + public string? BeginErrorMessage { get; set; } + + public IReadOnlyList Events { get; init; } = []; + + public bool WatchIsEventStream { get; init; } = true; + + public int DownloadCount { get; private set; } + + public int UploadCount { get; private set; } + + public string? LastDownloadName { get; private set; } + + public string? LastDownloadVersion { get; private set; } + + public string? LastApiKey { get; private set; } + + public string? LastContentType { get; private set; } + + public List Calls { get; } = []; + + public string? UploadBody { get; private set; } + + public string? ValidateBody { get; private set; } + + public string? CommitBody { get; private set; } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + LastApiKey = request.Headers.TryGetValues(ApiKeyHeader, out var values) + ? values.Single() + : null; + + if (request.Method == HttpMethod.Get) + { + return Download(request); + } + + var body = request.Content is null + ? string.Empty + : await request.Content.ReadAsStringAsync(cancellationToken); + LastContentType = request.Content?.Headers.ContentType?.MediaType; + var operationName = ReadOperationName(body); + Calls.Add(operationName); + + switch (operationName) + { + case NitroOperationDocuments.UploadSourceSchemaOperationName: + UploadCount++; + UploadBody = body; + if (UploadException is not null) + { + throw UploadException; + } + + return Json(UploadResult()); + + case NitroOperationDocuments.BeginDeploymentOperationName: + return Json(BeginResult()); + + case NitroOperationDocuments.WatchDeploymentOperationName: + return Watch(); + + case NitroOperationDocuments.ClaimDeploymentOperationName: + return Json(CommandResult("startFusionConfigurationComposition")); + + case NitroOperationDocuments.ValidateDeploymentOperationName: + ValidateBody = body; + return Json(CommandResult("validateFusionConfigurationComposition")); + + case NitroOperationDocuments.CommitDeploymentOperationName: + CommitBody = body; + return Json(CommandResult("commitFusionConfigurationPublish")); + + case NitroOperationDocuments.ReleaseDeploymentOperationName: + return Json(CommandResult("cancelFusionConfigurationComposition")); + + default: + throw new InvalidOperationException( + $"The Nitro operation '{operationName}' is not scripted."); + } + } + + private HttpResponseMessage Download(HttpRequestMessage request) + { + DownloadCount++; + var segments = request.RequestUri!.AbsolutePath.Split('/'); + LastDownloadName = Uri.UnescapeDataString(segments[6]); + LastDownloadVersion = Uri.UnescapeDataString(segments[8]); + + if (DownloadStatusCode is { } statusCode) + { + return new HttpResponseMessage(statusCode) + { + Content = new StringContent(string.Empty) + }; + } + + var archive = DownloadCount > 1 && RemoteArchiveAfterUpload is not null + ? RemoteArchiveAfterUpload + : RemoteArchive; + + if (archive is null) + { + return new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent(string.Empty) + }; + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(archive.ToArray()) + }; + } + + private string UploadResult() + { + if (UploadErrorTypeName is null) + { + return """ + {"data":{"uploadFusionSubgraph":{"fusionSubgraphVersion":{"id":"version-1"},"errors":[]}}} + """; + } + + return "{\"data\":{\"uploadFusionSubgraph\":{\"fusionSubgraphVersion\":null,\"errors\":[" + + Error(UploadErrorTypeName, UploadErrorMessage) + + "]}}}"; + } + + private string BeginResult() + => BeginErrorMessage is null + ? """ + {"data":{"beginFusionConfigurationPublish":{"requestId":"request-id","errors":[]}}} + """ + : "{\"data\":{\"beginFusionConfigurationPublish\":{\"requestId\":null,\"errors\":[" + + Error("StageNotFoundError", BeginErrorMessage) + + "]}}}"; + + private static string Error(string typeName, string message) + => "{\"__typename\":\"" + typeName + "\",\"message\":\"" + message + "\"}"; + + private static string CommandResult(string fieldName) + => "{\"data\":{\"" + fieldName + "\":{\"errors\":[]}}}"; + + private HttpResponseMessage Watch() + { + var payloads = Events.Select(WatchPayload).ToArray(); + + return WatchIsEventStream + ? new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + BuildEventStream(payloads), + Encoding.UTF8, + "text/event-stream") + } + : Json(payloads[0]); + } + + private static string WatchPayload(WatchEvent watchEvent) + { + var errors = string.Join( + ',', + watchEvent.Errors.Select(error => "{\"message\":\"" + error + "\"}")); + + return "{\"data\":{\"onFusionConfigurationPublishingTaskChanged\":{" + + "\"__typename\":\"" + watchEvent.TypeName + "\"," + + "\"state\":\"PROCESSING\"," + + "\"errors\":[" + errors + "]}}}"; + } + + private static string BuildEventStream(IEnumerable payloads) + { + var builder = new StringBuilder(); + + foreach (var payload in payloads) + { + builder.Append("event: next\n"); + + foreach (var line in payload.Split('\n')) + { + builder.Append("data: ").Append(line.TrimEnd('\r')).Append('\n'); + } + + builder.Append('\n'); + } + + builder.Append("event: complete\n\n"); + + return builder.ToString(); + } + + private static HttpResponseMessage Json(string json) + => new(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + private static string ReadOperationName(string body) + { + string[] operationNames = + [ + NitroOperationDocuments.UploadSourceSchemaOperationName, + NitroOperationDocuments.BeginDeploymentOperationName, + NitroOperationDocuments.WatchDeploymentOperationName, + NitroOperationDocuments.ClaimDeploymentOperationName, + NitroOperationDocuments.ValidateDeploymentOperationName, + NitroOperationDocuments.CommitDeploymentOperationName, + NitroOperationDocuments.ReleaseDeploymentOperationName + ]; + + return Array.Find( + operationNames, + operationName => body.Contains(operationName, StringComparison.Ordinal)) + ?? "unknown"; + } + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/Nitro/FusionStageCompositionSettingsTests.cs b/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/Nitro/FusionStageCompositionSettingsTests.cs new file mode 100644 index 00000000000..755493b43d1 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/Nitro/FusionStageCompositionSettingsTests.cs @@ -0,0 +1,182 @@ +using System.Text.Json; + +namespace HotChocolate.Fusion.Aspire.Nitro; + +public sealed class FusionStageCompositionSettingsTests : IAsyncLifetime +{ + private FakeNitroServer _server = null!; + + public async ValueTask InitializeAsync() => _server = await FakeNitroServer.StartAsync(); + + public async ValueTask DisposeAsync() => await _server.DisposeAsync(); + + [Fact] + public async Task GetStageCompositionSettingsAsync_Should_SendTheOperation_When_ItIsCalled() + { + // arrange + _server.GraphQLHandler = _ => FakeNitroResponse.Json( + """{"data":{"node":{"stage":{"compositionSettings":null}}}}"""); + using var api = CreateApi(); + + // act + await api.GetStageCompositionSettingsAsync( + CreateTarget(), + "production", + TestContext.Current.CancellationToken); + + // assert + var request = Assert.Single(_server.Requests); + + Assert.Equal("nitro-api-key", request.Headers[NitroRequestHeaders.ApiKey]); +#if !NITRO_PERSISTED_OPERATIONS + request.Body.MatchInlineSnapshot( + """ + {"query":"query GetNitroCompositionSettings($apiId: ID!, $stageName: String!) {\n node(id: $apiId) {\n ... on Api {\n stage(name: $stageName) {\n compositionSettings {\n cacheControlMergeBehavior\n enableGlobalObjectIdentification\n excludeByTag\n nodeResolution\n removeUnreferencedDefinitions\n tagMergeBehavior\n }\n }\n }\n }\n}\n","operationName":"GetNitroCompositionSettings","variables":{"apiId":"api-1","stageName":"production"}} + """); +#else + request.Body.MatchInlineSnapshot( + """ + {"id":"576b1d39b8ed179da29e50247574aaae26d979591a4bbe53fcaf850fe2b02351","operationName":"GetNitroCompositionSettings","variables":{"apiId":"api-1","stageName":"production"}} + """); +#endif + } + + [Fact] + public async Task GetStageCompositionSettingsAsync_Should_ReadSettings_When_StageDeclaresThem() + { + // arrange + _server.GraphQLHandler = _ => FakeNitroResponse.Json( + """ + {"data":{"node":{"stage":{"compositionSettings":{ + "cacheControlMergeBehavior":"INCLUDE_PRIVATE", + "enableGlobalObjectIdentification":true, + "excludeByTag":["internal"], + "nodeResolution":"SOURCE_SCHEMA", + "removeUnreferencedDefinitions":true, + "tagMergeBehavior":"IGNORE"}}}}} + """); + using var api = CreateApi(); + + // act + var settings = await api.GetStageCompositionSettingsAsync( + CreateTarget(), + "production", + TestContext.Current.CancellationToken); + + // assert + Assert.NotNull(settings); + JsonSerializer.Serialize( + JsonSerializer.SerializeToDocument( + settings, + SettingsJsonSerializerContext.Default.CompositionSettings) + .RootElement, + new JsonSerializerOptions { WriteIndented = true }) + .MatchInlineSnapshot( + """ + { + "preprocessor": { + "excludeByTag": [ + "internal" + ] + }, + "merger": { + "addFusionDefinitions": null, + "cacheControlMergeBehavior": "IncludePrivate", + "enableGlobalObjectIdentification": true, + "nodeResolution": "SourceSchema", + "removeUnreferencedDefinitions": true, + "tagMergeBehavior": "Ignore" + }, + "satisfiability": { + "includeSatisfiabilityPaths": null + }, + "apolloFederationCompatibility": { + "allowNonResolvableInterfaceObjects": null, + "shareableFieldRuntimeTypeRouting": null + } + } + """); + } + + [Fact] + public async Task + GetStageCompositionSettingsAsync_Should_ReturnNull_When_StageDeclaresNoSettings() + { + // arrange + _server.GraphQLHandler = _ => FakeNitroResponse.Json( + """{"data":{"node":{"stage":{"compositionSettings":null}}}}"""); + using var api = CreateApi(); + + // act + var settings = await api.GetStageCompositionSettingsAsync( + CreateTarget(), + "production", + TestContext.Current.CancellationToken); + + // assert + Assert.Null(settings); + } + + [Fact] + public async Task + TryGetStageCompositionSettingsAsync_Should_WarnAndYieldNull_When_FieldIsUnknown() + { + // arrange + // a Nitro server that predates the stage composition settings rejects the field + _server.GraphQLHandler = _ => FakeNitroResponse.Json( + """ + {"errors":[{"message":"The field `compositionSettings` does not exist.", + "extensions":{"code":"HC0020"}}]} + """); + using var api = CreateApi(); + var workflow = new FusionDeploymentWorkflow(api); + var logger = new RecordingLogger(); + + // act + var settings = await workflow.TryGetStageCompositionSettingsAsync( + CreateTarget(), + "production", + logger, + TestContext.Current.CancellationToken); + + // assert + Assert.Null(settings); + Assert.Equal( + "The composition settings of stage production could not be downloaded, so the " + + "composition only uses the settings of the distributed application. Update the " + + "Nitro server to the latest version. Nitro returned GraphQL errors for the " + + "composition settings operation: The field `compositionSettings` does not exist.", + Assert.Single(logger.Entries).Message); + } + + [Fact] + public async Task + TryGetStageCompositionSettingsAsync_Should_Throw_When_ServerRejectsTheRequest() + { + // arrange + _server.GraphQLHandler = _ => FakeNitroResponse.Json( + """{"errors":[{"message":"The current user is not authorized."}]}"""); + using var api = CreateApi(); + var workflow = new FusionDeploymentWorkflow(api); + + // act + var exception = await Assert.ThrowsAsync( + () => workflow.TryGetStageCompositionSettingsAsync( + CreateTarget(), + "production", + new RecordingLogger(), + TestContext.Current.CancellationToken)); + + // assert + Assert.Equal( + "Nitro returned GraphQL errors for the composition settings operation: " + + "The current user is not authorized.", + exception.Message); + } + + private static NitroFusionApi CreateApi() + => new(new HttpClient(), disposeHttpClient: true); + + private FusionTarget CreateTarget() + => new(_server.BaseAddress, "api-1", "nitro-api-key"); +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/Nitro/NitroFusionApiTests.cs b/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/Nitro/NitroFusionApiTests.cs new file mode 100644 index 00000000000..92f1300d911 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/Nitro/NitroFusionApiTests.cs @@ -0,0 +1,223 @@ +using System.Buffers; + +namespace HotChocolate.Fusion.Aspire.Nitro; + +public sealed class NitroFusionApiTests +{ + [Fact] + public async Task ReadSourceSchemaArchiveAsync_Should_RejectBody_When_DeclaredLengthIsTooLarge() + { + using var content = new ByteArrayContent([1]); + content.Headers.ContentLength = 17; + + var exception = await Assert.ThrowsAsync( + () => NitroFusionApi.ReadSourceSchemaArchiveAsync( + content, + 16, + TestContext.Current.CancellationToken)); + + Assert.Equal( + "The compressed Fusion source schema archive exceeds the maximum " + + "allowed size of 16 bytes.", + exception.Message); + } + + [Fact] + public async Task ReadSourceSchemaArchiveAsync_Should_RejectBody_When_ChunkedBodyIsTooLarge() + { + await using var stream = new NonSeekableReadStream(new byte[17]); + using var content = new StreamContent(stream); + var bufferPool = new TrackingArrayPool(); + Assert.Null(content.Headers.ContentLength); + + var exception = await Assert.ThrowsAsync( + () => NitroFusionApi.ReadSourceSchemaArchiveAsync( + content, + 16, + bufferPool, + 4, + TestContext.Current.CancellationToken)); + + Assert.Equal( + "The compressed Fusion source schema archive exceeds the maximum " + + "allowed size of 16 bytes.", + exception.Message); + AssertAllBuffersCleared(bufferPool); + } + + [Fact] + public async Task ReadSourceSchemaArchiveAsync_Should_Cancel_When_ReadIsCanceled() + { + await using var stream = new BlockingReadStream(); + using var content = new StreamContent(stream); + using var cancellation = new CancellationTokenSource(); + var bufferPool = new TrackingArrayPool(); + var readTask = NitroFusionApi.ReadSourceSchemaArchiveAsync( + content, + 16, + bufferPool, + 4, + cancellation.Token); + + await stream.ReadStarted.Task.WaitAsync( + TimeSpan.FromSeconds(5), + TestContext.Current.CancellationToken); + await cancellation.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => readTask); + AssertAllBuffersCleared(bufferPool); + } + + [Fact] + public async Task ReadSourceSchemaArchiveAsync_Should_ClearEveryRentedBuffer_When_BodyGrows() + { + byte[] expected = [1, 2, 3, 4, 5, 6, 7, 8, 9]; + await using var stream = new NonSeekableReadStream(expected); + using var content = new StreamContent(stream); + var bufferPool = new TrackingArrayPool(); + + var archive = await NitroFusionApi.ReadSourceSchemaArchiveAsync( + content, + 16, + bufferPool, + 4, + TestContext.Current.CancellationToken); + + Assert.Equal(expected, archive); + Assert.Equal(3, bufferPool.Rented.Count); + AssertAllBuffersCleared(bufferPool); + } + + private static void AssertAllBuffersCleared(TrackingArrayPool bufferPool) + { + Assert.Equal(bufferPool.Rented.Count, bufferPool.Returned.Count); + Assert.All(bufferPool.Returned, returned => Assert.True(returned.ClearArray)); + Assert.All( + bufferPool.Rented, + buffer => Assert.Equal(new byte[buffer.Length], buffer)); + } + + private sealed class NonSeekableReadStream(byte[] content) : Stream + { + private readonly MemoryStream _inner = new(content, writable: false); + + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + => _inner.Read(buffer, offset, count); + + public override ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default) + => _inner.ReadAsync(buffer, cancellationToken); + + public override long Seek(long offset, SeekOrigin origin) + => throw new NotSupportedException(); + + public override void SetLength(long value) + => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) + => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _inner.Dispose(); + } + + base.Dispose(disposing); + } + } + + private sealed class BlockingReadStream : Stream + { + public TaskCompletionSource ReadStarted { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + => throw new NotSupportedException(); + + public override async ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default) + { + ReadStarted.TrySetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return 0; + } + + public override long Seek(long offset, SeekOrigin origin) + => throw new NotSupportedException(); + + public override void SetLength(long value) + => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) + => throw new NotSupportedException(); + } + + private sealed class TrackingArrayPool : ArrayPool + { + public List Rented { get; } = []; + + public List<(byte[] Buffer, bool ClearArray)> Returned { get; } = []; + + public override byte[] Rent(int minimumLength) + { + var buffer = new byte[minimumLength]; + Rented.Add(buffer); + return buffer; + } + + public override void Return(byte[] array, bool clearArray = false) + { + if (Returned.Any(returned => ReferenceEquals(returned.Buffer, array))) + { + throw new InvalidOperationException("The buffer was returned twice."); + } + + Returned.Add((array, clearArray)); + + if (clearArray) + { + array.AsSpan().Clear(); + } + } + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/Nitro/NitroOperationDocumentsTests.cs b/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/Nitro/NitroOperationDocumentsTests.cs index f6f2216f8b2..bcaacebe5bc 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/Nitro/NitroOperationDocumentsTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/Nitro/NitroOperationDocumentsTests.cs @@ -1,7 +1,73 @@ +using System.Security.Cryptography; +using System.Text; +using HotChocolate.Language; + namespace HotChocolate.Fusion.Aspire.Nitro; public sealed class NitroOperationDocumentsTests { + /// + /// The persisted operation id of every operation. The ids are the sidecars that + /// .github/scripts/nitro-aspire-operations.sh generates and that Nitro registers, so + /// they are pinned here rather than recomputed. + /// + public static TheoryData OperationIds => + new() + { + { + NitroOperationDocuments.ResolveApiNameOperationName, + "6480d908b5fe1cad49198376e03c5547c83234d90c154c9566324717cda41f45" + }, + { + NitroOperationDocuments.ValidateSchemaOperationName, + "89e8800b8720401041061528aef1101683b241d7d24c7f0912928a1a901def02" + }, + { + NitroOperationDocuments.WatchSchemaValidationOperationName, + "22190a0146ff00a9719826ea025019ea279b61a1be0d88b0b21015acd1f88dc4" + }, + { + NitroOperationDocuments.GetStageVersionOperationName, + "32cc7d1ee75aaa16627d21ee9289b4758f805d8b1eedfa796d832bae05b840f1" + }, + { + NitroOperationDocuments.WatchStageOperationName, + "6626c4ef1d416a1db6a56bed1f19ab68f8797bb3e9bf79b31d2434b41a0d1d6e" + }, + { + NitroOperationDocuments.UploadSourceSchemaOperationName, + "1114c593d527acede0596dc456f59242f899101ed4c089314adaa60cdddcff79" + }, + { + NitroOperationDocuments.BeginDeploymentOperationName, + "ceab775204b86a28d147d5799f39b5b05cd2e7abb93d337df315432f5b8a32ac" + }, + { + NitroOperationDocuments.ClaimDeploymentOperationName, + "1efc9d275a44832a90a1a5227e280de66582e21e4e83bc89ae08fe8f0b3c69e1" + }, + { + NitroOperationDocuments.ValidateDeploymentOperationName, + "e5d213965c3e14e470ba31e9c238147ebd35fcec42b636960a7c2dcc535e1325" + }, + { + NitroOperationDocuments.CommitDeploymentOperationName, + "74a2f5e237fb7213158efed61f9b3e48d420bb3fe45b2f0bce82ecff64319edd" + }, + { + NitroOperationDocuments.ReleaseDeploymentOperationName, + "0ce52927ce212dfd36404a8a74db96c06be1e791845c526d8c0b0a8ded1a4a61" + }, + { + NitroOperationDocuments.WatchDeploymentOperationName, + "25fff4ef7a751b2d9b1008f353dc23c89b8aed8978334407d3aa9a83806839b1" + }, + { + NitroOperationDocuments.GetCompositionSettingsOperationName, + "576b1d39b8ed179da29e50247574aaae26d979591a4bbe53fcaf850fe2b02351" + } + }; + #if !NITRO_PERSISTED_OPERATIONS [Fact] public void GetResolveApiNameDocument_Should_ReturnTheEmbeddedDocument() @@ -23,53 +89,36 @@ ... on Api { """); } - [Fact] - public void ResolveApiNameOperationName_Should_MatchTheOperationInTheDocument() - { - // act - var document = NitroOperationDocuments.GetResolveApiNameDocument(); - - // assert - Assert.Contains( - $"query {NitroOperationDocuments.ResolveApiNameOperationName}(", - document, - StringComparison.Ordinal); - } - - [Fact] - public void GetCompositionSettingsOperationName_Should_MatchTheOperationInTheDocument() - { - // act - var document = NitroOperationDocuments.GetCompositionSettingsDocument(); - - // assert - Assert.Contains( - $"query {NitroOperationDocuments.GetCompositionSettingsOperationName}(", - document, - StringComparison.Ordinal); - } - [Theory] + [InlineData("ResolveNitroApiName", "query ResolveNitroApiName(")] [InlineData("ValidateNitroSchema", "mutation ValidateNitroSchema(")] - [InlineData("PollNitroSchemaValidation", "mutation PollNitroSchemaValidation(")] - public void ValidationOperationName_Should_MatchTheOperationInTheDocument( + [InlineData("WatchNitroSchemaValidation", "subscription WatchNitroSchemaValidation(")] + [InlineData("GetNitroStageVersion", "query GetNitroStageVersion(")] + [InlineData("WatchNitroStage", "subscription WatchNitroStage(")] + [InlineData("UploadFusionSourceSchema", "mutation UploadFusionSourceSchema(")] + [InlineData("BeginFusionDeployment", "mutation BeginFusionDeployment(")] + [InlineData("ClaimFusionDeployment", "mutation ClaimFusionDeployment(")] + [InlineData("ValidateFusionDeployment", "mutation ValidateFusionDeployment(")] + [InlineData("CommitFusionDeployment", "mutation CommitFusionDeployment(")] + [InlineData("ReleaseFusionDeployment", "mutation ReleaseFusionDeployment(")] + [InlineData("WatchFusionDeployment", "subscription WatchFusionDeployment(")] + [InlineData("GetNitroCompositionSettings", "query GetNitroCompositionSettings(")] + public void OperationName_Should_MatchTheOperationInTheDocument( string operationName, string expectedDeclaration) { // act - var document = operationName == NitroOperationDocuments.ValidateSchemaOperationName - ? NitroOperationDocuments.GetValidateSchemaDocument() - : NitroOperationDocuments.GetPollSchemaValidationDocument(); + var document = GetDocument(operationName); // assert Assert.Contains(expectedDeclaration, document, StringComparison.Ordinal); } [Fact] - public void GetPollSchemaValidationDocument_Should_RequestNestedSchemaChangeDetails() + public void GetWatchSchemaValidationDocument_Should_RequestNestedSchemaChangeDetails() { // act - var document = NitroOperationDocuments.GetPollSchemaValidationDocument(); + var document = NitroOperationDocuments.GetWatchSchemaValidationDocument(); // assert // the fragments must be defined and actually spread into the selections @@ -86,86 +135,99 @@ public void GetPollSchemaValidationDocument_Should_RequestNestedSchemaChangeDeta } [Theory] - [InlineData("GetNitroStageVersion", "query GetNitroStageVersion(")] - [InlineData("WatchNitroStage", "subscription WatchNitroStage(")] - public void StageUpdateOperationName_Should_MatchTheOperationInTheDocument( + [MemberData(nameof(OperationIds))] + public void GetDocument_Should_HashToTheOperationId( string operationName, - string expectedDeclaration) + string expectedOperationId) { // act - var document = operationName == NitroOperationDocuments.GetStageVersionOperationName - ? NitroOperationDocuments.GetStageVersionDocument() - : NitroOperationDocuments.GetWatchStageDocument(); + // the document that is sent on the wire has to be exactly the document that the sidecar + // was computed over, otherwise a release build sends an id that Nitro never persisted. + var document = Utf8GraphQLParser.Parse(GetDocument(operationName)).ToString(false); + var operationId = Convert.ToHexStringLower( + SHA256.HashData(Encoding.UTF8.GetBytes(document))); // assert - Assert.Contains(expectedDeclaration, document, StringComparison.Ordinal); + Assert.Equal(expectedOperationId, operationId); } + private static string GetDocument(string operationName) + => operationName switch + { + NitroOperationDocuments.ResolveApiNameOperationName + => NitroOperationDocuments.GetResolveApiNameDocument(), + NitroOperationDocuments.ValidateSchemaOperationName + => NitroOperationDocuments.GetValidateSchemaDocument(), + NitroOperationDocuments.WatchSchemaValidationOperationName + => NitroOperationDocuments.GetWatchSchemaValidationDocument(), + NitroOperationDocuments.GetStageVersionOperationName + => NitroOperationDocuments.GetStageVersionDocument(), + NitroOperationDocuments.WatchStageOperationName + => NitroOperationDocuments.GetWatchStageDocument(), + NitroOperationDocuments.UploadSourceSchemaOperationName + => NitroOperationDocuments.GetUploadSourceSchemaDocument(), + NitroOperationDocuments.BeginDeploymentOperationName + => NitroOperationDocuments.GetBeginDeploymentDocument(), + NitroOperationDocuments.ClaimDeploymentOperationName + => NitroOperationDocuments.GetClaimDeploymentDocument(), + NitroOperationDocuments.ValidateDeploymentOperationName + => NitroOperationDocuments.GetValidateDeploymentDocument(), + NitroOperationDocuments.CommitDeploymentOperationName + => NitroOperationDocuments.GetCommitDeploymentDocument(), + NitroOperationDocuments.ReleaseDeploymentOperationName + => NitroOperationDocuments.GetReleaseDeploymentDocument(), + NitroOperationDocuments.WatchDeploymentOperationName + => NitroOperationDocuments.GetWatchDeploymentDocument(), + NitroOperationDocuments.GetCompositionSettingsOperationName + => NitroOperationDocuments.GetCompositionSettingsDocument(), + _ => throw new ArgumentOutOfRangeException(nameof(operationName)) + }; #endif #if NITRO_PERSISTED_OPERATIONS - [Fact] - public void GetResolveApiNameOperationId_Should_ReturnTheEmbeddedHash() - { - // act - var operationId = NitroOperationDocuments.GetResolveApiNameOperationId(); - - // assert - Assert.Equal( - "6480d908b5fe1cad49198376e03c5547c83234d90c154c9566324717cda41f45", - operationId); - } - - [Fact] - public void GetCompositionSettingsOperationId_Should_ReturnTheEmbeddedHash() - { - // act - var operationId = NitroOperationDocuments.GetCompositionSettingsOperationId(); - - // assert - Assert.Equal( - "576b1d39b8ed179da29e50247574aaae26d979591a4bbe53fcaf850fe2b02351", - operationId); - } - [Theory] - [InlineData( - "ValidateNitroSchema", - "89e8800b8720401041061528aef1101683b241d7d24c7f0912928a1a901def02")] - [InlineData( - "PollNitroSchemaValidation", - "9c6725ca85cf073343e4136f6a9b53353328156eb4335363fe624bfefc86bdd3")] - public void GetValidationOperationId_Should_ReturnTheEmbeddedHash( + [MemberData(nameof(OperationIds))] + public void GetOperationId_Should_ReturnTheEmbeddedHash( string operationName, string expectedOperationId) { // act - var operationId = operationName == NitroOperationDocuments.ValidateSchemaOperationName - ? NitroOperationDocuments.GetValidateSchemaOperationId() - : NitroOperationDocuments.GetPollSchemaValidationOperationId(); + var operationId = GetOperationId(operationName); // assert Assert.Equal(expectedOperationId, operationId); } - [Theory] - [InlineData( - "GetNitroStageVersion", - "32cc7d1ee75aaa16627d21ee9289b4758f805d8b1eedfa796d832bae05b840f1")] - [InlineData( - "WatchNitroStage", - "6626c4ef1d416a1db6a56bed1f19ab68f8797bb3e9bf79b31d2434b41a0d1d6e")] - public void GetStageUpdateOperationId_Should_ReturnTheEmbeddedHash( - string operationName, - string expectedOperationId) - { - // act - var operationId = operationName == NitroOperationDocuments.GetStageVersionOperationName - ? NitroOperationDocuments.GetStageVersionOperationId() - : NitroOperationDocuments.GetWatchStageOperationId(); - - // assert - Assert.Equal(expectedOperationId, operationId); - } + private static string GetOperationId(string operationName) + => operationName switch + { + NitroOperationDocuments.ResolveApiNameOperationName + => NitroOperationDocuments.GetResolveApiNameOperationId(), + NitroOperationDocuments.ValidateSchemaOperationName + => NitroOperationDocuments.GetValidateSchemaOperationId(), + NitroOperationDocuments.WatchSchemaValidationOperationName + => NitroOperationDocuments.GetWatchSchemaValidationOperationId(), + NitroOperationDocuments.GetStageVersionOperationName + => NitroOperationDocuments.GetStageVersionOperationId(), + NitroOperationDocuments.WatchStageOperationName + => NitroOperationDocuments.GetWatchStageOperationId(), + NitroOperationDocuments.UploadSourceSchemaOperationName + => NitroOperationDocuments.GetUploadSourceSchemaOperationId(), + NitroOperationDocuments.BeginDeploymentOperationName + => NitroOperationDocuments.GetBeginDeploymentOperationId(), + NitroOperationDocuments.ClaimDeploymentOperationName + => NitroOperationDocuments.GetClaimDeploymentOperationId(), + NitroOperationDocuments.ValidateDeploymentOperationName + => NitroOperationDocuments.GetValidateDeploymentOperationId(), + NitroOperationDocuments.CommitDeploymentOperationName + => NitroOperationDocuments.GetCommitDeploymentOperationId(), + NitroOperationDocuments.ReleaseDeploymentOperationName + => NitroOperationDocuments.GetReleaseDeploymentOperationId(), + NitroOperationDocuments.WatchDeploymentOperationName + => NitroOperationDocuments.GetWatchDeploymentOperationId(), + NitroOperationDocuments.GetCompositionSettingsOperationName + => NitroOperationDocuments.GetCompositionSettingsOperationId(), + _ => throw new ArgumentOutOfRangeException(nameof(operationName)) + }; #endif } diff --git a/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/Nitro/NitroSchemaCompositionTests.cs b/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/Nitro/NitroSchemaCompositionTests.cs index d0d3901d3b8..21ef1adcf0e 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/Nitro/NitroSchemaCompositionTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/Nitro/NitroSchemaCompositionTests.cs @@ -1056,7 +1056,6 @@ private NitroSeedCoordinator CreateCoordinator( validator ?? new NitroSchemaValidator( GraphQLHttpClient.Create(_httpClient, disposeHttpClient: false), - _timeProvider, new RecordingLogger()), new NoopStageUpdateClient(), new NitroCompositionSettingsClient( diff --git a/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/Nitro/NitroSchemaValidatorTests.cs b/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/Nitro/NitroSchemaValidatorTests.cs index 75dda5cd66f..bf8f3d6ad3e 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/Nitro/NitroSchemaValidatorTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/Nitro/NitroSchemaValidatorTests.cs @@ -2,7 +2,6 @@ using System.Net.Http.Headers; using System.Text; using HotChocolate.Transport.Http; -using Microsoft.Extensions.Time.Testing; namespace HotChocolate.Fusion.Aspire.Nitro; @@ -18,9 +17,10 @@ public async Task ValidateAsync_Should_ReturnPassed_When_ValidationSucceedsAfter // arrange using var session = new ValidatorSession( Json(StartSuccess), - Json(PollResult("""{"__typename":"OperationInProgress","state":"PROCESSING"}""")), - Json(PollResult("""{"__typename":"ValidationInProgress","state":"PROCESSING"}""")), - Json(PollResult("""{"__typename":"SchemaVersionValidationSuccess","state":"SUCCESS"}"""))); + Sse( + WatchResult("""{"__typename":"OperationInProgress","state":"PROCESSING"}"""), + WatchResult("""{"__typename":"ValidationInProgress","state":"PROCESSING"}"""), + WatchResult("""{"__typename":"SchemaVersionValidationSuccess","state":"SUCCESS"}"""))); // act var report = await session.ValidateAsync(); @@ -36,8 +36,8 @@ public async Task ValidateAsync_Should_ReturnPassed_When_ValidationSucceedsAfter """ Status: Passed Request ID: request-1 - Calls: 4 - Operations: ValidateNitroSchema, PollNitroSchemaValidation, PollNitroSchemaValidation, PollNitroSchemaValidation + Calls: 2 + Operations: ValidateNitroSchema, WatchNitroSchemaValidation Start wire: multipart=True, apiId=True, stage=True, schema=True, apiKey=nitro-api-key """); } @@ -51,7 +51,7 @@ public async Task ValidateAsync_Should_ReturnStructuredViolations_When_TerminalF // arrange using var session = new ValidatorSession( Json(StartSuccess), - Json(PollFailure(error))); + Sse(WatchFailure(error))); // act var report = await session.ValidateAsync(); @@ -80,23 +80,83 @@ public async Task ValidateAsync_Should_ReturnUnavailable_When_StartReturnsAnErro } [Theory] - [InlineData("UnauthorizedOperation", "The credential is invalid.")] - [InlineData("SchemaVersionRequestNotFoundError", "The validation request does not exist.")] - public async Task ValidateAsync_Should_ReturnUnavailable_When_PollReturnsAnError( - string typeName, + [InlineData("The credential is invalid.")] + [InlineData("The validation request does not exist.")] + public async Task ValidateAsync_Should_ReturnUnavailable_When_SubscriptionReturnsAnError( string message) { // arrange using var session = new ValidatorSession( Json(StartSuccess), - Json(PollFailurePayload(typeName, message))); + Sse(WatchError(message))); // act var report = await session.ValidateAsync(); // assert Assert.Equal( - $"Unavailable|request=request-1|reason={typeName}: {message}|calls=2", + $"Unavailable|request=request-1|reason=Nitro returned GraphQL errors: {message}" + + "|calls=2", + DescribeUnavailable(report, session.Handler.Requests.Count)); + } + + [Fact] + public async Task ValidateAsync_Should_ReturnUnavailable_When_SubscriptionEndsWithoutAResult() + { + // arrange + using var session = new ValidatorSession( + Json(StartSuccess), + Sse(WatchResult("""{"__typename":"ValidationInProgress","state":"PROCESSING"}"""))); + + // act + var report = await session.ValidateAsync(); + + // assert + Assert.Equal( + "Unavailable|request=request-1|reason=Nitro ended the subscription without a result." + + "|calls=2", + DescribeUnavailable(report, session.Handler.Requests.Count)); + } + + [Fact] + public async Task ValidateAsync_Should_ReturnUnavailable_When_SubscriptionIsNotAnEventStream() + { + // arrange + // A server that ignores the pinned Accept header answers with a single JSON result. + using var session = new ValidatorSession( + Json(StartSuccess), + Json(WatchResult("""{"__typename":"SchemaVersionValidationSuccess","state":"SUCCESS"}"""))); + + // act + var report = await session.ValidateAsync(); + + // assert + Assert.Equal( + "Unavailable|request=request-1|reason=Nitro answered the subscription with the " + + "content type 'application/json' instead of text/event-stream.|calls=2", + DescribeUnavailable(report, session.Handler.Requests.Count)); + } + + [Fact] + public async Task ValidateAsync_Should_ReturnUnavailable_When_SubscriptionExceedsTheEventLimit() + { + // arrange + var events = Enumerable + .Repeat( + WatchResult("""{"__typename":"ValidationInProgress","state":"PROCESSING"}"""), + 1001) + .ToArray(); + using var session = new ValidatorSession( + Json(StartSuccess), + Sse(events)); + + // act + var report = await session.ValidateAsync(); + + // assert + Assert.Equal( + "Unavailable|request=request-1|reason=Nitro sent too many subscription events." + + "|calls=2", DescribeUnavailable(report, session.Handler.Requests.Count)); } @@ -106,8 +166,8 @@ public async Task ValidateAsync_Should_UseClientIdAndQueryMessage_When_ClientIsN // arrange using var session = new ValidatorSession( Json(StartSuccess), - Json( - PollFailure( + Sse( + WatchFailure( """ { "__typename": "PersistedQueryValidationError", @@ -199,7 +259,7 @@ public async Task ValidateAsync_Should_ReturnUnavailable_When_ResponseIsMalforme // arrange using var session = new ValidatorSession( Json(StartSuccess), - Json(response)); + Sse(response)); // act var report = await session.ValidateAsync(); @@ -216,7 +276,7 @@ public async Task ValidateAsync_Should_ReturnUnavailable_When_ResponseContainsIn // arrange using var session = new ValidatorSession( Json(StartSuccess), - Json("{")); + Sse("{")); // act var report = await session.ValidateAsync(); @@ -234,7 +294,7 @@ public async Task ValidateAsync_Should_ReturnUnavailable_When_ResultTypeIsUnknow // arrange using var session = new ValidatorSession( Json(StartSuccess), - Json(PollResult("""{"__typename":"FutureValidationResult","state":"SUCCESS"}"""))); + Sse(WatchResult("""{"__typename":"FutureValidationResult","state":"SUCCESS"}"""))); // act var report = await session.ValidateAsync(); @@ -252,8 +312,8 @@ public async Task ValidateAsync_Should_ReturnUnavailable_When_FailureTypeIsUnkno // arrange using var session = new ValidatorSession( Json(StartSuccess), - Json( - PollFailure( + Sse( + WatchFailure( """ { "__typename": "FutureValidationError", @@ -277,8 +337,8 @@ public async Task ValidateAsync_Should_KeepKnownFindings_When_FailureAlsoContain // arrange using var session = new ValidatorSession( Json(StartSuccess), - Json( - PollFailure( + Sse( + WatchFailure( """ { "__typename": "SchemaVersionSyntaxError", @@ -312,7 +372,6 @@ public async Task ValidateAsync_Should_EnforceResponseLimit_When_ContentLengthIs var oversizedContent = ChunkedContent(new byte[2 * 1024 * 1024 + 1]); var contentLength = oversizedContent.Headers.ContentLength; using var session = new ValidatorSession( - Json(StartSuccess), Returns( new HttpResponseMessage(HttpStatusCode.OK) { @@ -333,7 +392,7 @@ public async Task ValidateAsync_Should_EnforceResponseLimit_When_ContentLengthIs Status: Unavailable Reason: Nitro schema validation was unavailable: Nitro returned a schema validation response that exceeded the size limit. Content-Length: - Calls: 2 + Calls: 1 """); } @@ -355,14 +414,14 @@ public async Task ValidateAsync_Should_NotRetry_When_StartReturnsTransientStatus } [Fact] - public async Task ValidateAsync_Should_RetryTransientFailures_When_Polling() + public async Task ValidateAsync_Should_RetryTransientFailures_When_Subscribing() { // arrange using var session = new ValidatorSession( Json(StartSuccess), Status(HttpStatusCode.ServiceUnavailable), Status(HttpStatusCode.TooManyRequests), - Json(PollResult("""{"__typename":"SchemaVersionValidationSuccess","state":"SUCCESS"}"""))); + Sse(WatchResult("""{"__typename":"SchemaVersionValidationSuccess","state":"SUCCESS"}"""))); // act var report = await session.ValidateAsync(); @@ -370,8 +429,8 @@ public async Task ValidateAsync_Should_RetryTransientFailures_When_Polling() // assert Assert.Equal( "Passed|request=request-1|calls=4|operations=ValidateNitroSchema," - + "PollNitroSchemaValidation,PollNitroSchemaValidation," - + "PollNitroSchemaValidation", + + "WatchNitroSchemaValidation,WatchNitroSchemaValidation," + + "WatchNitroSchemaValidation", $"{report.Status}|request={report.RequestId}|calls={session.Handler.Requests.Count}" + $"|operations={string.Join(',', session.Handler.Requests.Select(r => r.OperationName))}"); } @@ -380,14 +439,14 @@ public async Task ValidateAsync_Should_RetryTransientFailures_When_Polling() [InlineData(HttpStatusCode.BadRequest)] [InlineData(HttpStatusCode.Unauthorized)] [InlineData(HttpStatusCode.Forbidden)] - public async Task ValidateAsync_Should_NotRetry_When_PollReturnsNonTransientStatus( + public async Task ValidateAsync_Should_NotRetry_When_SubscribeReturnsNonTransientStatus( HttpStatusCode statusCode) { // arrange using var session = new ValidatorSession( Json(StartSuccess), Status(statusCode), - Json(PollResult("""{"__typename":"SchemaVersionValidationSuccess","state":"SUCCESS"}"""))); + Sse(WatchResult("""{"__typename":"SchemaVersionValidationSuccess","state":"SUCCESS"}"""))); // act var report = await session.ValidateAsync(); @@ -677,27 +736,18 @@ public static TheoryData MalformedResponseData() { { """{"data":{}}""", - "Nitro returned a malformed validation polling response." + "Nitro returned no schema validation result." }, { """{"errors":[{"message":"GraphQL failed."}]}""", - "Nitro returned GraphQL errors." + "Nitro returned GraphQL errors: GraphQL failed." }, { - """ - { - "data": { - "pollSchemaVersionValidationRequest": { - "errors": [], - "result": null - } - } - } - """, + """{"data":{"onSchemaVersionValidationUpdate":null}}""", "Nitro returned no schema validation result." }, { - PollFailure(""), + WatchFailure(""), "Nitro returned a validation failure without findings." } }; @@ -767,47 +817,24 @@ private static string StartFailure(string typeName, string message) } """; - private static string PollFailurePayload(string typeName, string message) - => $$""" - { - "data": { - "pollSchemaVersionValidationRequest": { - "errors": [ - { - "__typename": "{{typeName}}", - "message": "{{message}}" - } - ], - "result": null - } - } - } - """; + private static string WatchError(string message) + => $$"""{"errors":[{"message":"{{message}}"}]}"""; - private static string PollFailure(params string[] errors) - => $$""" - { - "data": { - "pollSchemaVersionValidationRequest": { - "errors": [], - "result": { - "__typename": "SchemaVersionValidationFailed", - "state": "FAILED", - "errors": [{{string.Join(',', errors)}}] - } + private static string WatchFailure(params string[] errors) + => WatchResult( + $$""" + { + "__typename": "SchemaVersionValidationFailed", + "state": "FAILED", + "errors": [{{string.Join(',', errors)}}] } - } - } - """; + """); - private static string PollResult(string result) + private static string WatchResult(string result) => $$""" { "data": { - "pollSchemaVersionValidationRequest": { - "errors": [], - "result": {{result}} - } + "onSchemaVersionValidationUpdate": {{result}} } } """; @@ -819,6 +846,37 @@ private static ResponseStep Json(string json) Content = new StringContent(json, Encoding.UTF8, "application/json") }); + private static ResponseStep Sse(params string[] payloads) + => Returns( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + BuildEventStream(payloads), + Encoding.UTF8, + "text/event-stream") + }); + + private static string BuildEventStream(IEnumerable payloads) + { + var builder = new StringBuilder(); + + foreach (var payload in payloads) + { + builder.Append("event: next\n"); + + foreach (var line in payload.Split('\n')) + { + builder.Append("data: ").Append(line.TrimEnd('\r')).Append('\n'); + } + + builder.Append('\n'); + } + + builder.Append("event: complete\n\n"); + + return builder.ToString(); + } + private static ResponseStep Status(HttpStatusCode statusCode) => Returns( new HttpResponseMessage(statusCode) @@ -847,7 +905,6 @@ private static StreamContent ChunkedContent(byte[] content) private sealed class ValidatorSession : IDisposable { private readonly HttpClient _httpClient; - private readonly FakeTimeProvider _timeProvider = new(); private readonly NitroSchemaValidator _validator; public ValidatorSession(params ResponseStep[] responses) @@ -856,16 +913,14 @@ public ValidatorSession(params ResponseStep[] responses) _httpClient = new HttpClient(Handler); _validator = new NitroSchemaValidator( GraphQLHttpClient.Create(_httpClient, disposeHttpClient: false), - _timeProvider, new RecordingLogger()); } public ScriptedHttpMessageHandler Handler { get; } - public async Task ValidateAsync( + public Task ValidateAsync( NitroCredential? credential = null) - { - var validation = _validator.ValidateAsync( + => _validator.ValidateAsync( new NitroConnection( new Uri("https://api.example.test"), new Uri("https://api.example.test/graphql"), @@ -876,17 +931,6 @@ public async Task ValidateAsync( SchemaHash, TestContext.Current.CancellationToken); - for (var attempt = 0; attempt < 100 && !validation.IsCompleted; attempt++) - { - await Task.Yield(); - _timeProvider.Advance(TimeSpan.FromSeconds(5)); - } - - return await validation.WaitAsync( - TimeSpan.FromSeconds(10), - TestContext.Current.CancellationToken); - } - public void Dispose() => _httpClient.Dispose(); } @@ -909,9 +953,9 @@ protected override async Task SendAsync( StringComparison.Ordinal) ? NitroOperationDocuments.ValidateSchemaOperationName : body.Contains( - NitroOperationDocuments.PollSchemaValidationOperationName, + NitroOperationDocuments.WatchSchemaValidationOperationName, StringComparison.Ordinal) - ? NitroOperationDocuments.PollSchemaValidationOperationName + ? NitroOperationDocuments.WatchSchemaValidationOperationName : "unknown"; var apiKey = request.Headers.TryGetValues(NitroRequestHeaders.ApiKey, out var values) ? values.Single() diff --git a/src/HotChocolate/Fusion/test/Fusion.Packaging.Tests/FusionArchiveStorageTests.cs b/src/HotChocolate/Fusion/test/Fusion.Packaging.Tests/FusionArchiveStorageTests.cs new file mode 100644 index 00000000000..b2ee742a15a --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Packaging.Tests/FusionArchiveStorageTests.cs @@ -0,0 +1,178 @@ +using System.IO.Compression; +using System.Text.Json; + +namespace HotChocolate.Fusion.Packaging; + +public sealed class FusionArchiveStorageTests +{ + [Fact] + public async Task StreamCreateAndOpen_Should_NotCreateTempFiles() + { + var before = SnapshotFusionTempFiles(); + await using var stream = new MemoryStream(); + + using (var archive = FusionArchive.Create(stream, leaveOpen: true)) + { + await using var content = new MemoryStream("archive"u8.ToArray()); + await archive.SetLegacyArchiveFileAsync(content, TestContext.Current.CancellationToken); + await archive.CommitAsync(TestContext.Current.CancellationToken); + } + + stream.Position = 0; + using (var archive = FusionArchive.Open(stream, leaveOpen: true)) + { + await using var content = await archive.TryGetLegacyArchiveFileAsync( + TestContext.Current.CancellationToken); + Assert.NotNull(content); + } + + Assert.Equal(before, SnapshotFusionTempFiles()); + } + + [Fact] + public async Task PathOpen_Should_CleanTempFile_WhenExtractionIsCanceled() + { + var archivePath = System.IO.Path.GetTempFileName(); + + try + { + CreateZipEntry(archivePath, "legacy-v1-archive.fgp", "archive"); + var before = SnapshotFusionTempFiles(); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + using (var archive = FusionArchive.Open(archivePath)) + { + await Assert.ThrowsAnyAsync( + () => archive.TryGetLegacyArchiveFileAsync(cancellation.Token)); + } + + Assert.Equal(before, SnapshotFusionTempFiles()); + } + finally + { + File.Delete(archivePath); + } + } + + [Fact] + public async Task PathOpen_Should_CleanTempFile_WhenEntryIsOversized() + { + var archivePath = System.IO.Path.GetTempFileName(); + + try + { + CreateZipEntry( + archivePath, + "composition-settings.json", + new string('a', 512_001)); + var before = SnapshotFusionTempFiles(); + + using (var archive = FusionArchive.Open(archivePath)) + { + await Assert.ThrowsAsync( + () => archive.GetCompositionSettingsAsync(TestContext.Current.CancellationToken)); + } + + Assert.Equal(before, SnapshotFusionTempFiles()); + } + finally + { + File.Delete(archivePath); + } + } + + [Fact] + public async Task StreamCreate_Should_EnforceEntrySizeIncrementally() + { + await using var stream = new MemoryStream(); + using var archive = FusionArchive.Create( + stream, + new FusionArchiveOptions + { + MaxAllowedLegacyArchiveSize = 4, + MaxAllowedInMemorySessionSize = 100 + }); + await using var content = new MemoryStream("12345"u8.ToArray()); + + var exception = await Assert.ThrowsAsync( + () => archive.SetLegacyArchiveFileAsync(content, TestContext.Current.CancellationToken)); + + Assert.Equal("File is too large and exceeds the allowed size of 4.", exception.Message); + } + + [Fact] + public async Task StreamCreate_Should_EnforceTotalSessionSizeIncrementally() + { + await using var stream = new MemoryStream(); + using var archive = FusionArchive.Create( + stream, + new FusionArchiveOptions + { + MaxAllowedLegacyArchiveSize = 10, + MaxAllowedSettingsSize = 10, + MaxAllowedInMemorySessionSize = 6 + }); + await using var content = new MemoryStream("12345"u8.ToArray()); + await archive.SetLegacyArchiveFileAsync(content, TestContext.Current.CancellationToken); + using var settings = JsonDocument.Parse("{}"); + + var exception = await Assert.ThrowsAsync( + () => archive.SetCompositionSettingsAsync( + settings, + TestContext.Current.CancellationToken)); + + Assert.Equal("File is too large and exceeds the allowed size of 1.", exception.Message); + } + + [Fact] + public async Task MemoryAndTempStorage_Should_CreateIdenticalArchives() + { + var archivePath = System.IO.Path.GetTempFileName(); + var content = "deterministic archive"u8.ToArray(); + + try + { + using (var archive = FusionArchive.Create(archivePath)) + { + await using var input = new MemoryStream(content); + await archive.SetLegacyArchiveFileAsync(input, TestContext.Current.CancellationToken); + await archive.CommitAsync(TestContext.Current.CancellationToken); + } + + await using var stream = new MemoryStream(); + using (var archive = FusionArchive.Create(stream, leaveOpen: true)) + { + await using var input = new MemoryStream(content); + await archive.SetLegacyArchiveFileAsync(input, TestContext.Current.CancellationToken); + await archive.CommitAsync(TestContext.Current.CancellationToken); + } + + Assert.Equal( + await File.ReadAllBytesAsync( + archivePath, + TestContext.Current.CancellationToken), + stream.ToArray()); + } + finally + { + File.Delete(archivePath); + } + } + + private static string[] SnapshotFusionTempFiles() + => Directory + .EnumerateFiles( + System.IO.Path.GetTempPath(), + $"hotchocolate-fusion-{Environment.ProcessId}-*.tmp") + .Order(StringComparer.Ordinal) + .ToArray(); + + private static void CreateZipEntry(string archivePath, string entryName, string content) + { + using var archive = ZipFile.Open(archivePath, ZipArchiveMode.Update); + var entry = archive.CreateEntry(entryName); + using var writer = new StreamWriter(entry.Open()); + writer.Write(content); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Packaging.Tests/PooledMemoryArchiveEntryStorageTests.cs b/src/HotChocolate/Fusion/test/Fusion.Packaging.Tests/PooledMemoryArchiveEntryStorageTests.cs new file mode 100644 index 00000000000..2045d824b93 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Packaging.Tests/PooledMemoryArchiveEntryStorageTests.cs @@ -0,0 +1,45 @@ +using System.Buffers; +using HotChocolate.Fusion.Packaging.Storage; + +namespace HotChocolate.Fusion.Packaging; + +public sealed class PooledMemoryArchiveEntryStorageTests +{ + [Fact] + public void Dispose_Should_ClearRentedBufferBeforeReturningIt() + { + var pool = new RecordingArrayPool(); + var storage = new PooledMemoryArchiveEntryStorage(pool); + using (var stream = storage.OpenWrite(16)) + { + stream.Write("sensitive"u8); + } + + storage.Dispose(); + + Assert.True(pool.ClearArray); + Assert.NotNull(pool.ReturnedArray); + Assert.All(pool.ReturnedArray, value => Assert.Equal(0, value)); + } + + private sealed class RecordingArrayPool : ArrayPool + { + public byte[]? ReturnedArray { get; private set; } + + public bool ClearArray { get; private set; } + + public override byte[] Rent(int minimumLength) + => new byte[minimumLength]; + + public override void Return(byte[] array, bool clearArray = false) + { + if (clearArray) + { + array.AsSpan().Clear(); + } + + ReturnedArray = array; + ClearArray = clearArray; + } + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.SourceSchema.Packaging.Tests/FusionSourceSchemaArchiveStorageTests.cs b/src/HotChocolate/Fusion/test/Fusion.SourceSchema.Packaging.Tests/FusionSourceSchemaArchiveStorageTests.cs new file mode 100644 index 00000000000..4ac8f85158d --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.SourceSchema.Packaging.Tests/FusionSourceSchemaArchiveStorageTests.cs @@ -0,0 +1,184 @@ +using System.IO.Compression; +using System.Text.Json; + +namespace HotChocolate.Fusion.SourceSchema.Packaging; + +public sealed class FusionSourceSchemaArchiveStorageTests +{ + [Fact] + public async Task StreamCreateAndOpen_Should_NotCreateTempFiles() + { + var before = SnapshotFusionTempFiles(); + await using var stream = new MemoryStream(); + + using (var archive = FusionSourceSchemaArchive.Create(stream, leaveOpen: true)) + { + await SetRequiredEntriesAsync(archive); + await archive.CommitAsync(TestContext.Current.CancellationToken); + } + + stream.Position = 0; + using (var archive = FusionSourceSchemaArchive.Open(stream, leaveOpen: true)) + { + Assert.NotNull(await archive.GetArchiveMetadataAsync( + TestContext.Current.CancellationToken)); + Assert.NotNull(await archive.TryGetSchemaAsync( + TestContext.Current.CancellationToken)); + Assert.NotNull(await archive.TryGetSettingsAsync( + TestContext.Current.CancellationToken)); + } + + Assert.Equal(before, SnapshotFusionTempFiles()); + } + + [Fact] + public async Task PathOpen_Should_CleanTempFile_WhenExtractionIsCanceled() + { + var archivePath = System.IO.Path.GetTempFileName(); + + try + { + CreateZipEntry(archivePath, "schema.graphqls", "type Query { field: String }"); + var before = SnapshotFusionTempFiles(); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + using (var archive = FusionSourceSchemaArchive.Open(archivePath)) + { + await Assert.ThrowsAnyAsync( + () => archive.TryGetSchemaAsync(cancellation.Token)); + } + + Assert.Equal(before, SnapshotFusionTempFiles()); + } + finally + { + File.Delete(archivePath); + } + } + + [Fact] + public async Task PathOpen_Should_CleanTempFile_WhenEntryIsOversized() + { + var archivePath = System.IO.Path.GetTempFileName(); + + try + { + CreateZipEntry(archivePath, "schema-settings.json", new string('a', 512_001)); + var before = SnapshotFusionTempFiles(); + + using (var archive = FusionSourceSchemaArchive.Open(archivePath)) + { + await Assert.ThrowsAsync( + () => archive.TryGetSettingsAsync(TestContext.Current.CancellationToken)); + } + + Assert.Equal(before, SnapshotFusionTempFiles()); + } + finally + { + File.Delete(archivePath); + } + } + + [Fact] + public async Task StreamCreate_Should_EnforceEntrySizeIncrementally() + { + await using var stream = new MemoryStream(); + using var archive = FusionSourceSchemaArchive.Create( + stream, + new FusionSourceSchemaArchiveOptions + { + MaxAllowedSchemaSize = 4, + MaxAllowedInMemorySessionSize = 100 + }); + + var exception = await Assert.ThrowsAsync( + () => archive.SetSchemaAsync( + "12345"u8.ToArray(), + TestContext.Current.CancellationToken)); + + Assert.Equal("File is too large and exceeds the allowed size of 4.", exception.Message); + } + + [Fact] + public async Task StreamCreate_Should_EnforceTotalSessionSizeIncrementally() + { + await using var stream = new MemoryStream(); + using var archive = FusionSourceSchemaArchive.Create( + stream, + new FusionSourceSchemaArchiveOptions + { + MaxAllowedSchemaSize = 10, + MaxAllowedInMemorySessionSize = 6 + }); + await archive.SetSchemaAsync("12345"u8.ToArray(), TestContext.Current.CancellationToken); + + var exception = await Assert.ThrowsAsync( + () => archive.SetSchemaExtensionsAsync( + "12"u8.ToArray(), + TestContext.Current.CancellationToken)); + + Assert.Equal("File is too large and exceeds the allowed size of 1.", exception.Message); + } + + [Fact] + public async Task MemoryAndTempStorage_Should_CreateIdenticalArchives() + { + var archivePath = System.IO.Path.GetTempFileName(); + + try + { + using (var archive = FusionSourceSchemaArchive.Create(archivePath)) + { + await SetRequiredEntriesAsync(archive); + await archive.CommitAsync(TestContext.Current.CancellationToken); + } + + await using var stream = new MemoryStream(); + using (var archive = FusionSourceSchemaArchive.Create(stream, leaveOpen: true)) + { + await SetRequiredEntriesAsync(archive); + await archive.CommitAsync(TestContext.Current.CancellationToken); + } + + Assert.Equal( + await File.ReadAllBytesAsync( + archivePath, + TestContext.Current.CancellationToken), + stream.ToArray()); + } + finally + { + File.Delete(archivePath); + } + } + + private static async Task SetRequiredEntriesAsync(FusionSourceSchemaArchive archive) + { + await archive.SetArchiveMetadataAsync( + new ArchiveMetadata { FormatVersion = new Version(2, 0) }, + TestContext.Current.CancellationToken); + await archive.SetSchemaAsync( + "type Query { field: String }"u8.ToArray(), + TestContext.Current.CancellationToken); + using var settings = JsonDocument.Parse("{}"); + await archive.SetSettingsAsync(settings, TestContext.Current.CancellationToken); + } + + private static string[] SnapshotFusionTempFiles() + => Directory + .EnumerateFiles( + System.IO.Path.GetTempPath(), + $"hotchocolate-fusion-{Environment.ProcessId}-*.tmp") + .Order(StringComparer.Ordinal) + .ToArray(); + + private static void CreateZipEntry(string archivePath, string entryName, string content) + { + using var archive = ZipFile.Open(archivePath, ZipArchiveMode.Update); + var entry = archive.CreateEntry(entryName); + using var writer = new StreamWriter(entry.Open()); + writer.Write(content); + } +}