Skip to content

Commit eb732f0

Browse files
committed
Introduce installation goal state
The existing LibraryInstallationState abstraction is not flexible about mapping files to their source on an individual basis. It presumes that all files installed by a library retain the same folder hierarchy as the library they originate from. In order to allow more flexibility, this change adds a new abstraction to wrap all installed files to their individual source files. This eliminates the prior assumption, allowing for flexibility to install files to a different folder hierarchy than their originating library structure. This flexibility also allows an important new concept: each destination file must be unique (to avoid collisions), but the sources are not so constrained. A single source file may be installed to multiple destinations; by creating a mapping of destination-to-source, we can easily allow this. This change prepares for a new feature to allow more granular mappings of files within a library.
1 parent b603e16 commit eb732f0

File tree

3 files changed

+285
-48
lines changed

3 files changed

+285
-48
lines changed
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System.Collections.Generic;
5+
using System.IO;
6+
7+
namespace Microsoft.Web.LibraryManager.Contracts
8+
{
9+
/// <summary>
10+
/// Represents a goal state of deployed files mapped to their sources from the local cache
11+
/// </summary>
12+
public class LibraryInstallationGoalState
13+
{
14+
/// <summary>
15+
/// Initialize a new goal state from the desired installation state.
16+
/// </summary>
17+
public LibraryInstallationGoalState(ILibraryInstallationState installationState)
18+
{
19+
InstallationState = installationState;
20+
}
21+
22+
/// <summary>
23+
/// The ILibraryInstallationState that this goal state was computed from.
24+
/// </summary>
25+
public ILibraryInstallationState InstallationState { get; }
26+
27+
/// <summary>
28+
/// Mapping from destination file to source file
29+
/// </summary>
30+
public IDictionary<string, string> InstalledFiles { get; } = new Dictionary<string, string>();
31+
32+
/// <summary>
33+
/// Returns whether the goal is in an achieved state - that is, all files are up to date.
34+
/// </summary>
35+
/// <remarks>
36+
/// This is intended to serve as a fast check compared to restoring the files.
37+
/// If there isn't a faster way to verify that a file is up to date, this method should
38+
/// return false to indicate that a restore can't be skipped.
39+
/// </remarks>
40+
public bool IsAchieved()
41+
{
42+
foreach (KeyValuePair<string, string> kvp in InstalledFiles)
43+
{
44+
var destinationFile = new FileInfo(kvp.Key);
45+
var cacheFile = new FileInfo(kvp.Value);
46+
47+
if (!destinationFile.Exists || !cacheFile.Exists || !FileHelpers.AreFilesUpToDate(destinationFile, cacheFile))
48+
{
49+
return false;
50+
}
51+
}
52+
53+
return true;
54+
}
55+
}
56+
}

src/LibraryManager/Providers/BaseProvider.cs

Lines changed: 105 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
namespace Microsoft.Web.LibraryManager.Providers
1717
{
1818
/// <summary>
19-
/// Default implenentation for a provider, since most provider implementations are very similar.
19+
/// Default implementation for a provider, since most provider implementations are very similar.
2020
/// </summary>
2121
internal abstract class BaseProvider : IProvider
2222
{
@@ -60,30 +60,61 @@ public virtual async Task<ILibraryOperationResult> InstallAsync(ILibraryInstalla
6060
return LibraryOperationResult.FromCancelled(desiredState);
6161
}
6262

63-
//Expand the files property if needed
64-
ILibraryOperationResult updateResult = await UpdateStateAsync(desiredState, cancellationToken);
65-
if (!updateResult.Success)
66-
{
67-
return updateResult;
68-
}
63+
ILibraryCatalog catalog = GetCatalog();
64+
ILibrary library = await catalog.GetLibraryAsync(desiredState.Name, desiredState.Version, cancellationToken).ConfigureAwait(false);
6965

70-
desiredState = updateResult.InstallationState;
66+
LibraryInstallationGoalState goalState = GenerateGoalState(desiredState, library);
7167

72-
// Refresh cache if needed
73-
ILibraryOperationResult cacheUpdateResult = await RefreshCacheAsync(desiredState, cancellationToken);
74-
if (!cacheUpdateResult.Success)
68+
if (!IsSourceCacheReady(goalState))
7569
{
76-
return cacheUpdateResult;
70+
ILibraryOperationResult updateCacheResult = await RefreshCacheAsync(desiredState, library, cancellationToken);
71+
if (!updateCacheResult.Success)
72+
{
73+
return updateCacheResult;
74+
}
7775
}
7876

79-
// Check if Library is already up to date
80-
if (IsLibraryUpToDate(desiredState))
77+
if (goalState.IsAchieved())
8178
{
8279
return LibraryOperationResult.FromUpToDate(desiredState);
8380
}
8481

85-
// Write files to destination
86-
return await WriteToFilesAsync(desiredState, cancellationToken);
82+
return await InstallFiles(goalState, cancellationToken);
83+
84+
}
85+
86+
private async Task<LibraryOperationResult> InstallFiles(LibraryInstallationGoalState goalState, CancellationToken cancellationToken)
87+
{
88+
try
89+
{
90+
foreach (KeyValuePair<string, string> kvp in goalState.InstalledFiles)
91+
{
92+
if (cancellationToken.IsCancellationRequested)
93+
{
94+
return LibraryOperationResult.FromCancelled(goalState.InstallationState);
95+
}
96+
97+
string sourcePath = kvp.Value;
98+
string destinationPath = kvp.Key;
99+
bool writeOk = await HostInteraction.CopyFileAsync(sourcePath, destinationPath, cancellationToken);
100+
101+
if (!writeOk)
102+
{
103+
return new LibraryOperationResult(goalState.InstallationState, PredefinedErrors.CouldNotWriteFile(destinationPath));
104+
}
105+
}
106+
}
107+
catch (UnauthorizedAccessException)
108+
{
109+
return new LibraryOperationResult(goalState.InstallationState, PredefinedErrors.PathOutsideWorkingDirectory());
110+
}
111+
catch (Exception ex)
112+
{
113+
HostInteraction.Logger.Log(ex.ToString(), LogLevel.Error);
114+
return new LibraryOperationResult(goalState.InstallationState, PredefinedErrors.UnknownException());
115+
}
116+
117+
return LibraryOperationResult.FromSuccess(goalState.InstallationState);
87118
}
88119

89120
/// <inheritdoc />
@@ -165,6 +196,50 @@ public virtual async Task<ILibraryOperationResult> UpdateStateAsync(ILibraryInst
165196

166197
#endregion
167198

199+
public LibraryInstallationGoalState GenerateGoalState(ILibraryInstallationState desiredState, ILibrary library)
200+
{
201+
var goalState = new LibraryInstallationGoalState(desiredState);
202+
IEnumerable<string> outFiles;
203+
if (desiredState.Files == null || desiredState.Files.Count == 0)
204+
{
205+
outFiles = library.Files.Keys;
206+
}
207+
else
208+
{
209+
outFiles = FileGlobbingUtility.ExpandFileGlobs(desiredState.Files, library.Files.Keys);
210+
}
211+
212+
foreach (string outFile in outFiles)
213+
{
214+
// strip the source prefix
215+
string destinationFile = Path.Combine(HostInteraction.WorkingDirectory, desiredState.DestinationPath, outFile);
216+
217+
// don't forget to include the cache folder in the path
218+
string sourceFile = GetCachedFileLocalPath(desiredState, outFile);
219+
220+
// TODO: make goalState immutable
221+
// map destination back to the library-relative file it originated from
222+
goalState.InstalledFiles.Add(destinationFile, sourceFile);
223+
}
224+
225+
return goalState;
226+
}
227+
228+
public bool IsSourceCacheReady(LibraryInstallationGoalState goalState)
229+
{
230+
foreach (KeyValuePair<string, string> item in goalState.InstalledFiles)
231+
{
232+
string cachePath = GetCachedFileLocalPath(goalState.InstallationState, item.Value);
233+
// TODO: use abstraction for filesystem ops
234+
if (!File.Exists(cachePath))
235+
{
236+
return false;
237+
}
238+
}
239+
240+
return true;
241+
}
242+
168243
protected virtual ILibraryOperationResult CheckForInvalidFiles(ILibraryInstallationState desiredState, string libraryId, ILibrary library)
169244
{
170245
IReadOnlyList<string> invalidFiles = library.GetInvalidFiles(desiredState.Files);
@@ -239,36 +314,7 @@ protected async Task<ILibraryOperationResult> WriteToFilesAsync(ILibraryInstalla
239314
/// <returns></returns>
240315
private string GetCachedFileLocalPath(ILibraryInstallationState state, string sourceFile)
241316
{
242-
return Path.Combine(CacheFolder, state.Name, state.Version, sourceFile);
243-
}
244-
245-
private bool IsLibraryUpToDate(ILibraryInstallationState state)
246-
{
247-
try
248-
{
249-
if (!string.IsNullOrEmpty(state.Name) && !string.IsNullOrEmpty(state.Version))
250-
{
251-
string cacheDir = Path.Combine(CacheFolder, state.Name, state.Version);
252-
string destinationDir = Path.Combine(HostInteraction.WorkingDirectory, state.DestinationPath);
253-
254-
foreach (string sourceFile in state.Files)
255-
{
256-
var destinationFile = new FileInfo(Path.Combine(destinationDir, sourceFile).Replace('\\', '/'));
257-
var cacheFile = new FileInfo(Path.Combine(cacheDir, sourceFile).Replace('\\', '/'));
258-
259-
if (!destinationFile.Exists || !cacheFile.Exists || !FileHelpers.AreFilesUpToDate(destinationFile, cacheFile))
260-
{
261-
return false;
262-
}
263-
}
264-
}
265-
}
266-
catch
267-
{
268-
return false;
269-
}
270-
271-
return true;
317+
return Path.Combine(CacheFolder, state.Name, state.Version, sourceFile.Trim('/'));
272318
}
273319

274320
/// <summary>
@@ -277,7 +323,7 @@ private bool IsLibraryUpToDate(ILibraryInstallationState state)
277323
/// <param name="state"></param>
278324
/// <param name="cancellationToken"></param>
279325
/// <returns></returns>
280-
private async Task<ILibraryOperationResult> RefreshCacheAsync(ILibraryInstallationState state, CancellationToken cancellationToken)
326+
private async Task<ILibraryOperationResult> RefreshCacheAsync(ILibraryInstallationState state, ILibrary library, CancellationToken cancellationToken)
281327
{
282328
if (cancellationToken.IsCancellationRequested)
283329
{
@@ -288,8 +334,19 @@ private async Task<ILibraryOperationResult> RefreshCacheAsync(ILibraryInstallati
288334

289335
try
290336
{
337+
IEnumerable<string> filesToCache;
338+
// expand "files" to concrete files in the library
339+
if (state.Files == null || state.Files.Count == 0)
340+
{
341+
filesToCache = library.Files.Keys;
342+
}
343+
else
344+
{
345+
filesToCache = FileGlobbingUtility.ExpandFileGlobs(state.Files, library.Files.Keys);
346+
}
347+
291348
var librariesMetadata = new HashSet<CacheFileMetadata>();
292-
foreach (string sourceFile in state.Files)
349+
foreach (string sourceFile in filesToCache)
293350
{
294351
string cacheFile = Path.Combine(libraryDir, sourceFile);
295352
string url = GetDownloadUrl(state, sourceFile);
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.IO;
7+
using Microsoft.VisualStudio.TestTools.UnitTesting;
8+
using Microsoft.Web.LibraryManager.Cache;
9+
using Microsoft.Web.LibraryManager.Contracts;
10+
using Microsoft.Web.LibraryManager.Providers;
11+
using Microsoft.Web.LibraryManager.Resources;
12+
13+
namespace Microsoft.Web.LibraryManager.Test.Providers
14+
{
15+
[TestClass]
16+
public class BaseProviderTest
17+
{
18+
private IHostInteraction _hostInteraction;
19+
private ILibrary _library;
20+
21+
public BaseProviderTest()
22+
{
23+
_hostInteraction = new Mocks.HostInteraction()
24+
{
25+
CacheDirectory = "C:\\cache",
26+
WorkingDirectory = "C:\\project",
27+
};
28+
29+
_library = new Mocks.Library()
30+
{
31+
Name = "test",
32+
Version = "1.0",
33+
ProviderId = "TestProvider",
34+
Files = new Dictionary<string, bool>()
35+
{
36+
{ "file1.txt", true },
37+
{ "file2.txt", false },
38+
{ "folder/file3.txt", false },
39+
},
40+
};
41+
42+
}
43+
44+
[TestMethod]
45+
public void GenerateGoalState_NoFileMapping_SpecifyFilesAtLibraryLevel()
46+
{
47+
ILibraryInstallationState installState = new LibraryInstallationState
48+
{
49+
Name = "test",
50+
Version = "1.0",
51+
ProviderId = "TestProvider",
52+
DestinationPath = "lib/test",
53+
Files = ["folder/*.txt"],
54+
};
55+
BaseProvider provider = new TestProvider(_hostInteraction, cacheService: null);
56+
string expectedDestinationFile1 = FileHelpers.NormalizePath(Path.Combine(provider.HostInteraction.WorkingDirectory, "lib/test/folder/file3.txt"));
57+
string expectedSourceFile1 = FileHelpers.NormalizePath(Path.Combine(provider.HostInteraction.CacheDirectory, "TestProvider/test/1.0/folder/file3.txt"));
58+
59+
LibraryInstallationGoalState goalState = provider.GenerateGoalState(installState, _library);
60+
61+
Assert.IsNotNull(goalState);
62+
Assert.AreEqual(1, goalState.InstalledFiles.Count);
63+
Assert.IsTrue(goalState.InstalledFiles.TryGetValue(expectedDestinationFile1, out string file1));
64+
Assert.AreEqual(expectedSourceFile1, file1);
65+
}
66+
67+
[TestMethod]
68+
public void GenerateGoalState_NoFileMapping_NoFilesAtLibraryLevel()
69+
{
70+
ILibraryInstallationState installState = new LibraryInstallationState
71+
{
72+
Name = "test",
73+
Version = "1.0",
74+
ProviderId = "TestProvider",
75+
DestinationPath = "lib/test",
76+
};
77+
BaseProvider provider = new TestProvider(_hostInteraction, cacheService: null);
78+
string expectedDestinationFile1 = FileHelpers.NormalizePath(Path.Combine(provider.HostInteraction.WorkingDirectory, "lib/test/file1.txt"));
79+
string expectedSourceFile1 = FileHelpers.NormalizePath(Path.Combine(provider.HostInteraction.CacheDirectory, "TestProvider/test/1.0/file1.txt"));
80+
string expectedDestinationFile2 = FileHelpers.NormalizePath(Path.Combine(provider.HostInteraction.WorkingDirectory, "lib/test/file2.txt"));
81+
string expectedSourceFile2 = FileHelpers.NormalizePath(Path.Combine(provider.HostInteraction.CacheDirectory, "TestProvider/test/1.0/file2.txt"));
82+
string expectedDestinationFile3 = FileHelpers.NormalizePath(Path.Combine(provider.HostInteraction.WorkingDirectory, "lib/test/folder/file3.txt"));
83+
string expectedSourceFile3 = FileHelpers.NormalizePath(Path.Combine(provider.HostInteraction.CacheDirectory, "TestProvider/test/1.0/folder/file3.txt"));
84+
85+
LibraryInstallationGoalState goalState = provider.GenerateGoalState(installState, _library);
86+
87+
Assert.IsNotNull(goalState);
88+
Assert.AreEqual(3, goalState.InstalledFiles.Count);
89+
Assert.IsTrue(goalState.InstalledFiles.TryGetValue(expectedDestinationFile1, out string file1));
90+
Assert.AreEqual(expectedSourceFile1, file1);
91+
Assert.IsTrue(goalState.InstalledFiles.TryGetValue(expectedDestinationFile2, out string file2));
92+
Assert.AreEqual(expectedSourceFile2, file2);
93+
Assert.IsTrue(goalState.InstalledFiles.TryGetValue(expectedDestinationFile3, out string file3));
94+
Assert.AreEqual(expectedSourceFile3, file3);
95+
}
96+
97+
private class TestProvider : BaseProvider
98+
{
99+
public TestProvider(IHostInteraction hostInteraction, CacheService cacheService)
100+
: base(hostInteraction, cacheService)
101+
{
102+
}
103+
104+
public override string Id => nameof(TestProvider);
105+
106+
public override string LibraryIdHintText => Text.CdnjsLibraryIdHintText;
107+
108+
public override ILibraryCatalog GetCatalog()
109+
{
110+
throw new NotImplementedException();
111+
}
112+
113+
public override string GetSuggestedDestination(ILibrary library)
114+
{
115+
throw new NotImplementedException();
116+
}
117+
118+
protected override string GetDownloadUrl(ILibraryInstallationState state, string sourceFile)
119+
{
120+
throw new NotImplementedException();
121+
}
122+
}
123+
}
124+
}

0 commit comments

Comments
 (0)