Skip to content

Commit efab3d5

Browse files
authored
Ensure func init and func new can run without network connectivity (#4775)
1 parent edd10ec commit efab3d5

5 files changed

Lines changed: 188 additions & 5 deletions

File tree

release_notes.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
- `func bundles add` - Add extension bundle configuration to host.json with `--channel` flag to select GA (default), Preview, or Experimental bundles
2424
- Support for custom bundle download paths via `AzureFunctionsJobHost__extensionBundle__downloadPath` environment variable
2525
- Added `--bundles-channel` option to `func init` command to specify extension bundle channel (GA, Preview, or Experimental) during project initialization
26-
- Fallback to cached bundles if there is no network connection during `func start` (#4772)
2726
- Added global `--offline` variable to run in offline mode (#4772)
28-
- Improved error message for `func azure functionapp publish` when the connection fails due to networking restrictions, with a link to networking options documentation. (#4807)
27+
- Fallback to cached bundles if there is no network connection during `func start` (#4772)
28+
- Enable offline support for `func init` and `func new` (#4775)
29+
- Improved error message for `func azure functionapp publish` when the connection fails due to networking restrictions, with a link to networking options documentation. (#4807)

src/Cli/func/Actions/HelpAction.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ private static void DisplayGlobalOptions()
254254
{
255255
("--script-root <PATH>", "Set the root directory of the function app. Changes the working directory to the specified path. Defaults to the current directory."),
256256
("--verbose", "Enable verbose output for detailed logging (not supported by all commands)."),
257-
("--offline", "Run the function app in offline mode. Supported by func start."),
257+
("--offline", "Run the function app in offline mode. Supported by func start, func init, and func new."),
258258
("-v | --version", "Display the version of Azure Functions Core Tools."),
259259
("-h | --help", "Display help information about Azure Functions Core Tools or a specific command.")
260260
};

src/Cli/func/Actions/LocalActions/InitAction/InitAction.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,12 @@ public async Task FetchPackages(WorkerRuntime workerRuntime, ProgrammingModel pr
607607
{
608608
if (workerRuntime == Helpers.WorkerRuntime.Node && programmingModel == Common.ProgrammingModel.V4)
609609
{
610+
if (GlobalCoreToolsSettings.IsOfflineMode)
611+
{
612+
ColoredConsole.WriteLine(WarningColor("Skipping \"npm install\" because the CLI is running in offline mode. You must run \"npm install\" manually when network is available."));
613+
return;
614+
}
615+
610616
try
611617
{
612618
await NpmHelper.Install();

src/Cli/func/Common/TemplatesManager.cs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,15 @@ private static async Task<IEnumerable<Template>> GetTemplates()
7575
templatesJson = await FileLockHelper.WithFileLockAsync(BundleTemplatesLockFileName, async () =>
7676
{
7777
await ExtensionBundleHelper.GetExtensionBundle();
78-
var contentProvider = ExtensionBundleHelper.GetExtensionBundleContentProvider();
79-
return await contentProvider.GetTemplates();
78+
79+
// Get content provider if not offline; otherwise just get templates within CLI
80+
if (!GlobalCoreToolsSettings.IsOfflineMode)
81+
{
82+
var contentProvider = ExtensionBundleHelper.GetExtensionBundleContentProvider();
83+
return await contentProvider.GetTemplates();
84+
}
85+
86+
return GetTemplatesJson();
8087
});
8188
}
8289
else
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License. See LICENSE in the project root for license information.
3+
4+
using Azure.Functions.Cli.Common;
5+
using Azure.Functions.Cli.Helpers;
6+
using Azure.Functions.Cli.Interfaces;
7+
using FluentAssertions;
8+
using NSubstitute;
9+
using Xunit;
10+
11+
namespace Azure.Functions.Cli.UnitTests.CommonTests
12+
{
13+
/// <summary>
14+
/// Tests for <see cref="TemplatesManager"/> focusing on the offline mode
15+
/// fallback behavior introduced to skip the extension bundle content provider
16+
/// when the CLI is running in offline mode.
17+
/// </summary>
18+
[Collection("BundleActionTests")]
19+
public class TemplatesManagerTests : IDisposable
20+
{
21+
private readonly string _previousWorkingDir;
22+
private readonly string _testWorkingDir;
23+
private readonly string _previousOfflineEnv;
24+
private readonly string _previousBundlePathEnv;
25+
26+
public TemplatesManagerTests()
27+
{
28+
// Save current state
29+
_previousWorkingDir = Directory.GetCurrentDirectory();
30+
_previousOfflineEnv = Environment.GetEnvironmentVariable(Constants.FunctionsCoreToolsOffline);
31+
_previousBundlePathEnv = Environment.GetEnvironmentVariable(Constants.ExtensionBundleDownloadPath);
32+
33+
// Create an isolated working directory (no host.json by default)
34+
_testWorkingDir = Path.Combine(
35+
Path.GetTempPath(), "TemplatesManagerTest", Guid.NewGuid().ToString());
36+
Directory.CreateDirectory(_testWorkingDir);
37+
Directory.SetCurrentDirectory(_testWorkingDir);
38+
39+
// Ensure the lock file directory exists (CI agents may not have it)
40+
var lockDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".azurefunctions");
41+
Directory.CreateDirectory(lockDir);
42+
43+
// Clean environment for deterministic tests
44+
Environment.SetEnvironmentVariable(Constants.FunctionsCoreToolsOffline, null);
45+
Environment.SetEnvironmentVariable(Constants.ExtensionBundleDownloadPath, null);
46+
OfflineHelper.MarkAsOnline();
47+
}
48+
49+
public void Dispose()
50+
{
51+
Directory.SetCurrentDirectory(_previousWorkingDir);
52+
OfflineHelper.MarkAsOnline();
53+
Environment.SetEnvironmentVariable(Constants.FunctionsCoreToolsOffline, _previousOfflineEnv);
54+
Environment.SetEnvironmentVariable(Constants.ExtensionBundleDownloadPath, _previousBundlePathEnv);
55+
56+
try
57+
{
58+
Directory.Delete(_testWorkingDir, true);
59+
}
60+
catch
61+
{
62+
// best effort
63+
}
64+
}
65+
66+
private void WriteHostJsonWithBundles()
67+
{
68+
var hostJson = @"{
69+
""version"": ""2.0"",
70+
""extensionBundle"": {
71+
""id"": ""Microsoft.Azure.Functions.ExtensionBundle"",
72+
""version"": ""[4.*, 5.0.0)""
73+
}
74+
}";
75+
File.WriteAllText(Path.Combine(_testWorkingDir, "host.json"), hostJson);
76+
}
77+
78+
private string CreateCachedBundleDirectory(string version = "4.5.0")
79+
{
80+
var cachePath = Path.Combine(
81+
Path.GetTempPath(), "TemplateManagerBundleCache", Guid.NewGuid().ToString());
82+
Directory.CreateDirectory(Path.Combine(cachePath, version));
83+
return cachePath;
84+
}
85+
86+
// ─── Extension bundles configured + offline mode ────────────────
87+
[Fact]
88+
public async Task Templates_BundlesConfiguredAndOffline_WithCachedBundle_FallsBackToLocalTemplates()
89+
{
90+
// Arrange — host.json with extension bundles, a cached bundle, and offline mode
91+
WriteHostJsonWithBundles();
92+
93+
var cachePath = CreateCachedBundleDirectory("4.5.0");
94+
Environment.SetEnvironmentVariable(Constants.ExtensionBundleDownloadPath, cachePath);
95+
OfflineHelper.MarkAsOffline();
96+
97+
try
98+
{
99+
var manager = new TemplatesManager(Substitute.For<ISecretsManager>());
100+
101+
// Act — when offline, should skip the content provider and
102+
// fall back to the local templates.json bundled with the CLI
103+
var templates = await manager.Templates;
104+
105+
// Assert — should successfully load local templates
106+
templates.Should().NotBeNullOrEmpty(
107+
"offline mode with bundles configured should fall back to local CLI templates");
108+
templates.Should().Contain(
109+
t => t.Metadata != null && t.Metadata.TriggerType == "httpTrigger",
110+
"local fallback templates should include HTTP trigger templates");
111+
}
112+
finally
113+
{
114+
try
115+
{
116+
Directory.Delete(cachePath, true);
117+
}
118+
catch
119+
{
120+
// best effort cleanup
121+
}
122+
}
123+
}
124+
125+
[Fact]
126+
public async Task Templates_BundlesConfiguredAndOffline_NoCachedBundle_ThrowsCliException()
127+
{
128+
// Arrange — host.json with bundles configured but no cached bundle available
129+
WriteHostJsonWithBundles();
130+
131+
var emptyCachePath = Path.Combine(
132+
Path.GetTempPath(), "TemplateManagerEmptyCache", Guid.NewGuid().ToString());
133+
Directory.CreateDirectory(emptyCachePath);
134+
Environment.SetEnvironmentVariable(Constants.ExtensionBundleDownloadPath, emptyCachePath);
135+
OfflineHelper.MarkAsOffline();
136+
137+
try
138+
{
139+
var manager = new TemplatesManager(Substitute.For<ISecretsManager>());
140+
141+
// Act & Assert — GetExtensionBundle will throw because no cached bundle exists
142+
var ex = await Assert.ThrowsAsync<CliException>(() => manager.Templates);
143+
ex.Message.Should().Contain("no cached version");
144+
}
145+
finally
146+
{
147+
try
148+
{
149+
Directory.Delete(emptyCachePath, true);
150+
}
151+
catch
152+
{
153+
// best effort cleanup
154+
}
155+
}
156+
}
157+
158+
[Fact]
159+
public void TemplatesManager_CanBeInstantiated()
160+
{
161+
// Arrange & Act
162+
var manager = new TemplatesManager(Substitute.For<ISecretsManager>());
163+
164+
// Assert
165+
manager.Should().NotBeNull();
166+
manager.Should().BeAssignableTo<ITemplatesManager>();
167+
}
168+
}
169+
}

0 commit comments

Comments
 (0)