diff --git a/docs/user-guide/error-codes.md b/docs/user-guide/error-codes.md index ab5eb58e..3f958678 100644 --- a/docs/user-guide/error-codes.md +++ b/docs/user-guide/error-codes.md @@ -249,6 +249,37 @@ error JD0025: Failed to add SQL file warnings: Access to the path is denied --- +### JD0026: Efcpt Tool Not Available Offline +**Severity**: Error +**Task**: RunEfcpt + +`EfcptOfflineMode` (or the `EFCPT_OFFLINE` environment variable) is enabled, but the efcpt tool +is not guaranteed to run without a network call - no explicit, existing `EfcptToolPath` was +provided, no tool manifest was discovered, and no global tool was found on `PATH`. Offline mode +never spawns `dnx`, restores a tool manifest, or updates a global tool, since all three require +network access. + +**Example**: +``` +error JD0026: EfcptOfflineMode is enabled, but the efcpt tool is not guaranteed to run without a +network call. Offline mode will not spawn dnx, restore a tool manifest, or update a global tool, +since all three require network access. Pre-provision the tool before building offline using one +of the following: (1) a local tool manifest - run: dotnet new tool-manifest && dotnet tool +install ErikEJ.EFCorePowerTools.Cli --version 10.*; (2) a global tool - run: dotnet tool install +--global ErikEJ.EFCorePowerTools.Cli --version 10.*; or (3) set EfcptToolPath to an explicit, +pre-installed efcpt executable. ... +``` + +**Resolution**: +- Pre-provision a local tool manifest and restore it before the offline build runs: `dotnet new + tool-manifest && dotnet tool install ErikEJ.EFCorePowerTools.Cli --version 10.*` +- Or install the tool as a global tool ahead of time: `dotnet tool install --global + ErikEJ.EFCorePowerTools.Cli --version 10.*` +- Or set `EfcptToolPath` to an explicit, pre-installed efcpt executable +- See [offline.md](offline.md) for the full offline/air-gapped build workflow + +--- + ## Troubleshooting Tips ### General Troubleshooting Steps diff --git a/docs/user-guide/offline.md b/docs/user-guide/offline.md new file mode 100644 index 00000000..2c33607b --- /dev/null +++ b/docs/user-guide/offline.md @@ -0,0 +1,93 @@ +# Offline / Air-Gapped Builds + +`EfcptOfflineMode` lets you build in environments that have no outbound network access - +air-gapped CI agents, locked-down secure build environments, or any pipeline where a build +should never silently reach out to the internet. + +## What it does + +When `EfcptOfflineMode` is `true` (or the `EFCPT_OFFLINE` environment variable is set to a +truthy value - see [Enabling offline mode](#enabling-offline-mode) below for the exact accepted +values), the `RunEfcpt` task refuses to spawn any of the three network-dependent code +paths it would otherwise use to resolve or restore the efcpt CLI: + +1. **dnx execution** - on .NET 10+ projects, the task would normally run `dotnet dnx + --yes -- ...` to fetch and execute the tool on demand. This is skipped entirely offline. +2. **Tool-manifest restore** - `dotnet tool restore` against a discovered + `.config/dotnet-tools.json` manifest. Skipped offline. +3. **Global tool update** - `dotnet tool update --global `. Skipped offline. + +It also disables the SDK update-check target (`EfcptCheckForUpdates`), which otherwise makes an +outbound call to check for a newer `JD.Efcpt.Sdk` version. + +## What you need to provide + +Because none of the network-dependent paths run, the efcpt tool must already be available +through one of three network-free routes: + +- **A local tool manifest that has already been restored** before the offline build runs: + ```bash + dotnet new tool-manifest + dotnet tool install ErikEJ.EFCorePowerTools.Cli --version 10.* + ``` + Restore this once, on a machine with network access (or in an earlier CI stage that does have + access), then carry the restored `.config/dotnet-tools.json` (and the NuGet tool cache) into + the offline environment. + +- **A global tool install**, performed ahead of time on the build agent image: + ```bash + dotnet tool install --global ErikEJ.EFCorePowerTools.Cli --version 10.* + ``` + +- **An explicit, pre-provisioned executable**, via `EfcptToolPath`: + ```xml + + true + C:\tools\efcpt\efcpt.exe + + ``` + +If none of these are available, the build fails fast with error `JD0026` instead of hanging or +failing obscurely against a missing tool - see [error-codes.md](error-codes.md#jd0026-efcpt-tool-not-available-offline). + +## Enabling offline mode + +MSBuild property (recommended, per-project or via `Directory.Build.props`): + +```xml + + true + +``` + +Or via the `EFCPT_OFFLINE` environment variable (useful for CI pipelines that shouldn't need to +touch every consuming project's file): + +```bash +set EFCPT_OFFLINE=true +dotnet build +``` + +Either is sufficient; they are OR-ed together at two levels: + +- **MSBuild property default.** `EfcptOfflineMode`'s own default (applied only when the property + is not explicitly set by the project/`Directory.Build.props`) checks `$(EFCPT_OFFLINE)` - + MSBuild exposes process environment variables as ordinary properties, so this needs no special + plumbing. If `$(EFCPT_OFFLINE)` is one of the truthy tokens below, `EfcptOfflineMode` defaults + to `true`; this is what makes the update-check target (`_EfcptCheckForUpdates`, which reads + `$(EfcptOfflineMode)` directly) also skip when only the env var is set. +- **Task-level OR.** Independently, the `RunEfcpt` task itself also reads + `Environment.GetEnvironmentVariable("EFCPT_OFFLINE")` at execution time and OR's it with the + `EfcptOfflineMode` task property, so offline behavior holds even if something explicitly set + `EfcptOfflineMode=false` at the MSBuild property level while `EFCPT_OFFLINE` is set in the + process environment. + +**Accepted truthy values** for `EFCPT_OFFLINE` (case-insensitive): `true`, `yes`, `on`, `1`, +`enable`, `enabled`, `y`. Any other value - including `false`, `0`, `no`, `off`, or an empty/unset +variable - does **not** enable offline mode via the environment variable. + +## What still runs + +Offline mode only gates the three network-dependent tool resolution/restore branches and the +update-check target. It does not change DACPAC building, schema fingerprinting, or model +generation itself - those are already local operations that don't touch the network. diff --git a/src/JD.Efcpt.Build.Definitions/BuildTransitivePropsFactory.cs b/src/JD.Efcpt.Build.Definitions/BuildTransitivePropsFactory.cs index 37639c60..e4b3383b 100644 --- a/src/JD.Efcpt.Build.Definitions/BuildTransitivePropsFactory.cs +++ b/src/JD.Efcpt.Build.Definitions/BuildTransitivePropsFactory.cs @@ -45,6 +45,17 @@ public static PackageDefinition Create() group.Property( "efcpt", "'$(EfcptToolCommand)'==''"); group.Property( "", "'$(EfcptToolPath)'==''"); group.Property( "dotnet", "'$(EfcptDotNetExe)'==''"); + // EFCPT_OFFLINE env-var bridge: MSBuild exposes environment variables as + // $(EFCPT_OFFLINE) automatically. When EfcptOfflineMode has not been set + // explicitly, honor a truthy EFCPT_OFFLINE value as the default so the + // documented "either is sufficient" contract holds even for targets/tasks + // that read $(EfcptOfflineMode) directly (e.g. _EfcptCheckForUpdates). + // Truthy tokens intentionally mirror StringExtensions.IsTrue() (true/yes/on/1/ + // enable/enabled/y); MSBuild condition equality is case-insensitive, so no + // additional case variants are needed. EFCPT_OFFLINE=false/0/unset does NOT + // enable offline mode. + group.Property( "true", "'$(EfcptOfflineMode)'=='' and ('$(EFCPT_OFFLINE)'=='true' or '$(EFCPT_OFFLINE)'=='yes' or '$(EFCPT_OFFLINE)'=='on' or '$(EFCPT_OFFLINE)'=='1' or '$(EFCPT_OFFLINE)'=='enable' or '$(EFCPT_OFFLINE)'=='enabled' or '$(EFCPT_OFFLINE)'=='y')"); + group.Property( "false", "'$(EfcptOfflineMode)'==''"); group.Property( "$(EfcptOutput)fingerprint.txt", "'$(EfcptFingerprintFile)'==''"); group.Property( "$(EfcptOutput).efcpt.stamp", "'$(EfcptStampFile)'==''"); group.Property( "false", "'$(EfcptDetectGeneratedFileChanges)'==''"); @@ -141,6 +152,17 @@ public static PackageDefinition Create() group.Property( "efcpt", "'$(EfcptToolCommand)'==''"); group.Property( "", "'$(EfcptToolPath)'==''"); group.Property( "dotnet", "'$(EfcptDotNetExe)'==''"); + // EFCPT_OFFLINE env-var bridge: MSBuild exposes environment variables as + // $(EFCPT_OFFLINE) automatically. When EfcptOfflineMode has not been set + // explicitly, honor a truthy EFCPT_OFFLINE value as the default so the + // documented "either is sufficient" contract holds even for targets/tasks + // that read $(EfcptOfflineMode) directly (e.g. _EfcptCheckForUpdates). + // Truthy tokens intentionally mirror StringExtensions.IsTrue() (true/yes/on/1/ + // enable/enabled/y); MSBuild condition equality is case-insensitive, so no + // additional case variants are needed. EFCPT_OFFLINE=false/0/unset does NOT + // enable offline mode. + group.Property( "true", "'$(EfcptOfflineMode)'=='' and ('$(EFCPT_OFFLINE)'=='true' or '$(EFCPT_OFFLINE)'=='yes' or '$(EFCPT_OFFLINE)'=='on' or '$(EFCPT_OFFLINE)'=='1' or '$(EFCPT_OFFLINE)'=='enable' or '$(EFCPT_OFFLINE)'=='enabled' or '$(EFCPT_OFFLINE)'=='y')"); + group.Property( "false", "'$(EfcptOfflineMode)'==''"); group.Property( "$(EfcptOutput)fingerprint.txt", "'$(EfcptFingerprintFile)'==''"); group.Property( "$(EfcptOutput).efcpt.stamp", "'$(EfcptStampFile)'==''"); group.Property( "false", "'$(EfcptDetectGeneratedFileChanges)'==''"); @@ -443,6 +465,10 @@ public static PackageDefinition Create() { public string Name => "EfcptLogVerbosity"; } + public readonly struct EfcptOfflineMode : IMsBuildPropertyName + { + public string Name => "EfcptOfflineMode"; + } public readonly struct EfcptOutput : IMsBuildPropertyName { public string Name => "EfcptOutput"; diff --git a/src/JD.Efcpt.Build.Definitions/BuildTransitiveTargetsFactory.cs b/src/JD.Efcpt.Build.Definitions/BuildTransitiveTargetsFactory.cs index f84d420f..dfb58dc9 100644 --- a/src/JD.Efcpt.Build.Definitions/BuildTransitiveTargetsFactory.cs +++ b/src/JD.Efcpt.Build.Definitions/BuildTransitiveTargetsFactory.cs @@ -31,19 +31,19 @@ public static PackageDefinition Create() // EfcptEnabled override above (ConfigureDesignTimeBuildGuard). t.Target("_EfcptDesignTimeBuildSkipped", target => { - target.BeforeTargets("Build", "CoreCompile"); + target.BeforeTargets("Build;CoreCompile"); target.Condition("'$(DesignTimeBuild)' == 'true' and '$(EfcptRunDuringDesignTimeBuild)' != 'true'"); target.Message("[Efcpt] Skipping EF Core Power Tools generation pipeline: DesignTimeBuild='$(DesignTimeBuild)'. Set EfcptRunDuringDesignTimeBuild=true to force generation during design-time builds.", "Low"); }); t.Target("_EfcptDetectSqlProject", target => { - target.BeforeTargets("BeforeBuild", "BeforeRebuild"); + target.BeforeTargets("BeforeBuild;BeforeRebuild"); target.Task("DetectSqlProject", task => { task.Param("ProjectPath", "$(MSBuildProjectFullPath)"); task.Param("SqlServerVersion", "$(SqlServerVersion)"); task.Param("DSP", "$(DSP)"); - task.OutputProperty(); + task.OutputProperty("IsSqlProject", "_EfcptIsSqlProject"); }); target.PropertyGroup("'$(_EfcptIsSqlProject)' == ''", group => { @@ -86,7 +86,7 @@ public static PackageDefinition Create() t.Target("_EfcptCheckForUpdates", target => { target.BeforeTargets("Build"); - target.Condition("'$(EfcptCheckForUpdates)' == 'true' and '$(EfcptSdkVersion)' != ''"); + target.Condition("'$(EfcptCheckForUpdates)' == 'true' and '$(EfcptSdkVersion)' != '' and '$(EfcptOfflineMode)' != 'true'"); target.Task("CheckSdkVersion", task => { task.Param("CurrentVersion", "$(EfcptSdkVersion)"); @@ -94,8 +94,8 @@ public static PackageDefinition Create() task.Param("CacheHours", "$(EfcptUpdateCheckCacheHours)"); task.Param("ForceCheck", "$(EfcptForceUpdateCheck)"); task.Param("WarningLevel", "$(EfcptSdkVersionWarningLevel)"); - task.OutputProperty(); - task.OutputProperty(); + task.OutputProperty("LatestVersion", "_EfcptLatestVersion"); + task.OutputProperty("UpdateAvailable", "_EfcptUpdateAvailable"); }); }); t.Target( target => @@ -115,7 +115,7 @@ public static PackageDefinition Create() task.Param("Provider", "$(EfcptProvider)"); task.Param("LogVerbosity", "$(EfcptLogVerbosity)"); task.Param("ProviderSearchPaths", "@(EfcptProviderSearchPath)"); - task.OutputProperty(); + task.OutputProperty("SchemaFingerprint", "_EfcptSchemaFingerprint"); }); target.Message("Database schema fingerprint: $(_EfcptSchemaFingerprint)", "normal"); }); @@ -148,7 +148,7 @@ public static PackageDefinition Create() task.Param("ExtractTarget", "SchemaObjectType"); task.Param("TargetFramework", "$(TargetFramework)"); task.Param("LogVerbosity", "$(EfcptLogVerbosity)"); - task.OutputProperty(); + task.OutputProperty("ExtractedPath", "_EfcptExtractedScriptsPath"); }); target.Message("Extracted SQL scripts to: $(_EfcptExtractedScriptsPath)", "high"); }); @@ -201,13 +201,13 @@ public static PackageDefinition Create() task.Param("EfcptAppConfig", "$(EfcptAppConfig)"); task.Param("EfcptConnectionStringName", "$(EfcptConnectionStringName)"); task.Param("AutoDetectWarningLevel", "$(EfcptAutoDetectWarningLevel)"); - task.OutputProperty(); - task.OutputProperty(); - task.OutputProperty(); - task.OutputProperty(); - task.OutputProperty(); - task.OutputProperty(); - task.OutputProperty(); + task.OutputProperty("SqlProjPath", "_EfcptSqlProj"); + task.OutputProperty("ResolvedConfigPath", "_EfcptResolvedConfig"); + task.OutputProperty("ResolvedRenamingPath", "_EfcptResolvedRenaming"); + task.OutputProperty("ResolvedTemplateDir", "_EfcptResolvedTemplateDir"); + task.OutputProperty("ResolvedConnectionString", "_EfcptResolvedConnectionString"); + task.OutputProperty("UseConnectionString", "_EfcptUseConnectionString"); + task.OutputProperty("IsUsingDefaultConfig", "_EfcptIsUsingDefaultConfig"); }); }); t.Target( target => @@ -241,7 +241,7 @@ public static PackageDefinition Create() task.Param("Provider", "$(EfcptProvider)"); task.Param("LogVerbosity", "$(EfcptLogVerbosity)"); task.Param("ProviderSearchPaths", "@(EfcptProviderSearchPath)"); - task.OutputProperty(); + task.OutputProperty("SchemaFingerprint", "_EfcptSchemaFingerprint"); }); }); t.Target( target => @@ -281,12 +281,12 @@ public static PackageDefinition Create() task.Param("MsBuildExe", "$(MSBuildBinPath)msbuild.exe"); task.Param("DotNetExe", "$(EfcptDotNetExe)"); task.Param("LogVerbosity", "$(EfcptLogVerbosity)"); - task.OutputProperty(); + task.OutputProperty("DacpacPath", "_EfcptDacpacPath"); }, "'$(_EfcptUseConnectionString)' != 'true' and '$(_EfcptUseDirectDacpac)' != 'true' and '$(_EfcptIsSqlProject)' != 'true'"); }); t.Target( target => { - target.DependsOnTargets("EfcptResolveInputs;EfcptEnsureDacpac;EfcptUseDirectDacpac"); + target.DependsOnTargets("EfcptResolveInputs;EfcptEnsureDacpacBuilt;EfcptUseDirectDacpac"); target.Condition("'$(EfcptEnabled)' == 'true' and '$(_EfcptIsSqlProject)' != 'true'"); target.Task("ResolveDbContextName", task => { @@ -296,7 +296,7 @@ public static PackageDefinition Create() task.Param("ConnectionString", "$(_EfcptResolvedConnectionString)"); task.Param("UseConnectionStringMode", "$(_EfcptUseConnectionString)"); task.Param("LogVerbosity", "$(EfcptLogVerbosity)"); - task.OutputProperty(); + task.OutputProperty("ResolvedDbContextName", "_EfcptResolvedDbContextName"); }); target.PropertyGroup(null, group => { @@ -305,7 +305,7 @@ public static PackageDefinition Create() }); t.Target( target => { - target.DependsOnTargets("EfcptResolveInputs;EfcptEnsureDacpac;EfcptUseDirectDacpac;EfcptResolveDbContextName"); + target.DependsOnTargets("EfcptResolveInputs;EfcptEnsureDacpacBuilt;EfcptUseDirectDacpac;EfcptResolveDbContextName"); target.Condition("'$(EfcptEnabled)' == 'true' and '$(_EfcptIsSqlProject)' != 'true'"); target.Task("StageEfcptInputs", task => { @@ -317,9 +317,9 @@ public static PackageDefinition Create() task.Param("TemplateOutputDir", "$(EfcptGeneratedDir)"); task.Param("TargetFramework", "$(TargetFramework)"); task.Param("LogVerbosity", "$(EfcptLogVerbosity)"); - task.OutputProperty(); - task.OutputProperty(); - task.OutputProperty(); + task.OutputProperty("StagedConfigPath", "_EfcptStagedConfig"); + task.OutputProperty("StagedRenamingPath", "_EfcptStagedRenaming"); + task.OutputProperty("StagedTemplateDir", "_EfcptStagedTemplateDir"); }); }); t.Target( target => @@ -414,7 +414,7 @@ public static PackageDefinition Create() task.Param("UseSpatial", "$(EfcptConfigUseSpatial)"); task.Param("UseNodaTime", "$(EfcptConfigUseNodaTime)"); task.Param("PreserveCasingWithRegex", "$(EfcptConfigPreserveCasingWithRegex)"); - task.OutputProperty(); + task.OutputProperty("SerializedProperties", "_EfcptSerializedConfigProperties"); }); }); t.Target( target => @@ -435,8 +435,8 @@ public static PackageDefinition Create() task.Param("DetectGeneratedFileChanges", "$(EfcptDetectGeneratedFileChanges)"); task.Param("ConfigPropertyOverrides", "$(_EfcptSerializedConfigProperties)"); task.Param("LogVerbosity", "$(EfcptLogVerbosity)"); - task.OutputProperty(); - task.OutputProperty(); + task.OutputProperty("Fingerprint", "_EfcptFingerprint"); + task.OutputProperty("HasChanged", "_EfcptFingerprintChanged"); }); }); t.Target( target => @@ -464,6 +464,7 @@ public static PackageDefinition Create() task.Param("ToolCommand", "$(EfcptToolCommand)"); task.Param("ToolPath", "$(EfcptToolPath)"); task.Param("DotNetExe", "$(EfcptDotNetExe)"); + task.Param("OfflineMode", "$(EfcptOfflineMode)"); task.Param("WorkingDirectory", "$(EfcptOutput)"); task.Param("DacpacPath", "$(_EfcptDacpacPath)"); task.Param("ConnectionString", "$(_EfcptResolvedConnectionString)"); @@ -570,7 +571,7 @@ public static PackageDefinition Create() t.Target( target => { target.BeforeTargets("CoreCompile"); - target.DependsOnTargets("EfcptResolveInputs;EfcptUseDirectDacpac;EfcptEnsureDacpac;EfcptStageInputs;EfcptComputeFingerprint;EfcptGenerateModels;EfcptCopyDataToDataProject"); + target.DependsOnTargets("EfcptResolveInputs;EfcptUseDirectDacpac;EfcptEnsureDacpacBuilt;EfcptStageInputs;EfcptComputeFingerprint;EfcptGenerateModels;EfcptCopyDataToDataProject"); target.Condition("'$(EfcptEnabled)' == 'true' and '$(_EfcptIsSqlProject)' != 'true'"); target.ItemGroup(null, group => { @@ -769,7 +770,7 @@ public static PackageDefinition Create() } public readonly struct EfcptEnsureDacpacTarget : IMsBuildTargetName { - public string Name => "EfcptEnsureDacpac"; + public string Name => "EfcptEnsureDacpacBuilt"; } public readonly struct EfcptExtractDatabaseSchemaToScriptsTarget : IMsBuildTargetName { @@ -789,7 +790,7 @@ public static PackageDefinition Create() } public readonly struct EfcptQuerySchemaMetadataTarget : IMsBuildTargetName { - public string Name => "EfcptQuerySchemaMetadata"; + public string Name => "EfcptQuerySchemaMetadataForDb"; } public readonly struct EfcptResolveDbContextNameTarget : IMsBuildTargetName { diff --git a/src/JD.Efcpt.Build.Definitions/Registry/UsingTasksRegistry.cs b/src/JD.Efcpt.Build.Definitions/Registry/UsingTasksRegistry.cs index aad498e5..a1ccb262 100644 --- a/src/JD.Efcpt.Build.Definitions/Registry/UsingTasksRegistry.cs +++ b/src/JD.Efcpt.Build.Definitions/Registry/UsingTasksRegistry.cs @@ -41,7 +41,7 @@ public static class UsingTasksRegistry public static void RegisterAll(TargetsBuilder t) { t.RegisterTasks( - assemblyPath: "$(_EfcptTaskAssembly)", + assemblyPathProperty: "$(_EfcptTaskAssembly)", taskNamespace: "JD.Efcpt.Build.Tasks", taskNames: TaskNames); } diff --git a/src/JD.Efcpt.Build.Definitions/Shared/SharedPropertyGroups.cs b/src/JD.Efcpt.Build.Definitions/Shared/SharedPropertyGroups.cs index 42c4ccc2..86ff8d35 100644 --- a/src/JD.Efcpt.Build.Definitions/Shared/SharedPropertyGroups.cs +++ b/src/JD.Efcpt.Build.Definitions/Shared/SharedPropertyGroups.cs @@ -16,11 +16,14 @@ public static class SharedPropertyGroups public static void ConfigureTaskAssemblyResolution(PropsGroupBuilder group) { group.ResolveMultiTargetedTaskAssembly( - folderProperty: "_EfcptTasksFolder", - assemblyProperty: "_EfcptTaskAssembly", - assemblyFileName: "JD.Efcpt.Build.Tasks.dll", - nugetTasksPath: "$(MSBuildThisFileDirectory)..\\tasks", - localProjectPath: "$(MSBuildThisFileDirectory)..\\..\\JD.Efcpt.Build.Tasks"); + "_EfcptTasksFolder", + "_EfcptTaskAssembly", + "JD.Efcpt.Build.Tasks.dll", + "JD.Efcpt.Build", + ("net10.0", "18.0"), + ("net9.0", "17.12"), + ("net8.0", "15.0"), + ("net472", "15.0")); } /// @@ -37,10 +40,10 @@ public static void ConfigureTaskAssemblyResolution(PropsGroupBuilder group) /// public static void ConfigureNullableReferenceTypes(PropsGroupBuilder group) { - group.Property("true", + group.Property("EfcptConfigUseNullableReferenceTypes", "true", "'$(EfcptConfigUseNullableReferenceTypes)'=='' and ('$(Nullable)'=='enable' or '$(Nullable)'=='Enable')"); - - group.Property("false", + + group.Property("EfcptConfigUseNullableReferenceTypes", "false", "'$(EfcptConfigUseNullableReferenceTypes)'=='' and '$(Nullable)'!=''"); } @@ -57,7 +60,7 @@ public static void ConfigureNullableReferenceTypes(PropsGroupBuilder group) /// public static void ConfigureDesignTimeBuildGuard(PropsGroupBuilder group) { - group.Property("false", + group.Property("EfcptEnabled", "false", "'$(EfcptEnabled)'=='true' and '$(DesignTimeBuild)'=='true' and '$(EfcptRunDuringDesignTimeBuild)'!='true'"); } } diff --git a/src/JD.Efcpt.Build.Tasks/RunEfcpt.cs b/src/JD.Efcpt.Build.Tasks/RunEfcpt.cs index 91b536d6..d52389ef 100644 --- a/src/JD.Efcpt.Build.Tasks/RunEfcpt.cs +++ b/src/JD.Efcpt.Build.Tasks/RunEfcpt.cs @@ -1,6 +1,8 @@ using System.Diagnostics; +using System.Text.Json; using JD.Efcpt.Build.Tasks.Decorators; using JD.Efcpt.Build.Tasks.Extensions; +using JD.Efcpt.Build.Tasks.Schema; using JD.Efcpt.Build.Tasks.Utilities; using Microsoft.Build.Framework; using PatternKit.Behavioral.Strategy; @@ -263,6 +265,33 @@ public sealed class RunEfcpt : Task /// public string ProjectPath { get; set; } = ""; + /// + /// Controls whether the task avoids any network-dependent tool resolution/restore step + /// (dnx execution, tool-manifest restore, global tool update) and update-check calls. + /// + /// + /// Interpreted case-insensitively via the same truthy convention as other boolean-like + /// properties on this task (see ). Also + /// honours the EFCPT_OFFLINE environment variable - if either is truthy, offline mode + /// is enabled. Defaults to "false". + /// + /// + /// When enabled, the task will refuse to spawn any of the three network-dependent branches + /// (dnx, tool-manifest restore, global tool update) and instead requires the efcpt tool to + /// already be runnable via an explicit , a restored tool manifest, or a + /// global tool already on PATH. If none of those are available, the task fails with + /// error JD0026 instead of attempting (and blocking on) a network call. See + /// docs/user-guide/offline.md. + /// + public string OfflineMode { get; set; } = "false"; + + /// + /// Testability seam for the SDK/dnx/global-tool capability probes used during tool + /// resolution and restore. Defaults to , which + /// delegates to the existing memoized probes; tests may substitute a fake implementation. + /// + internal ISdkProbe Probe { get; set; } = new DefaultSdkProbe(); + private readonly record struct ToolResolutionContext( string ToolPath, string ToolMode, @@ -274,7 +303,9 @@ private readonly record struct ToolResolutionContext( string WorkingDir, string Args, string TargetFramework, - BuildLog Log + BuildLog Log, + ISdkProbe Probe, + bool Offline ); private readonly record struct ToolInvocation( @@ -296,7 +327,9 @@ private readonly record struct ToolRestoreContext( string ToolPackageId, string ToolVersion, string TargetFramework, - BuildLog Log + BuildLog Log, + ISdkProbe Probe, + bool Offline ); private static readonly Lazy> ToolResolutionStrategy = new(() => @@ -308,7 +341,7 @@ BuildLog Log Args: ctx.Args, Cwd: ctx.WorkingDir, UseManifest: false)) - .When((in ctx) => IsDotNet10OrLater(ctx.TargetFramework) && IsDotNet10SdkInstalled(ctx.DotNetExe) && IsDnxAvailable(ctx.DotNetExe)) + .When((in ctx) => !ctx.Offline && IsDotNet10OrLater(ctx.TargetFramework) && ctx.Probe.IsDotNet10SdkInstalled(ctx.DotNetExe) && ctx.Probe.IsDnxAvailable(ctx.DotNetExe)) .Then((in ctx) => new ToolInvocation( Exe: ctx.DotNetExe, @@ -331,31 +364,97 @@ BuildLog Log .Build()); private static bool ToolIsAutoOrManifest(ToolResolutionContext ctx) => - ctx.ToolMode.EqualsIgnoreCase("tool-manifest") || - (ctx.ToolMode.EqualsIgnoreCase("auto") && - (ctx.ManifestDir is not null || ctx.ForceManifestOnNonWindows)); + ToolModeUsesManifest(ctx.ToolMode, ctx.ManifestDir, ctx.ForceManifestOnNonWindows); + + /// + /// Determines whether would actually resolve to using a local + /// tool manifest - i.e. tool-manifest mode, or auto mode with a discovered + /// manifest directory or a forced manifest fallback (non-Windows, no explicit ToolPath). + /// + /// + /// This is the same condition uses (via + /// ) to pick the tool-manifest invocation branch, extracted + /// so the offline pre-flight check in can apply it before a + /// exists. + /// + private static bool ToolModeUsesManifest(string toolMode, string? manifestDir, bool forceManifestOnNonWindows) => + toolMode.EqualsIgnoreCase("tool-manifest") || + (toolMode.EqualsIgnoreCase("auto") && + (manifestDir is not null || forceManifestOnNonWindows)); + + /// + /// Reads a discovered .config/dotnet-tools.json manifest and determines whether it + /// lists an entry for the target efcpt tool - matched either by package id or by exposing a + /// command name matching . + /// + /// + /// This is a local file read only (no network access), so it is safe to call from the + /// offline pre-flight path. Any parse failure - missing file, malformed JSON, or an + /// unexpected shape - is tolerated by returning false (i.e. "does not prove + /// runnability") rather than throwing, since a corrupt manifest is exactly the kind of + /// situation the strengthened pre-flight check is meant to catch. + /// + private static bool ManifestListsTool(string manifestDir, string toolPackageId, string toolCommand) + { + try + { + var manifestPath = Path.Combine(manifestDir, ".config", "dotnet-tools.json"); + if (!File.Exists(manifestPath)) return false; + + using var stream = File.OpenRead(manifestPath); + using var doc = JsonDocument.Parse(stream); + + if (!doc.RootElement.TryGetProperty("tools", out var tools) || tools.ValueKind != JsonValueKind.Object) + return false; + + foreach (var tool in tools.EnumerateObject()) + { + if (tool.Name.EqualsIgnoreCase(toolPackageId)) + return true; + + if (tool.Value.ValueKind == JsonValueKind.Object && + tool.Value.TryGetProperty("commands", out var commands) && + commands.ValueKind == JsonValueKind.Array) + { + foreach (var command in commands.EnumerateArray()) + { + if (command.ValueKind == JsonValueKind.String && + command.GetString().EqualsIgnoreCase(toolCommand)) + return true; + } + } + } + + return false; + } + catch + { + // Malformed/unreadable manifest: don't treat it as proof of runnability. + return false; + } + } private static readonly Lazy> ToolRestoreStrategy = new(() => ActionStrategy.Create() // Manifest restore: restore tools from local manifest - // Skip when: dnx will be used OR no manifest directory exists - .When((in ctx) => ctx is { UseManifest: true, ShouldRestore: true, ManifestDir: not null } - && !(IsDotNet10OrLater(ctx.TargetFramework) && IsDotNet10SdkInstalled(ctx.DotNetExe) && IsDnxAvailable(ctx.DotNetExe))) + // Skip when: offline OR dnx will be used OR no manifest directory exists + .When((in ctx) => !ctx.Offline && ctx is { UseManifest: true, ShouldRestore: true, ManifestDir: not null } + && !(IsDotNet10OrLater(ctx.TargetFramework) && ctx.Probe.IsDotNet10SdkInstalled(ctx.DotNetExe) && ctx.Probe.IsDnxAvailable(ctx.DotNetExe))) .Then((in ctx) => { var restoreCwd = ctx.ManifestDir ?? ctx.WorkingDir; ProcessRunner.RunOrThrow(ctx.Log, ctx.DotNetExe, "tool restore", restoreCwd); }) // Global restore: update global tool package - // Skip only when dnx will be used (all three conditions: .NET 10+ target, SDK installed, dnx available) + // Skip when: offline OR dnx will be used (all three conditions: .NET 10+ target, SDK installed, dnx available) .When((in ctx) - => ctx is + => !ctx.Offline && ctx is { UseManifest: false, ShouldRestore: true, HasExplicitPath: false, HasPackageId: true - } && !(IsDotNet10OrLater(ctx.TargetFramework) && IsDotNet10SdkInstalled(ctx.DotNetExe) && IsDnxAvailable(ctx.DotNetExe))) + } && !(IsDotNet10OrLater(ctx.TargetFramework) && ctx.Probe.IsDotNet10SdkInstalled(ctx.DotNetExe) && ctx.Probe.IsDnxAvailable(ctx.DotNetExe))) .Then((in ctx) => { var versionArg = string.IsNullOrWhiteSpace(ctx.ToolVersion) ? "" : $" --version \"{ctx.ToolVersion}\""; @@ -418,20 +517,62 @@ private bool ExecuteCore(TaskExecutionContext ctx) var manifestDir = FindManifestDir(workingDir); var mode = ToolMode; + // Offline mode: OfflineMode task property OR-ed with the EFCPT_OFFLINE environment + // variable (test/CI escape hatch, mirroring the EFCPT_FAKE_EFCPT/EFCPT_TEST_DACPAC + // convention documented on this task). + var offline = OfflineMode.IsTrue() || Environment.GetEnvironmentVariable("EFCPT_OFFLINE").IsTrue(); + // On non-Windows, a bare efcpt executable is unlikely to exist unless explicitly provided // via ToolPath. To avoid fragile PATH assumptions on CI agents, treat "auto" as // "tool-manifest" whenever a manifest is present *or* when running on non-Windows and - // no explicit ToolPath was supplied. + // no explicit ToolPath was supplied. Computed here (before the offline pre-flight check) + // so that check can use the same "would this ToolMode actually use the manifest?" logic + // as the real tool-resolution strategy below. #if NETFRAMEWORK var forceManifestOnNonWindows = !OperatingSystemPolyfill.IsWindows() && !PathUtils.HasExplicitPath(ToolPath); #else var forceManifestOnNonWindows = !OperatingSystem.IsWindows() && !PathUtils.HasExplicitPath(ToolPath); #endif + if (offline) + { + log.Info("[Efcpt] Offline mode enabled (EfcptOfflineMode=true): dnx execution, tool-manifest restore, and global tool update are skipped."); + + // Pre-flight: offline mode only works if the tool is already guaranteed runnable + // without any network access - an explicit, existing ToolPath; a discovered tool + // manifest that will actually be used (same condition as ToolIsAutoOrManifest) and + // that genuinely lists the target tool (assumed already restored, since we cannot + // restore it now); or a global tool already resolvable on PATH. If none apply, fail + // actionably rather than let a later step hang or fail obscurely against a missing + // tool. + // + // The manifest leg is deliberately stricter than "a dotnet-tools.json exists + // somewhere above WorkingDirectory": a manifest that ToolMode wouldn't even use, or + // that doesn't list this tool (stale, foreign, or never-restored), is not proof the + // tool is runnable - it would otherwise pass pre-flight here and only fail later with + // a cryptic `dotnet tool run` error instead of the actionable JD0026. + var manifestIsRunnable = + manifestDir is not null && + ToolModeUsesManifest(mode, manifestDir, forceManifestOnNonWindows) && + ManifestListsTool(manifestDir, ToolPackageId, ToolCommand); + + var isRunnableOffline = + (PathUtils.HasExplicitPath(ToolPath) && File.Exists(PathUtils.FullPath(ToolPath, workingDir))) || + manifestIsRunnable || + Probe.IsGlobalToolInstalled(ToolCommand); + + if (!isRunnableOffline) + { + var ex = new EfcptToolNotAvailableOfflineException(TargetFramework, manifestDir, ToolPath, ToolPackageId, ToolVersion); + log.Error("JD0026", ex.Message); + return false; + } + } + // Use the Strategy pattern to resolve tool invocation var context = new ToolResolutionContext( ToolPath, mode, manifestDir, forceManifestOnNonWindows, - DotNetExe, ToolCommand, ToolPackageId, workingDir, args, TargetFramework, log); + DotNetExe, ToolCommand, ToolPackageId, workingDir, args, TargetFramework, log, Probe, offline); var invocation = ToolResolutionStrategy.Value.Execute(in context); @@ -458,7 +599,9 @@ private bool ExecuteCore(TaskExecutionContext ctx) ToolPackageId: ToolPackageId, ToolVersion: ToolVersion, TargetFramework: TargetFramework, - Log: log + Log: log, + Probe: Probe, + Offline: offline ); ToolRestoreStrategy.Value.Execute(in restoreContext); diff --git a/src/JD.Efcpt.Build.Tasks/Schema/EfcptToolNotAvailableOfflineException.cs b/src/JD.Efcpt.Build.Tasks/Schema/EfcptToolNotAvailableOfflineException.cs new file mode 100644 index 00000000..9c5bc220 --- /dev/null +++ b/src/JD.Efcpt.Build.Tasks/Schema/EfcptToolNotAvailableOfflineException.cs @@ -0,0 +1,73 @@ +namespace JD.Efcpt.Build.Tasks.Schema; + +/// +/// Thrown (surfaced as error JD0026 via , never actually thrown +/// across the task boundary - see ) when +/// EfcptOfflineMode is enabled but the efcpt tool cannot be guaranteed to run without a +/// network call. +/// +/// +/// +/// Offline mode refuses to spawn any of the three network-dependent tool resolution/restore +/// branches (dnx execution, tool-manifest restore, global tool update). That only works +/// if the tool is already available through one of three network-free paths: an explicit, +/// existing EfcptToolPath; a local tool manifest that has already been restored (offline +/// mode cannot restore it now); or a global tool already resolvable on PATH. When none of +/// those apply, this exception's message gives the exact pre-provisioning commands needed, +/// mirroring the actionable shape of . +/// +/// +internal sealed class EfcptToolNotAvailableOfflineException : Exception +{ + /// + /// The URL of the documentation page describing offline/air-gapped usage. + /// + private const string DocsUrl = "https://jerrettdavis.github.io/JD.Efcpt.Build/user-guide/offline.html"; + + /// + /// Initializes a new instance of , + /// building an actionable message from the resolved state at the point offline pre-flight + /// validation failed. + /// + /// The detected target framework moniker (e.g. net10.0). + /// + /// The directory containing a discovered .config/dotnet-tools.json manifest, or + /// if none was found. + /// + /// The configured EfcptToolPath value (may be empty). + /// The configured EfcptToolPackageId value. + /// The configured EfcptToolVersion value (may be empty). + public EfcptToolNotAvailableOfflineException( + string targetFramework, + string? manifestDir, + string toolPath, + string toolPackageId, + string toolVersion) + : base(BuildMessage(targetFramework, manifestDir, toolPath, toolPackageId, toolVersion)) + { + } + + private static string BuildMessage( + string targetFramework, + string? manifestDir, + string toolPath, + string toolPackageId, + string toolVersion) + { + var versionArg = string.IsNullOrWhiteSpace(toolVersion) ? "" : $" --version {toolVersion}"; + var tfmDisplay = string.IsNullOrWhiteSpace(targetFramework) ? "(not specified)" : targetFramework; + var manifestDisplay = manifestDir ?? "(none found)"; + var toolPathDisplay = string.IsNullOrWhiteSpace(toolPath) ? "(not set)" : toolPath; + + return + "EfcptOfflineMode is enabled, but the efcpt tool is not guaranteed to run without a " + + "network call. Offline mode will not spawn dnx, restore a tool manifest, or update a " + + "global tool, since all three require network access. Pre-provision the tool before " + + "building offline using one of the following: " + + $"(1) a local tool manifest - run: dotnet new tool-manifest && dotnet tool install {toolPackageId}{versionArg}; " + + $"(2) a global tool - run: dotnet tool install --global {toolPackageId}{versionArg}; " + + "or (3) set EfcptToolPath to an explicit, pre-installed efcpt executable. " + + $"Detected state: TargetFramework='{tfmDisplay}', discovered tool manifest directory='{manifestDisplay}', EfcptToolPath='{toolPathDisplay}'. " + + $"See {DocsUrl} for details."; + } +} diff --git a/src/JD.Efcpt.Build.Tasks/Utilities/ISdkProbe.cs b/src/JD.Efcpt.Build.Tasks/Utilities/ISdkProbe.cs new file mode 100644 index 00000000..4c75e3b4 --- /dev/null +++ b/src/JD.Efcpt.Build.Tasks/Utilities/ISdkProbe.cs @@ -0,0 +1,60 @@ +namespace JD.Efcpt.Build.Tasks.Utilities; + +/// +/// Testability seam over the SDK/dnx/global-tool capability probes used during +/// tool resolution and restore. +/// +/// +/// +/// Extracting these probes behind an interface lets tests substitute a fake implementation +/// (e.g. one that throws if invoked) to assert that offline mode - or any other code path that +/// must avoid spawning processes - genuinely never calls into the underlying SDK checks, +/// without needing to mock Process.Start itself. +/// +/// +/// The production implementation, , does not reimplement probing +/// logic; it simply delegates to the existing memoized probes on (which +/// are backed by , introduced in #187) so behavior and caching are +/// unchanged by introducing this seam. +/// +/// +internal interface ISdkProbe +{ + /// + /// Checks if the .NET 10.0 (or later) SDK is installed for the given dotnet executable. + /// + /// Path to (or bare name of) the dotnet executable. + bool IsDotNet10SdkInstalled(string dotnetExe); + + /// + /// Checks if dnx (dotnet native execution) is available for the given dotnet executable. + /// + /// Path to (or bare name of) the dotnet executable. + bool IsDnxAvailable(string dotnetExe); + + /// + /// Checks whether a global dotnet tool command is resolvable on PATH, without spawning + /// a process. Used for offline pre-flight validation - see 's + /// JD0026 guard. + /// + /// The tool command name (e.g. efcpt). + bool IsGlobalToolInstalled(string toolCommand); +} + +/// +/// Production implementation that delegates to the existing, memoized +/// SDK/dnx probes on (backed by ) and to +/// 's PATH resolution helper for the global-tool check. +/// +internal sealed class DefaultSdkProbe : ISdkProbe +{ + /// + public bool IsDotNet10SdkInstalled(string dotnetExe) => RunEfcpt.IsDotNet10SdkInstalled(dotnetExe); + + /// + public bool IsDnxAvailable(string dotnetExe) => RunEfcpt.IsDnxAvailable(dotnetExe); + + /// + public bool IsGlobalToolInstalled(string toolCommand) => + SdkProbeCache.ResolveDotnetExecutable(toolCommand) is not null; +} diff --git a/src/JD.Efcpt.Build/JD.Efcpt.Build.csproj b/src/JD.Efcpt.Build/JD.Efcpt.Build.csproj index 17d2e9d2..578c21dd 100644 --- a/src/JD.Efcpt.Build/JD.Efcpt.Build.csproj +++ b/src/JD.Efcpt.Build/JD.Efcpt.Build.csproj @@ -47,6 +47,20 @@ IncludeAssets="Compile" /> + + + + + + + <_EfcptIsDirectReference>true - - diff --git a/src/JD.Efcpt.Build/build/JD.Efcpt.Build.targets b/src/JD.Efcpt.Build/build/JD.Efcpt.Build.targets index 5ca30385..ad421982 100644 --- a/src/JD.Efcpt.Build/build/JD.Efcpt.Build.targets +++ b/src/JD.Efcpt.Build/build/JD.Efcpt.Build.targets @@ -1,8 +1,4 @@ - - + + diff --git a/src/JD.Efcpt.Build/buildTransitive/JD.Efcpt.Build.props b/src/JD.Efcpt.Build/buildTransitive/JD.Efcpt.Build.props index 88b8b21e..ed5c081d 100644 --- a/src/JD.Efcpt.Build/buildTransitive/JD.Efcpt.Build.props +++ b/src/JD.Efcpt.Build/buildTransitive/JD.Efcpt.Build.props @@ -1,170 +1,96 @@ - - true - - false - - $(BaseIntermediateOutputPath)efcpt\ - $(EfcptOutput)Generated\ - - - - efcpt-config.json - efcpt.renaming.json - Template - - - - - DefaultConnection - - mssql - - $(SolutionDir) - $(SolutionPath) - true - - auto - ErikEJ.EFCorePowerTools.Cli - 10.* - true - efcpt - - dotnet - - $(EfcptOutput)fingerprint.txt - $(EfcptOutput).efcpt.stamp - false - - minimal - false - - Info - Warn - - false - 24 - false - - false - - obj\efcpt\Generated\ - - - - true - - - $(RootNamespace) - $(MSBuildProjectName) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - false - $(EfcptOutput)build-profile.json - minimal + true + false + $(BaseIntermediateOutputPath)efcpt\ + $(EfcptOutput)Generated\ + + + efcpt-config.json + efcpt.renaming.json + Template + + + + DefaultConnection + mssql + $(SolutionDir) + $(SolutionPath) + true + auto + ErikEJ.EFCorePowerTools.Cli + 10.* + true + efcpt + + dotnet + true + false + $(EfcptOutput)fingerprint.txt + $(EfcptOutput).efcpt.stamp + false + minimal + false + Info + Warn + false + 24 + false + false + + obj\efcpt\Generated\ + + true + $(RootNamespace) + $(MSBuildProjectName) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + false + $(EfcptOutput)build-profile.json + minimal - - microsoft-build-sql - csharp - $(MSBuildProjectDirectory)\ - $(MSBuildProjectDirectory)\ - Sql160 - - true - + + true + + csharp + $(MSBuildProjectDirectory)\ + microsoft-build-sql + $(MSBuildProjectDirectory)\ + Sql160 diff --git a/src/JD.Efcpt.Build/buildTransitive/JD.Efcpt.Build.targets b/src/JD.Efcpt.Build/buildTransitive/JD.Efcpt.Build.targets index ea2d11e9..5fbfeab9 100644 --- a/src/JD.Efcpt.Build/buildTransitive/JD.Efcpt.Build.targets +++ b/src/JD.Efcpt.Build/buildTransitive/JD.Efcpt.Build.targets @@ -1,34 +1,24 @@ - - - true - false + true + false - - false + <_EfcptTasksFolder Condition="'$(MSBuildRuntimeType)' == 'Core' and $([MSBuild]::VersionGreaterThanOrEquals('$(MSBuildVersion)', '18.0'))">net10.0 + <_EfcptTasksFolder Condition="'$(_EfcptTasksFolder)' == '' and '$(MSBuildRuntimeType)' == 'Core' and $([MSBuild]::VersionGreaterThanOrEquals('$(MSBuildVersion)', '17.12'))">net9.0 + <_EfcptTasksFolder Condition="'$(_EfcptTasksFolder)' == '' and '$(MSBuildRuntimeType)' == 'Core' and $([MSBuild]::VersionGreaterThanOrEquals('$(MSBuildVersion)', '15.0'))">net8.0 + <_EfcptTasksFolder Condition="'$(_EfcptTasksFolder)' == '' and '$(_EfcptTasksFolder)' == ''">net472 + <_EfcptTaskAssembly>$(MSBuildThisFileDirectory)..\tasks\$(_EfcptTasksFolder)\JD.Efcpt.Build.Tasks.dll + <_EfcptTaskAssembly Condition="!Exists('$(_EfcptTaskAssembly)')">$(MSBuildThisFileDirectory)..\..\JD.Efcpt.Build.Tasks\bin\$(Configuration)\$(_EfcptTasksFolder)\JD.Efcpt.Build.Tasks.dll + <_EfcptTaskAssembly Condition="!Exists('$(_EfcptTaskAssembly)') and '$(Configuration)' == ''">$(MSBuildThisFileDirectory)..\..\JD.Efcpt.Build.Tasks\bin\Debug\$(_EfcptTasksFolder)\JD.Efcpt.Build.Tasks.dll + + + false - - @@ -37,19 +27,7 @@ <_EfcptIsSqlProject>false - - - <_EfcptTasksFolder Condition="'$(MSBuildRuntimeType)' == 'Core' and $([MSBuild]::VersionGreaterThanOrEquals('$(MSBuildVersion)', '18.0'))">net10.0 - <_EfcptTasksFolder Condition="('$(_EfcptTasksFolder)' == '') and ('$(MSBuildRuntimeType)' == 'Core' and $([MSBuild]::VersionGreaterThanOrEquals('$(MSBuildVersion)', '17.14')))">net10.0 - <_EfcptTasksFolder Condition="('$(_EfcptTasksFolder)' == '') and ('$(MSBuildRuntimeType)' == 'Core' and $([MSBuild]::VersionGreaterThanOrEquals('$(MSBuildVersion)', '17.12')))">net9.0 - <_EfcptTasksFolder Condition="('$(_EfcptTasksFolder)' == '') and ('$(MSBuildRuntimeType)' == 'Core')">net8.0 - <_EfcptTasksFolder Condition="'$(_EfcptTasksFolder)' == ''">net472 - <_EfcptTaskAssembly>$(MSBuildThisFileDirectory)\..\tasks\$(_EfcptTasksFolder)\JD.Efcpt.Build.Tasks.dll - <_EfcptTaskAssembly Condition="!Exists('$(_EfcptTaskAssembly)')">$(MSBuildThisFileDirectory)\..\..\JD.Efcpt.Build.Tasks\bin\$(Configuration)\$(_EfcptTasksFolder)\JD.Efcpt.Build.Tasks.dll - <_EfcptTaskAssembly Condition="(!Exists('$(_EfcptTaskAssembly)')) and ('$(Configuration)' == '')">$(MSBuildThisFileDirectory)\..\..\JD.Efcpt.Build.Tasks\bin\Debug\$(_EfcptTasksFolder)\JD.Efcpt.Build.Tasks.dll - - - + @@ -57,7 +35,6 @@ - @@ -74,32 +51,25 @@ - - - + - - - - - - - + + + - - + <_EfcptScriptsDir>$(EfcptSqlScriptsDir) @@ -113,26 +83,19 @@ - - - + + <_EfcptDatabaseName>$([System.Text.RegularExpressions.Regex]::Match($(EfcptConnectionString), 'Database\s*=\s*\"?([^;"]+)\"?').Groups[1].Value) <_EfcptDatabaseName>$([System.Text.RegularExpressions.Regex]::Match($(EfcptConnectionString), 'Initial Catalog\s*=\s*\"?([^;"]+)\"?').Groups[1].Value) - - - - - - - + + + - - - + @@ -143,8 +106,7 @@ - - + <_EfcptResolvedConfig>$(MSBuildProjectDirectory)\$(EfcptConfig) <_EfcptResolvedConfig>$(MSBuildThisFileDirectory)Defaults\efcpt-config.json @@ -157,12 +119,12 @@ - + - + <_EfcptDacpacPath>$(EfcptDacpac) <_EfcptDacpacPath>$([System.IO.Path]::GetFullPath($([System.IO.Path]::Combine('$(MSBuildProjectDirectory)', '$(EfcptDacpac)')))) @@ -171,19 +133,16 @@ - - - + + - - + - - + @@ -191,56 +150,49 @@ $(_EfcptResolvedDbContextName) - + - - + - - + - + - - - + + - + - - - - - + + <_EfcptDataProjectPath>$(EfcptDataProject) - <_EfcptDataProjectPath>$([System.IO.Path]::GetFullPath($([System.IO.Path]::Combine($(MSBuildProjectDirectory), $(EfcptDataProject))))) + <_EfcptDataProjectPath>$([System.IO.Path]::GetFullPath($([System.IO.Path]::Combine('$(MSBuildProjectDirectory)', '$(EfcptDataProject)')))) - - + + <_EfcptDataDestDir>$(_EfcptDataProjectDir)$(EfcptDataProjectOutputSubdir) - <_EfcptDataProjectDir>$([System.IO.Path]::GetDirectoryName($(_EfcptDataProjectPath)))\ + <_EfcptDataProjectDir>$([System.IO.Path]::GetDirectoryName('$(_EfcptDataProjectPath)'))\ - - + <_EfcptDbContextFiles Include="$(EfcptGeneratedDir)*.g.cs" /> @@ -251,8 +203,8 @@ <_EfcptHasFilesToCopy>true - - + + @@ -266,27 +218,23 @@ - - + - - + - - + - - - + + diff --git a/tests/JD.Efcpt.Build.Tests/Infrastructure/TestScripts.cs b/tests/JD.Efcpt.Build.Tests/Infrastructure/TestScripts.cs index 26127e0a..c20cbb66 100644 --- a/tests/JD.Efcpt.Build.Tests/Infrastructure/TestScripts.cs +++ b/tests/JD.Efcpt.Build.Tests/Infrastructure/TestScripts.cs @@ -76,4 +76,64 @@ goto parse TestFileSystem.MakeExecutable(sh); return sh; } + + /// + /// Writes a trivial, real, always-succeeding script standing in for a tool executable that + /// exits 0 and does nothing else. On Windows this is a .cmd batch file (invoked via + /// cmd.exe /c by CommandNormalizationStrategy); on Linux/macOS it is a shell + /// script with a shebang and the executable bit set, launched directly by + /// without any wrapper. + /// + /// The test folder to write the script into (under its "tools" dir). + /// The script's base name, without extension. + /// The full path to the created script. + public static string CreateAlwaysSucceedsScript(TestFolder folder, string baseName) + { + var toolDir = folder.CreateDir("tools"); + + if (OperatingSystem.IsWindows()) + { + var path = Path.Combine(toolDir, $"{baseName}.cmd"); + File.WriteAllText(path, "@echo off\r\nexit /b 0\r\n"); + return path; + } + + var shPath = Path.Combine(toolDir, $"{baseName}.sh"); + File.WriteAllText(shPath, "#!/bin/sh\nexit 0\n"); + TestFileSystem.MakeExecutable(shPath); + return shPath; + } + + /// + /// Writes a trivial, real, always-succeeding script standing in for dotnet or a global + /// tool executable that appends its own invocation (prefixed with , + /// followed by its arguments) to and exits 0. Standing in for a + /// real executable lets tests assert exactly which commands were (or were not) invoked, + /// without depending on a real dotnet tool installation being present on the test machine. + /// On Windows this is a .cmd batch file (invoked via cmd.exe /c by + /// CommandNormalizationStrategy); on Linux/macOS it is a shell script with a shebang + /// and the executable bit set, launched directly by + /// without any wrapper. + /// + /// The test folder to write the script into (under its "tools" dir). + /// The script's base name, without extension. + /// The label prefixed to each captured invocation line. + /// The file the script appends its invocation line to. + /// The full path to the created script. + public static string CreateCaptureScript(TestFolder folder, string baseName, string label, string captureFile) + { + var toolDir = folder.CreateDir("tools"); + + if (OperatingSystem.IsWindows()) + { + var path = Path.Combine(toolDir, $"{baseName}.cmd"); + File.WriteAllText(path, $"@echo off\r\necho {label} %* >> \"{captureFile}\"\r\nexit /b 0\r\n"); + return path; + } + + var shPath = Path.Combine(toolDir, $"{baseName}.sh"); + File.WriteAllText(shPath, $"#!/bin/sh\necho {label} \"$@\" >> \"{captureFile}\"\nexit 0\n"); + TestFileSystem.MakeExecutable(shPath); + return shPath; + } } diff --git a/tests/JD.Efcpt.Build.Tests/OfflineModeTests.cs b/tests/JD.Efcpt.Build.Tests/OfflineModeTests.cs new file mode 100644 index 00000000..fbef3192 --- /dev/null +++ b/tests/JD.Efcpt.Build.Tests/OfflineModeTests.cs @@ -0,0 +1,498 @@ +using JD.Efcpt.Build.Tasks; +using JD.Efcpt.Build.Tasks.Utilities; +using JD.Efcpt.Build.Tests.Infrastructure; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit; +using Xunit.Abstractions; + +namespace JD.Efcpt.Build.Tests; + +/// +/// Tests for EfcptOfflineMode: verifies that offline mode never spawns any of the three +/// network-dependent tool resolution/restore branches (dnx, tool-manifest restore, global tool +/// update), and that it fails actionably with JD0026 when the tool cannot be guaranteed +/// to run without a network call. +/// +[Feature("EfcptOfflineMode: skip network-dependent tool resolution when offline")] +[Collection(nameof(AssemblySetup))] +public sealed class OfflineModeTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + /// + /// An that throws if any probe method is invoked - used to assert + /// that a given code path genuinely never calls into SDK/dnx/global-tool detection. + /// + private sealed class ThrowingSdkProbe : ISdkProbe + { + public bool IsDotNet10SdkInstalled(string dotnetExe) => + throw new InvalidOperationException("IsDotNet10SdkInstalled should not be called in this scenario."); + + public bool IsDnxAvailable(string dotnetExe) => + throw new InvalidOperationException("IsDnxAvailable should not be called in this scenario."); + + public bool IsGlobalToolInstalled(string toolCommand) => + throw new InvalidOperationException("IsGlobalToolInstalled should not be called in this scenario."); + } + + /// + /// An that deterministically reports every capability as + /// unavailable, without spawning any process - used for the negative (JD0026) scenario so + /// the outcome doesn't depend on what's actually installed on the test machine. + /// + private sealed class AllUnavailableSdkProbe : ISdkProbe + { + public bool IsDotNet10SdkInstalled(string dotnetExe) => false; + public bool IsDnxAvailable(string dotnetExe) => false; + public bool IsGlobalToolInstalled(string toolCommand) => false; + } + + /// + /// An that reports the global tool as installed and throws if the + /// dnx-related probe methods are invoked - used for the global-tool-on-PATH offline scenario, + /// where only should ever be consulted. + /// + private sealed class GlobalToolOnlySdkProbe : ISdkProbe + { + public bool IsDotNet10SdkInstalled(string dotnetExe) => + throw new InvalidOperationException("IsDotNet10SdkInstalled should not be called in this scenario."); + + public bool IsDnxAvailable(string dotnetExe) => + throw new InvalidOperationException("IsDnxAvailable should not be called in this scenario."); + + public bool IsGlobalToolInstalled(string toolCommand) => true; + } + + /// + /// An that records how many times each probe method is invoked, + /// while deterministically reporting every capability as unavailable (so it never itself + /// steers tool resolution toward dnx or the global tool) - used for the offline=false + /// gate-regression scenario, where we want to observe what actually got called rather than + /// assert nothing was. + /// + private sealed class CountingSdkProbe : ISdkProbe + { + public int IsDotNet10SdkInstalledCalls { get; private set; } + public int IsDnxAvailableCalls { get; private set; } + public int IsGlobalToolInstalledCalls { get; private set; } + + public bool IsDotNet10SdkInstalled(string dotnetExe) + { + IsDotNet10SdkInstalledCalls++; + return false; + } + + public bool IsDnxAvailable(string dotnetExe) + { + IsDnxAvailableCalls++; + return false; + } + + public bool IsGlobalToolInstalled(string toolCommand) + { + IsGlobalToolInstalledCalls++; + return false; + } + } + + /// + /// Writes a minimal, valid .config/dotnet-tools.json manifest under + /// 's root that lists an entry for the efcpt tool - matching the + /// default ToolPackageId (ErikEJ.EFCorePowerTools.Cli) and ToolCommand + /// (efcpt) used throughout these tests. + /// + private static void WriteEfcptToolManifest(TestFolder folder) => + folder.WriteFile( + ".config/dotnet-tools.json", + """ + { + "version": 1, + "isRoot": true, + "tools": { + "erikej.efcorepowertools.cli": { + "version": "10.0.0", + "commands": [ "efcpt" ] + } + } + } + """); + + private sealed record SetupState( + TestFolder Folder, + string WorkingDir, + string DacpacPath, + string ConfigPath, + string RenamingPath, + string TemplateDir, + string OutputDir, + TestBuildEngine Engine); + + private sealed record TaskResult( + SetupState Setup, + RunEfcpt Task, + bool Success); + + private static SetupState SetupForDacpacMode() + { + var folder = new TestFolder(); + var workingDir = folder.CreateDir("obj"); + var dacpac = folder.WriteFile("db.dacpac", "DACPAC content"); + var config = folder.WriteFile("efcpt-config.json", "{}"); + var renaming = folder.WriteFile("efcpt.renaming.json", "[]"); + var templateDir = folder.CreateDir("Templates"); + var outputDir = Path.Combine(folder.Root, "Generated"); + + var engine = new TestBuildEngine(); + return new SetupState(folder, workingDir, dacpac, config, renaming, templateDir, outputDir, engine); + } + + [Scenario("Offline mode with a pre-provisioned explicit ToolPath succeeds without touching any SDK probe")] + [Fact] + public async Task Offline_with_explicit_tool_path_skips_all_probes() + { + await Given("inputs for DACPAC mode with a pre-provisioned explicit tool path", () => + { + var setup = SetupForDacpacMode(); + // A trivial, real, always-succeeding script stands in for the efcpt CLI. On + // Windows this is a .cmd file (CommandNormalizationStrategy wraps it via + // `cmd.exe /c`); on Linux/macOS it's a shell script with a shebang and the + // executable bit set, launched directly. Either way it genuinely spawns and + // exits 0 regardless of the args RunEfcpt appends - letting this test exercise + // real tool resolution/restore (not the EFCPT_FAKE_EFCPT short-circuit, which + // returns before any of that runs) while still asserting the (throwing) probe is + // never invoked. + var toolPath = TestScripts.CreateAlwaysSucceedsScript(setup.Folder, "fake-efcpt"); + return (setup, toolPath); + }) + .When("task executes offline with a throwing probe", ctx => + { + var task = new RunEfcpt + { + BuildEngine = ctx.setup.Engine, + WorkingDirectory = ctx.setup.WorkingDir, + DacpacPath = ctx.setup.DacpacPath, + ConfigPath = ctx.setup.ConfigPath, + RenamingPath = ctx.setup.RenamingPath, + TemplateDir = ctx.setup.TemplateDir, + OutputDir = ctx.setup.OutputDir, + ToolMode = "auto", + ToolPackageId = "ErikEJ.EFCorePowerTools.Cli", + TargetFramework = "net10.0", + OfflineMode = "true", + ToolPath = ctx.toolPath, + Probe = new ThrowingSdkProbe() + }; + + var success = task.Execute(); + return new TaskResult(ctx.setup, task, success); + }) + .Then("task succeeds", r => r.Success) + .And("no error is logged", r => r.Setup.Engine.Errors.Count == 0) + .Finally(r => r.Setup.Folder.Dispose()) + .AssertPassed(); + } + + [Scenario("Offline mode with no explicit tool path, no manifest, and no global tool fails actionably with JD0026")] + [Fact] + public async Task Offline_with_no_runnable_tool_fails_with_jd0026() + { + await Given("inputs for DACPAC mode with no pre-provisioned tool", SetupForDacpacMode) + .When("task executes offline without fake mode, TFM net8.0, no manifest, no ToolPath", s => + { + var task = new RunEfcpt + { + BuildEngine = s.Engine, + WorkingDirectory = s.WorkingDir, + DacpacPath = s.DacpacPath, + ConfigPath = s.ConfigPath, + RenamingPath = s.RenamingPath, + TemplateDir = s.TemplateDir, + OutputDir = s.OutputDir, + ToolMode = "auto", + ToolPackageId = "ErikEJ.EFCorePowerTools.Cli", + TargetFramework = "net8.0", + OfflineMode = "true", + ToolPath = "", + Probe = new AllUnavailableSdkProbe() + }; + + var success = task.Execute(); + return new TaskResult(s, task, success); + }) + .Then("task fails", r => !r.Success) + .And("an error is logged", r => r.Setup.Engine.Errors.Count > 0) + .And("the error carries the JD0026 code", r => + r.Setup.Engine.Errors.Any(e => e.Code == "JD0026")) + .And("the error message includes a dotnet tool install command", r => + r.Setup.Engine.Errors.Any(e => + (e.Message?.Contains("dotnet tool install", StringComparison.OrdinalIgnoreCase) ?? false))) + .Finally(r => r.Setup.Folder.Dispose()) + .AssertPassed(); + } + + [Scenario("Offline mode is a no-op when disabled (default)")] + [Fact] + public async Task Offline_mode_disabled_by_default() + { + await Given("inputs for DACPAC mode", SetupForDacpacMode) + .When("task executes in fake mode without setting OfflineMode", s => + { + Environment.SetEnvironmentVariable("EFCPT_FAKE_EFCPT", "true"); + try + { + var task = new RunEfcpt + { + BuildEngine = s.Engine, + WorkingDirectory = s.WorkingDir, + DacpacPath = s.DacpacPath, + ConfigPath = s.ConfigPath, + RenamingPath = s.RenamingPath, + TemplateDir = s.TemplateDir, + OutputDir = s.OutputDir, + ToolMode = "auto", + ToolPackageId = "ErikEJ.EFCorePowerTools.Cli" + }; + var success = task.Execute(); + return new TaskResult(s, task, success); + } + finally + { + Environment.SetEnvironmentVariable("EFCPT_FAKE_EFCPT", null); + } + }) + .Then("task succeeds", r => r.Success) + .And("OfflineMode defaults to false", r => r.Task.OfflineMode == "false") + .Finally(r => r.Setup.Folder.Dispose()) + .AssertPassed(); + } + + // The four scenarios above all short-circuit before the `!ctx.Offline` STRATEGY gates: + // Offline_with_explicit_tool_path_skips_all_probes resolves via the explicit-ToolPath leg + // (the very first `.When` in ToolResolutionStrategy, unconditional on Offline), and + // Offline_mode_disabled_by_default uses EFCPT_FAKE_EFCPT, which returns before tool + // resolution runs at all. The scenarios below exercise the `!ctx.Offline`-gated branches + // directly - tool-manifest resolution/restore and the global-tool/update-global path - to + // prove offline mode actually gates them (and that the gate isn't inverted or always-on). + + private sealed record CaptureResult(SetupState Setup, RunEfcpt Task, bool Success, string CaptureFile); + + [Scenario("Offline mode with a present, tool-listing manifest resolves via 'dotnet tool run' and never probes dnx or the global tool, and never restores")] + [Fact] + public async Task Offline_with_manifest_listing_tool_resolves_via_tool_run_without_restore() + { + await Given("inputs for DACPAC mode with a real tool manifest listing the efcpt tool, and a capturing fake dotnet", () => + { + var setup = SetupForDacpacMode(); + WriteEfcptToolManifest(setup.Folder); + var captureFile = Path.Combine(setup.Folder.Root, "dotnet-invocations.log"); + var fakeDotNet = TestScripts.CreateCaptureScript(setup.Folder, "fake-dotnet", "DOTNET", captureFile); + return (setup, captureFile, fakeDotNet); + }) + .When("task executes offline, TFM net10.0, no explicit ToolPath, with a throwing probe", ctx => + { + var task = new RunEfcpt + { + BuildEngine = ctx.setup.Engine, + WorkingDirectory = ctx.setup.WorkingDir, + DacpacPath = ctx.setup.DacpacPath, + ConfigPath = ctx.setup.ConfigPath, + RenamingPath = ctx.setup.RenamingPath, + TemplateDir = ctx.setup.TemplateDir, + OutputDir = ctx.setup.OutputDir, + ToolMode = "auto", + ToolPackageId = "ErikEJ.EFCorePowerTools.Cli", + ToolCommand = "efcpt", + TargetFramework = "net10.0", + OfflineMode = "true", + ToolPath = "", + DotNetExe = ctx.fakeDotNet, + Probe = new ThrowingSdkProbe() + }; + + var success = task.Execute(); + return new CaptureResult(ctx.setup, task, success, ctx.captureFile); + }) + .Then("task succeeds", r => r.Success) + .And("no error is logged", r => r.Setup.Engine.Errors.Count == 0) + .And("dotnet was invoked exactly once, via 'tool run efcpt'", r => + { + var lines = File.ReadAllLines(r.CaptureFile); + return lines.Length == 1 && + lines[0].Contains("tool run", StringComparison.OrdinalIgnoreCase) && + lines[0].Contains("efcpt", StringComparison.OrdinalIgnoreCase); + }) + .And("tool restore was never invoked", r => + !File.ReadAllText(r.CaptureFile).Contains("tool restore", StringComparison.OrdinalIgnoreCase)) + .And("dnx was never invoked", r => + !File.ReadAllText(r.CaptureFile).Contains("dnx", StringComparison.OrdinalIgnoreCase)) + .And("global tool update was never invoked", r => + !File.ReadAllText(r.CaptureFile).Contains("tool update", StringComparison.OrdinalIgnoreCase)) + .Finally(r => r.Setup.Folder.Dispose()) + .AssertPassed(); + } + + [Scenario("Offline mode with no manifest but a global tool on PATH succeeds directly and never updates the global tool")] + [Fact] + public async Task Offline_with_global_tool_on_path_succeeds_without_update() + { + await Given("inputs for DACPAC mode with no manifest, a fake global-tool executable, and a capturing fake dotnet", () => + { + var setup = SetupForDacpacMode(); + var globalToolCaptureFile = Path.Combine(setup.Folder.Root, "global-tool-invocations.log"); + var dotNetCaptureFile = Path.Combine(setup.Folder.Root, "dotnet-invocations.log"); + var fakeGlobalTool = TestScripts.CreateCaptureScript(setup.Folder, "fake-efcpt-global", "GLOBALTOOL", globalToolCaptureFile); + var fakeDotNet = TestScripts.CreateCaptureScript(setup.Folder, "fake-dotnet", "DOTNET", dotNetCaptureFile); + return (setup, globalToolCaptureFile, dotNetCaptureFile, fakeGlobalTool, fakeDotNet); + }) + .When("task executes offline with no manifest, no ToolPath, and IsGlobalToolInstalled=true", ctx => + { + var task = new RunEfcpt + { + BuildEngine = ctx.setup.Engine, + WorkingDirectory = ctx.setup.WorkingDir, + DacpacPath = ctx.setup.DacpacPath, + ConfigPath = ctx.setup.ConfigPath, + RenamingPath = ctx.setup.RenamingPath, + TemplateDir = ctx.setup.TemplateDir, + OutputDir = ctx.setup.OutputDir, + // Deliberately NOT "auto": RunEfcpt.ExecuteCore sets + // forceManifestOnNonWindows = !IsWindows && !HasExplicitPath(ToolPath), and + // ToolIsAutoOrManifest treats "auto" as manifest-mode whenever + // forceManifestOnNonWindows is true - regardless of whether a manifest + // actually exists. Since this scenario intentionally has no manifest and an + // empty ToolPath, "auto" would resolve via the dotnet-tool-run branch on + // Linux/macOS (invoking fakeDotNet, never fakeGlobalTool) while resolving via + // the Default/global-tool branch on Windows (where forceManifestOnNonWindows + // is always false) - a platform-dependent divergence baked into the product's + // "auto" mode by design (see the ToolMode doc comment: "use a local tool + // manifest if one is discovered ... otherwise fall back to the global tool", + // combined with the non-Windows fragile-PATH guard). "global" is any + // non-"auto"/"tool-manifest" value, which per that same doc comment "behaves + // like the global tool mode" unconditionally on every platform, so this test + // exercises the Default branch (ctx.ToolCommand invoked directly) identically + // on Windows and Linux. + ToolMode = "global", + ToolPackageId = "ErikEJ.EFCorePowerTools.Cli", + // Stands in for a real global tool resolvable on PATH: the Default branch of + // ToolResolutionStrategy invokes ctx.ToolCommand directly as the executable. + ToolCommand = ctx.fakeGlobalTool, + TargetFramework = "net8.0", + OfflineMode = "true", + ToolPath = "", + DotNetExe = ctx.fakeDotNet, + Probe = new GlobalToolOnlySdkProbe() + }; + + var success = task.Execute(); + return (Result: new CaptureResult(ctx.setup, task, success, ctx.globalToolCaptureFile), ctx.dotNetCaptureFile); + }) + .Then("task succeeds", r => r.Result.Success) + .And("no error is logged", r => r.Result.Setup.Engine.Errors.Count == 0) + .And("the global tool executable was invoked directly, exactly once", r => + { + var lines = File.ReadAllLines(r.Result.CaptureFile); + return lines.Length == 1 && lines[0].StartsWith("GLOBALTOOL", StringComparison.Ordinal); + }) + .And("dotnet (and therefore 'tool update --global') was never invoked", r => + !File.Exists(r.dotNetCaptureFile)) + .Finally(r => r.Result.Setup.Folder.Dispose()) + .AssertPassed(); + } + + [Scenario("EFCPT_OFFLINE environment variable alone (no EfcptOfflineMode property) reproduces offline pre-flight behavior")] + [Fact] + public async Task Offline_env_var_alone_triggers_jd0026() + { + await Given("inputs for DACPAC mode with no pre-provisioned tool", SetupForDacpacMode) + .When("task executes with EFCPT_OFFLINE set but OfflineMode left at its default", s => + { + Environment.SetEnvironmentVariable("EFCPT_OFFLINE", "true"); + try + { + var task = new RunEfcpt + { + BuildEngine = s.Engine, + WorkingDirectory = s.WorkingDir, + DacpacPath = s.DacpacPath, + ConfigPath = s.ConfigPath, + RenamingPath = s.RenamingPath, + TemplateDir = s.TemplateDir, + OutputDir = s.OutputDir, + ToolMode = "auto", + ToolPackageId = "ErikEJ.EFCorePowerTools.Cli", + TargetFramework = "net8.0", + // OfflineMode intentionally left unset (defaults to "false") - only the + // EFCPT_OFFLINE environment variable signals offline. This exercises the + // task-level OR-in at RunEfcpt.cs (`OfflineMode.IsTrue() || + // Environment.GetEnvironmentVariable("EFCPT_OFFLINE").IsTrue()`), which is + // independent of the MSBuild-property-level bridge added to + // BuildTransitivePropsFactory (verified separately via the generated + // buildTransitive/*.props XML, since exercising MSBuild property + // evaluation itself would require a full MSBuild target harness rather + // than this task-level unit test). + ToolPath = "", + Probe = new AllUnavailableSdkProbe() + }; + + var success = task.Execute(); + return new TaskResult(s, task, success); + } + finally + { + Environment.SetEnvironmentVariable("EFCPT_OFFLINE", null); + } + }) + .Then("task fails", r => !r.Success) + .And("OfflineMode property itself is still the default 'false'", r => r.Task.OfflineMode == "false") + .And("the error carries the JD0026 code", r => + r.Setup.Engine.Errors.Any(e => e.Code == "JD0026")) + .Finally(r => r.Setup.Folder.Dispose()) + .AssertPassed(); + } + + [Scenario("Offline=false gate-regression: a present manifest is restored normally, proving the !ctx.Offline gate is not inverted or always-on")] + [Fact] + public async Task Offline_disabled_gate_regression_manifest_restore_runs() + { + await Given("inputs for DACPAC mode with a real tool manifest, offline explicitly disabled, and a capturing fake dotnet", () => + { + var setup = SetupForDacpacMode(); + WriteEfcptToolManifest(setup.Folder); + var captureFile = Path.Combine(setup.Folder.Root, "dotnet-invocations.log"); + var fakeDotNet = TestScripts.CreateCaptureScript(setup.Folder, "fake-dotnet", "DOTNET", captureFile); + return (setup, captureFile, fakeDotNet); + }) + .When("task executes with OfflineMode=false, TFM net8.0, manifest present, counting probe", ctx => + { + var probe = new CountingSdkProbe(); + var task = new RunEfcpt + { + BuildEngine = ctx.setup.Engine, + WorkingDirectory = ctx.setup.WorkingDir, + DacpacPath = ctx.setup.DacpacPath, + ConfigPath = ctx.setup.ConfigPath, + RenamingPath = ctx.setup.RenamingPath, + TemplateDir = ctx.setup.TemplateDir, + OutputDir = ctx.setup.OutputDir, + ToolMode = "auto", + ToolPackageId = "ErikEJ.EFCorePowerTools.Cli", + ToolCommand = "efcpt", + TargetFramework = "net8.0", + OfflineMode = "false", + ToolPath = "", + DotNetExe = ctx.fakeDotNet, + Probe = probe + }; + + var success = task.Execute(); + return (Result: new CaptureResult(ctx.setup, task, success, ctx.captureFile), Probe: probe); + }) + .Then("task succeeds", r => r.Result.Success) + .And("no error is logged", r => r.Result.Setup.Engine.Errors.Count == 0) + .And("tool restore DID run (the normal, non-offline path)", r => + File.ReadAllText(r.Result.CaptureFile).Contains("tool restore", StringComparison.OrdinalIgnoreCase)) + .And("tool run also ran, after restore", r => + File.ReadAllText(r.Result.CaptureFile).Contains("tool run", StringComparison.OrdinalIgnoreCase)) + .Finally(r => r.Result.Setup.Folder.Dispose()) + .AssertPassed(); + } +}