Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Staged rollouts #666

Merged
merged 16 commits into from
Apr 20, 2016
1 change: 1 addition & 0 deletions docs/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions docs/using/staged-rollouts.md
Original file line number Diff line number Diff line change
@@ -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.
68 changes: 60 additions & 8 deletions src/Squirrel/ReleaseEntry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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]
Expand Down Expand Up @@ -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);
}
Expand All @@ -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;
Expand All @@ -122,15 +137,29 @@ 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");
}

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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some comments on how we define a staging match would be 👌.

{
// 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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems to me, that in the absence of tracking actual downloads/installs, it is just assuming an even distribution of guid-to-integer-conversions? So, a hopeful approximation?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We create the GUID in a Weird Way by taking random bytes that is more likely to ensure a random distribution, it's not just Guid.Create(). That being said, I am not a statistician, but it doesn't really matter so much

return percentage < StagingPercentage.Value;
}

public static IEnumerable<ReleaseEntry> ParseReleaseFile(string fileContents)
Expand All @@ -150,6 +179,24 @@ public static IEnumerable<ReleaseEntry> ParseReleaseFile(string fileContents)
return ret.Any(x => x == null) ? null : ret;
}

public static IEnumerable<ReleaseEntry> 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<ReleaseEntry> releaseEntries, Stream stream)
{
Contract.Requires(releaseEntries != null && releaseEntries.Any());
Expand Down Expand Up @@ -225,6 +272,11 @@ public static List<ReleaseEntry> 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);
Expand Down
48 changes: 40 additions & 8 deletions src/Squirrel/UpdateManager.CheckForUpdates.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,14 @@ public CheckForUpdateImpl(string rootAppDirectory)
public async Task<UpdateInfo> CheckForUpdate(
string localReleaseFile,
string updateUrlOrPath,
bool ignoreDeltaUpdates = false,
bool ignoreDeltaUpdates = false,
Action<int> progress = null,
IFileDownloader urlDownloader = null)
{
progress = progress ?? (_ => { });

var localReleases = Enumerable.Empty<ReleaseEntry>();
var stagingId = getOrCreateStagedUserId();

bool shouldInitialize = false;
try {
Expand All @@ -44,11 +45,11 @@ public async Task<UpdateInfo> 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("/")) {
Expand Down Expand Up @@ -88,7 +89,7 @@ public async Task<UpdateInfo> 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);
Expand All @@ -97,7 +98,7 @@ public async Task<UpdateInfo> 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);
Expand All @@ -117,15 +118,15 @@ public async Task<UpdateInfo> CheckForUpdate(
}

var ret = default(UpdateInfo);
var remoteReleases = ReleaseEntry.ParseReleaseFile(releaseFile);
var remoteReleases = ReleaseEntry.ParseReleaseFileAndApplyStaging(releaseFile, stagingId);
progress(66);

if (!remoteReleases.Any()) {
throw new Exception("Remote release File is empty or corrupted");
}

ret = determineUpdateInfo(localReleases, remoteReleases, ignoreDeltaUpdates);

progress(100);
return ret;
}
Expand Down Expand Up @@ -177,6 +178,37 @@ UpdateInfo determineUpdateInfo(IEnumerable<ReleaseEntry> localReleases, IEnumera

return UpdateInfo.Create(currentRelease, remoteReleases, packageDirectory);
}

internal Guid? getOrCreateStagedUserId()
{
var stagedUserIdFile = Path.Combine(rootAppDirectory, "packages", ".betaId");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I couldn't tell from the docs how the rollout determination is happening. It would be nice if the docs gave some background on this .betaId file.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, so users / developers shouldn't have to know this implementation detail, but I don't see why we can't add it to the docs. It's effectively just a random number, serialized in GUID format

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the most part, you are probably right, but for my 2 cents, I wanted to know at least the high-level view of how it worked before I jumped in to trying it out.

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;
}
}
}
}
}
12 changes: 8 additions & 4 deletions src/Squirrel/Utility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading