diff --git a/docs/readme.md b/docs/readme.md index 02a0fadb0..8aa7d8a2c 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -50,6 +50,7 @@ The **[Getting Started Guide](getting-started/0-overview.md)** provides a step-b * [Update Manager](using/update-manager.md) - reference guide for the `UpdateManager`. * [GitHub](using/github.md) - overview of using GitHub for installing, distributing, and updating. * [Debugging Updates](using/debugging-updates.md) - tips for debugging Squirrel.Windows updates. + * [Staged Rollouts](using/staged-rollouts.md) - how to use staged rollouts to ramp up install distribution over time ## Contributing diff --git a/docs/using/staged-rollouts.md b/docs/using/staged-rollouts.md new file mode 100644 index 000000000..f9dac01cd --- /dev/null +++ b/docs/using/staged-rollouts.md @@ -0,0 +1,50 @@ +| [docs](..) / [using](.) / staged-rollouts.md +|:---| + +# Staged Rollouts + +Staged rollouts allow you to distribute the latest version of your app to a subset of users that you can increase over time, similar to rollouts on platforms like Google Play. This feature requires Squirrel.Windows 1.4.0 or above. + +### How to use + +Staged rollouts are controlled by manually editing your `RELEASES` file. Here's an example: + +~~~ +e3f67244e4166a65310c816221a12685c83f8e6f myapp-1.0.0-full.nupkg 600725 +~~~ + +Now let's ship a new version to 10% of our userbase. + +``` +e3f67244e4166a65310c816221a12685c83f8e6f myapp-1.0.0-full.nupkg 600725 +0d777ea94c612e8bf1ea7379164caefba6e24463 myapp-1.0.1-delta.nupkg 6030# 10% +85f4d657f8424dd437d1b33cc4511ea7ad86b1a7 myapp-1.0.1-full.nupkg 600752# 10% +``` + +Note that the syntax is `# nn%` - due to a bug in earlier versions of Squirrel.Windows, for now, you *must* put the `#` immediately following the file size, no spaces. Once all of your users have Squirrel 1.4.0 or higher, you can add a space after the `#` (similar to a comment). + +Assuming that this rollout is going well, at some point you can upload a new version of the `RELEASES` file: + +``` +e3f67244e4166a65310c816221a12685c83f8e6f myapp-1.0.0-full.nupkg 600725 +0d777ea94c612e8bf1ea7379164caefba6e24463 myapp-1.0.1-delta.nupkg 6030# 50% +85f4d657f8424dd437d1b33cc4511ea7ad86b1a7 myapp-1.0.1-full.nupkg 600752# 50% +``` + +When you're confident that this release has gone successfully, you can remove the comment so that 100% of users get the file: + +``` +e3f67244e4166a65310c816221a12685c83f8e6f myapp-1.0.0-full.nupkg 600725 +0d777ea94c612e8bf1ea7379164caefba6e24463 myapp-1.0.1-delta.nupkg 6030 +85f4d657f8424dd437d1b33cc4511ea7ad86b1a7 myapp-1.0.1-full.nupkg 600752 +``` + +### Handling failed rollouts + +If you want to pull a staged release because it hasn't gone well, you should hand-edit the RELEASES file to completely remove the bad version: + +~~~ +e3f67244e4166a65310c816221a12685c83f8e6f myapp-1.0.0-full.nupkg 600725 +~~~ + +Once you do this, you **must** increment the version number higher than your broken release (in this example, we would need to release MyApp 1.0.2). Because some of your users will be on the broken 1.0.1, releasing a _new_ 1.0.1 would result in them staying on a broken version. diff --git a/src/Squirrel/ReleaseEntry.cs b/src/Squirrel/ReleaseEntry.cs index 70f86c20a..855c82dd7 100644 --- a/src/Squirrel/ReleaseEntry.cs +++ b/src/Squirrel/ReleaseEntry.cs @@ -22,6 +22,7 @@ public interface IReleaseEntry string EntryAsString { get; } SemanticVersion Version { get; } string PackageName { get; } + float? StagingPercentage { get; } string GetReleaseNotes(string packageDirectory); Uri GetIconUrl(string packageDirectory); @@ -36,20 +37,27 @@ public class ReleaseEntry : IEnableLogger, IReleaseEntry [DataMember] public string Query { get; protected set; } [DataMember] public long Filesize { get; protected set; } [DataMember] public bool IsDelta { get; protected set; } + [DataMember] public float? StagingPercentage { get; protected set; } - protected ReleaseEntry(string sha1, string filename, long filesize, bool isDelta, string baseUrl = null, string query = null) + protected ReleaseEntry(string sha1, string filename, long filesize, bool isDelta, string baseUrl = null, string query = null, float? stagingPercentage = null) { Contract.Requires(sha1 != null && sha1.Length == 40); Contract.Requires(filename != null); Contract.Requires(filename.Contains(Path.DirectorySeparatorChar) == false); Contract.Requires(filesize > 0); - SHA1 = sha1; BaseUrl = baseUrl; Filename = filename; Query = query; Filesize = filesize; IsDelta = isDelta; + SHA1 = sha1; BaseUrl = baseUrl; Filename = filename; Query = query; Filesize = filesize; IsDelta = isDelta; StagingPercentage = stagingPercentage; } [IgnoreDataMember] public string EntryAsString { - get { return String.Format("{0} {1}{2} {3}", SHA1, BaseUrl, Filename, Filesize); } + get { + if (StagingPercentage != null) { + return String.Format("{0} {1}{2} {3} # {4}", SHA1, BaseUrl, Filename, Filesize, stagingPercentageAsString(StagingPercentage.Value)); + } else { + return String.Format("{0} {1}{2} {3}", SHA1, BaseUrl, Filename, Filesize); + } + } } [IgnoreDataMember] @@ -79,17 +87,24 @@ public Uri GetIconUrl(string packageDirectory) } static readonly Regex entryRegex = new Regex(@"^([0-9a-fA-F]{40})\s+(\S+)\s+(\d+)[\r]*$"); - static readonly Regex commentRegex = new Regex(@"#.*$"); + static readonly Regex commentRegex = new Regex(@"\s*#.*$"); + static readonly Regex stagingRegex = new Regex(@"#\s+(\d{1,3})%$"); public static ReleaseEntry ParseReleaseEntry(string entry) { Contract.Requires(entry != null); + float? stagingPercentage = null; + var m = stagingRegex.Match(entry); + if (m != null && m.Success) { + stagingPercentage = Single.Parse(m.Groups[1].Value) / 100.0f; + } + entry = commentRegex.Replace(entry, ""); if (String.IsNullOrWhiteSpace(entry)) { return null; } - var m = entryRegex.Match(entry); + m = entryRegex.Match(entry); if (!m.Success) { throw new Exception("Invalid release entry: " + entry); } @@ -100,7 +115,7 @@ public static ReleaseEntry ParseReleaseEntry(string entry) string filename = m.Groups[2].Value; - // Split the base URL and the filename if an URI is provided, + // Split the base URL and the filename if an URI is provided, // throws if a path is provided string baseUrl = null; string query = null; @@ -122,7 +137,7 @@ public static ReleaseEntry ParseReleaseEntry(string entry) query = uri.Query; } } - + if (filename.IndexOfAny(Path.GetInvalidFileNameChars()) > -1) { throw new Exception("Filename can either be an absolute HTTP[s] URL, *or* a file name"); } @@ -130,7 +145,21 @@ public static ReleaseEntry ParseReleaseEntry(string entry) long size = Int64.Parse(m.Groups[3].Value); bool isDelta = filenameIsDeltaFile(filename); - return new ReleaseEntry(m.Groups[1].Value, filename, size, isDelta, baseUrl, query); + return new ReleaseEntry(m.Groups[1].Value, filename, size, isDelta, baseUrl, query, stagingPercentage); + } + + public bool IsStagingMatch(Guid? userId) + { + // A "Staging match" is when a user falls into the affirmative + // bucket - i.e. if the staging is at 10%, this user is the one out + // of ten case. + if (!StagingPercentage.HasValue) return true; + if (!userId.HasValue) return false; + + uint val = BitConverter.ToUInt32(userId.Value.ToByteArray(), 12); + + double percentage = ((double)val / (double)UInt32.MaxValue); + return percentage < StagingPercentage.Value; } public static IEnumerable ParseReleaseFile(string fileContents) @@ -150,6 +179,24 @@ public static IEnumerable ParseReleaseFile(string fileContents) return ret.Any(x => x == null) ? null : ret; } + public static IEnumerable ParseReleaseFileAndApplyStaging(string fileContents, Guid? userToken) + { + if (String.IsNullOrEmpty(fileContents)) { + return new ReleaseEntry[0]; + } + + fileContents = Utility.RemoveByteOrderMarkerIfPresent(fileContents); + + var ret = fileContents.Split('\n') + .Where(x => !String.IsNullOrWhiteSpace(x)) + .Select(ParseReleaseEntry) + .Where(x => x != null && x.IsStagingMatch(userToken)) + .ToArray(); + + return ret.Any(x => x == null) ? null : ret; + } + + public static void WriteReleaseFile(IEnumerable releaseEntries, Stream stream) { Contract.Requires(releaseEntries != null && releaseEntries.Any()); @@ -225,6 +272,11 @@ public static List BuildReleasesFile(string releasePackagesDir) return entries; } + static string stagingPercentageAsString(float percentage) + { + return String.Format("{0:F0}%", percentage * 100.0); + } + static bool filenameIsDeltaFile(string filename) { return filename.EndsWith("-delta.nupkg", StringComparison.InvariantCultureIgnoreCase); diff --git a/src/Squirrel/UpdateManager.CheckForUpdates.cs b/src/Squirrel/UpdateManager.CheckForUpdates.cs index 42c621d00..b4782415a 100644 --- a/src/Squirrel/UpdateManager.CheckForUpdates.cs +++ b/src/Squirrel/UpdateManager.CheckForUpdates.cs @@ -23,13 +23,14 @@ public CheckForUpdateImpl(string rootAppDirectory) public async Task CheckForUpdate( string localReleaseFile, string updateUrlOrPath, - bool ignoreDeltaUpdates = false, + bool ignoreDeltaUpdates = false, Action progress = null, IFileDownloader urlDownloader = null) { progress = progress ?? (_ => { }); var localReleases = Enumerable.Empty(); + var stagingId = getOrCreateStagedUserId(); bool shouldInitialize = false; try { @@ -44,11 +45,11 @@ public async Task CheckForUpdate( string releaseFile; - var latestLocalRelease = localReleases.Count() > 0 ? - localReleases.MaxBy(x => x.Version).First() : + var latestLocalRelease = localReleases.Count() > 0 ? + localReleases.MaxBy(x => x.Version).First() : default(ReleaseEntry); - // Fetch the remote RELEASES file, whether it's a local dir or an + // Fetch the remote RELEASES file, whether it's a local dir or an // HTTP URL if (Utility.IsHttpUrl(updateUrlOrPath)) { if (updateUrlOrPath.EndsWith("/")) { @@ -88,7 +89,7 @@ public async Task CheckForUpdate( if (!Directory.Exists(updateUrlOrPath)) { var message = String.Format( - "The directory {0} does not exist, something is probably broken with your application", + "The directory {0} does not exist, something is probably broken with your application", updateUrlOrPath); throw new Exception(message); @@ -97,7 +98,7 @@ public async Task CheckForUpdate( var fi = new FileInfo(Path.Combine(updateUrlOrPath, "RELEASES")); if (!fi.Exists) { var message = String.Format( - "The file {0} does not exist, something is probably broken with your application", + "The file {0} does not exist, something is probably broken with your application", fi.FullName); this.Log().Warn(message); @@ -117,7 +118,7 @@ public async Task CheckForUpdate( } var ret = default(UpdateInfo); - var remoteReleases = ReleaseEntry.ParseReleaseFile(releaseFile); + var remoteReleases = ReleaseEntry.ParseReleaseFileAndApplyStaging(releaseFile, stagingId); progress(66); if (!remoteReleases.Any()) { @@ -125,7 +126,7 @@ public async Task CheckForUpdate( } ret = determineUpdateInfo(localReleases, remoteReleases, ignoreDeltaUpdates); - + progress(100); return ret; } @@ -177,6 +178,37 @@ UpdateInfo determineUpdateInfo(IEnumerable localReleases, IEnumera return UpdateInfo.Create(currentRelease, remoteReleases, packageDirectory); } + + internal Guid? getOrCreateStagedUserId() + { + var stagedUserIdFile = Path.Combine(rootAppDirectory, "packages", ".betaId"); + var ret = default(Guid); + + try { + if (!Guid.TryParse(File.ReadAllText(stagedUserIdFile, Encoding.UTF8), out ret)) { + throw new Exception("File was read but contents were invalid"); + } + + this.Log().Info("Using existing staging user ID: {0}", ret.ToString()); + return ret; + } catch (Exception ex) { + this.Log().DebugException("Couldn't read staging user ID, creating a blank one", ex); + } + + var prng = new Random(); + var buf = new byte[4096]; + prng.NextBytes(buf); + + ret = Utility.CreateGuidFromHash(buf); + try { + File.WriteAllText(stagedUserIdFile, ret.ToString(), Encoding.UTF8); + this.Log().Info("Generated new staging user ID: {0}", ret.ToString()); + return ret; + } catch (Exception ex) { + this.Log().WarnException("Couldn't write out staging user ID, this user probably shouldn't get beta anything", ex); + return null; + } + } } } } diff --git a/src/Squirrel/Utility.cs b/src/Squirrel/Utility.cs index 74df2539f..54b0c0fa5 100644 --- a/src/Squirrel/Utility.cs +++ b/src/Squirrel/Utility.cs @@ -589,14 +589,18 @@ public static Guid CreateGuidFromHash(string text) { return CreateGuidFromHash(text, Utility.IsoOidNamespace); } - + public static Guid CreateGuidFromHash(byte[] data) + { + return CreateGuidFromHash(data, Utility.IsoOidNamespace); + } public static Guid CreateGuidFromHash(string text, Guid namespaceId) { - // convert the name to a sequence of octets (as defined by the standard - // or conventions of its namespace) (step 3) - byte[] nameBytes = Encoding.UTF8.GetBytes(text); + return CreateGuidFromHash(Encoding.UTF8.GetBytes(text), namespaceId); + } + public static Guid CreateGuidFromHash(byte[] nameBytes, Guid namespaceId) + { // convert the namespace UUID to network order (step 3) byte[] namespaceBytes = namespaceId.ToByteArray(); SwapByteOrder(namespaceBytes); diff --git a/test/ReleaseEntryTests.cs b/test/ReleaseEntryTests.cs index 3e0c3817c..3037aa715 100644 --- a/test/ReleaseEntryTests.cs +++ b/test/ReleaseEntryTests.cs @@ -88,6 +88,26 @@ public void ParseVersionTest(string releaseEntry, int major, int minor, int patc Assert.Equal(isDelta, fixture.IsDelta); } + [Theory] + [InlineData("0000000000000000000000000000000000000000 MyCoolApp-1.2.nupkg 123 # 10%", 1, 2, 0, 0, "", false, 0.1f)] + [InlineData("0000000000000000000000000000000000000000 MyCoolApp-1.2-full.nupkg 123 # 90%", 1, 2, 0, 0, "", false, 0.9f)] + [InlineData("0000000000000000000000000000000000000000 MyCoolApp-1.2-delta.nupkg 123", 1, 2, 0, 0, "", true, null)] + [InlineData("0000000000000000000000000000000000000000 MyCoolApp-1.2-delta.nupkg 123 # 5%", 1, 2, 0, 0, "", true, 0.05f)] + public void ParseStagingPercentageTest(string releaseEntry, int major, int minor, int patch, int revision, string prerelease, bool isDelta, float? stagingPercentage) + { + var fixture = ReleaseEntry.ParseReleaseEntry(releaseEntry); + + Assert.Equal(new SemanticVersion(new Version(major, minor, patch, revision), prerelease), fixture.Version); + Assert.Equal(isDelta, fixture.IsDelta); + + if (stagingPercentage.HasValue) { + Assert.True(Math.Abs(fixture.StagingPercentage.Value - stagingPercentage.Value) < 0.001); + } else { + Assert.Null(fixture.StagingPercentage); + } + } + + [Fact] public void CanParseGeneratedReleaseEntryAsString() { @@ -218,6 +238,88 @@ public void WhenReleasesAreOutOfOrderSortByVersion() Assert.Equal(false, releases[4].IsDelta); } + [Fact] + public void StagingUsersGetBetaSoftware() + { + // NB: We're kind of using a hack here, in that we know that the + // last 4 bytes are used as the percentage, and the percentage + // effectively measures, "How close are you to zero". Guid.Empty + // is v close to zero, because it is zero. + var path = Path.GetTempFileName(); + var ourGuid = Guid.Empty; + + var releaseEntries = new[] { + ReleaseEntry.ParseReleaseEntry(MockReleaseEntry("Espera-1.2.0-full.nupkg", 0.1f)), + ReleaseEntry.ParseReleaseEntry(MockReleaseEntry("Espera-1.1.0-full.nupkg")), + ReleaseEntry.ParseReleaseEntry(MockReleaseEntry("Espera-1.0.0-full.nupkg")) + }; + + ReleaseEntry.WriteReleaseFile(releaseEntries, path); + + var releases = ReleaseEntry.ParseReleaseFileAndApplyStaging(File.ReadAllText(path), ourGuid).ToArray(); + Assert.Equal(3, releases.Length); + } + + [Fact] + public void BorkedUsersGetProductionSoftware() + { + var path = Path.GetTempFileName(); + var ourGuid = default(Guid?); + + var releaseEntries = new[] { + ReleaseEntry.ParseReleaseEntry(MockReleaseEntry("Espera-1.2.0-full.nupkg", 0.1f)), + ReleaseEntry.ParseReleaseEntry(MockReleaseEntry("Espera-1.1.0-full.nupkg")), + ReleaseEntry.ParseReleaseEntry(MockReleaseEntry("Espera-1.0.0-full.nupkg")) + }; + + ReleaseEntry.WriteReleaseFile(releaseEntries, path); + + var releases = ReleaseEntry.ParseReleaseFileAndApplyStaging(File.ReadAllText(path), ourGuid).ToArray(); + Assert.Equal(2, releases.Length); + } + + [Theory] + [InlineData("{22b29e6f-bd2e-43d2-85ca-ffffffffffff}")] + [InlineData("{22b29e6f-bd2e-43d2-85ca-888888888888}")] + [InlineData("{22b29e6f-bd2e-43d2-85ca-444444444444}")] + public void UnluckyUsersGetProductionSoftware(string inputGuid) + { + var path = Path.GetTempFileName(); + var ourGuid = Guid.ParseExact(inputGuid, "B"); + + var releaseEntries = new[] { + ReleaseEntry.ParseReleaseEntry(MockReleaseEntry("Espera-1.2.0-full.nupkg", 0.1f)), + ReleaseEntry.ParseReleaseEntry(MockReleaseEntry("Espera-1.1.0-full.nupkg")), + ReleaseEntry.ParseReleaseEntry(MockReleaseEntry("Espera-1.0.0-full.nupkg")) + }; + + ReleaseEntry.WriteReleaseFile(releaseEntries, path); + + var releases = ReleaseEntry.ParseReleaseFileAndApplyStaging(File.ReadAllText(path), ourGuid).ToArray(); + Assert.Equal(2, releases.Length); + } + + [Theory] + [InlineData("{22b29e6f-bd2e-43d2-85ca-333333333333}")] + [InlineData("{22b29e6f-bd2e-43d2-85ca-111111111111}")] + [InlineData("{22b29e6f-bd2e-43d2-85ca-000000000000}")] + public void LuckyUsersGetBetaSoftware(string inputGuid) + { + var path = Path.GetTempFileName(); + var ourGuid = Guid.ParseExact(inputGuid, "B"); + + var releaseEntries = new[] { + ReleaseEntry.ParseReleaseEntry(MockReleaseEntry("Espera-1.2.0-full.nupkg", 0.25f)), + ReleaseEntry.ParseReleaseEntry(MockReleaseEntry("Espera-1.1.0-full.nupkg")), + ReleaseEntry.ParseReleaseEntry(MockReleaseEntry("Espera-1.0.0-full.nupkg")) + }; + + ReleaseEntry.WriteReleaseFile(releaseEntries, path); + + var releases = ReleaseEntry.ParseReleaseFileAndApplyStaging(File.ReadAllText(path), ourGuid).ToArray(); + Assert.Equal(3, releases.Length); + } + [Fact] public void ParseReleaseFileShouldReturnNothingForBlankFiles() { @@ -225,9 +327,14 @@ public void ParseReleaseFileShouldReturnNothingForBlankFiles() Assert.True(ReleaseEntry.ParseReleaseFile(null).Count() == 0); } - static string MockReleaseEntry(string name) + static string MockReleaseEntry(string name, float? percentage = null) { - return string.Format("94689fede03fed7ab59c24337673a27837f0c3ec {0} 1004502", name); + if (percentage.HasValue) { + var ret = String.Format("94689fede03fed7ab59c24337673a27837f0c3ec {0} 1004502 # {1:F0}%", name, percentage * 100.0f); + return ret; + } else { + return String.Format("94689fede03fed7ab59c24337673a27837f0c3ec {0} 1004502", name); + } } } }