Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions cli/cli/App.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1454,6 +1454,15 @@ protected virtual Parser GetProgram()
Console.Error.WriteLine("\nLogs at\n " + Path.GetFullPath(logFile));
}
}

// console-only: a CliException's message can end up above a long stack trace (e.g. when wrapped in an
// AggregateException during manifest init), so repeat it last, in red, where it's seen without scrolling.
var cliError = ex as CliException ??
(ex as AggregateException)?.Flatten().InnerExceptions.OfType<CliException>().FirstOrDefault();
if (cliError != null && !appContext.UsePipeOutput && !appContext.ShowRawOutput)
{
AnsiConsole.MarkupLine($"\n[red]{Markup.Escape(cliError.Message)}[/]");
}
});


Expand Down
7 changes: 7 additions & 0 deletions cli/cli/Services/BeamoLocalSystem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,13 @@ public async Task InitManifest(bool useManifestCache=true, bool fetchServerManif
{
Log.Warning(conflict);
}

// A storage name that makes its MongoDB database name too long breaks both local runs and deploys, even when
// it was renamed by hand rather than through the CLI. In-memory only: zone storages use the local zid, and
// are skipped (left to the deploy check and the runtime guard) when none is set.
var localZid = _configService.GetConfigString(ConfigService.CFG_JSON_FIELD_ZID);
ProjectService.ThrowIfStorageNamesTooLong(
ProjectService.FindTooLongStorageNames(BeamoManifest.ServiceDefinitions, _ctx.Cid, _ctx.Pid, localZid));
}

private static Uri GetLocalDockerEndpoint(ConfigService config)
Expand Down
15 changes: 15 additions & 0 deletions cli/cli/Services/CliRequester.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,21 @@ private string GetEffectiveScope()
return string.IsNullOrEmpty(pid) ? cid : $"{cid}.{pid}";
}

/// <summary>
/// The cid and scope id the next request targets: parsed from <see cref="BeamScopeOverride"/> when set (a zone
/// deploy's <c>{cid}.{zid}</c> gives the zid), otherwise the current cid and pid.
/// </summary>
public (string cid, string scopeId) GetEffectiveScopeIds()
{
if (string.IsNullOrEmpty(BeamScopeOverride))
{
return (AccessToken?.Cid ?? Cid, AccessToken?.Pid ?? Pid);
}

var scopeParts = BeamScopeOverride.Split('.', 2);
return (scopeParts[0], scopeParts.Length > 1 ? scopeParts[1] : AccessToken?.Pid ?? Pid);
}

private sealed class ScopeResetter : IDisposable
{
private readonly CliRequester _requester;
Expand Down
13 changes: 13 additions & 0 deletions cli/cli/Services/DeploymentService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1588,6 +1588,19 @@ public static bool IsJsonAManifest(IDictionary<string, object> data)
}
else
{
// A storage whose MongoDB database name doesn't fit the target would only fail once the manifest is
// posted or the service first touches it; stop before building anything. The target is the requester's
// scope: `{cid}.{zid}` for a zone deploy (resolved by ApplyDeployScopeAsync), else the realm's cid/pid.
{
var (targetCid, targetScopeId) = provider.GetService<CliRequester>().GetEffectiveScopeIds();

// only this scope's storages are deployed, so the one scope id serves as both pid and zid.
var scopedDefinitions = beamo.BeamoManifest.ServiceDefinitions
.Where(d => d.IsZoneScoped == (args.Scope == DeployScope.Zone));
ProjectService.ThrowIfStorageNamesTooLong(ProjectService.FindTooLongStorageNames(
scopedDefinitions, targetCid, targetScopeId, targetScopeId, includeOnlyBeamoIds));
}

// Docker is only needed to build service images. A filtered plan whose set contains no
// local microservice (e.g. a portal-extension-only bundle) can run without the daemon.
var needsServiceBuild = beamo.BeamoManifest.ServiceDefinitions.Any(d =>
Expand Down
125 changes: 125 additions & 0 deletions cli/cli/Services/ProjectService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,8 @@ public async Task<NewServiceInfo> CreateNewPortalExtensionLib(NewPortalExtension

public async Task<NewServiceInfo> CreateNewStorage(NewStorageCommandArgs args)
{
ValidateNewStorageName(args.AppContext.Cid, await ResolveNewStorageScope(args), args.ProjectName, args.IsZone);

string usedVersion = VersionService.GetNugetPackagesForExecutingCliVersion().ToString();
var microserviceInfo = new NewServiceInfo();
// check that we have the templates available
Expand Down Expand Up @@ -421,6 +423,129 @@ private static void SetZoneScopeOnCsproj(string csprojPath)
File.WriteAllLines(csprojPath, lines.Skip(1).ToArray());
}

// ── Storage name length ─────────────────────────────────────────────────────────────────────
// A storage maps to the MongoDB database {cid}{scope}_{storageName} (see StorageDatabaseName), where the scope is
// the pid for a realm storage and the zid for a zone storage, so how long a storage name may be depends on the
// target cid and scope. MongoDB rejects the name locally and remotely alike, so these checks guard creation,
// manifest init (local runs) and deploys.

// The id the new storage's database name will be scoped by: the pid for a realm storage, the zid for a zone
// storage. Null when it can't be resolved (e.g. not logged in), in which case the name check only warns.
private static async Task<string> ResolveNewStorageScope(NewStorageCommandArgs args)
{
if (!args.IsZone)
{
return args.AppContext.Pid;
}

var localZid = args.ConfigService.GetConfigString(ConfigService.CFG_JSON_FIELD_ZID);
try
{
return await ZoneResolver.ResolveZid(args.DependencyProvider, args.AppContext.Cid, args.AppContext.Pid,
localZid);
}
catch (Exception ex)
{
// a selected realm's zone binding is authoritative, so don't guess with the local zid; an unresolved
// zone leaves the name check warn-only.
Log.Debug($"Could not resolve the zone for the storage name check: {ex.Message}");
return null;
}
}

/// <summary>
/// Validates the name of a storage about to be created. With a known cid and scope (pid, or zid for a zone
/// storage) a name that doesn't fit throws; without them, the name is only compared against the worst-case
/// budget and a warning is logged, since the target isn't known yet.
/// </summary>
public static void ValidateNewStorageName(string cid, string scope, string storageName, bool isZone)
{
if (IsNumericCid(cid) && !string.IsNullOrEmpty(scope))
{
if (StorageDatabaseName.IsTooLong(cid, scope, storageName))
{
ThrowIfStorageNamesTooLong(new List<string> { StorageDatabaseName.DescribeTooLong(cid, scope, storageName) });
}

return;
}

// Worst-case ids are snowflake longs of up to 19 digits: cid (19), pid "DE_" + 19, zid "ZONE_" + 19.
const int worstCaseCidLength = 19;
var worstCaseScopeLength = isZone ? 24 : 22;
var worstCase = StorageDatabaseName.MaxLength - worstCaseCidLength - worstCaseScopeLength - 1;
if (storageName.Length > worstCase)
{
Log.Warning($"Storage [{storageName}] is {storageName.Length} characters long. Its MongoDB database name " +
$"is {{cid}}{{{(isZone ? "zid" : "pid")}}}_{{storageName}} (at most {StorageDatabaseName.MaxLength} " +
$"characters), and the {(isZone ? "zone" : "realm")} isn't known yet, so it can't be checked. " +
$"Names up to {worstCase} characters always fit; a longer name may fail once you run or deploy it.");
}
}

/// <summary>
/// Returns one message per local storage whose database name would be too long. Realm storages are checked
/// against <paramref name="pid"/> and zone storages against <paramref name="zid"/>; a storage whose scope id is
/// unknown (null/empty) is skipped, as is everything when <paramref name="cid"/> isn't a numeric customer id.
/// </summary>
/// <param name="definitions">The manifest's definitions; only local storages are checked.</param>
/// <param name="includeOnlyBeamoIds">When set, only these storages are checked.</param>
public static List<string> FindTooLongStorageNames(
IEnumerable<BeamoServiceDefinition> definitions,
string cid,
string pid,
string zid,
ICollection<string> includeOnlyBeamoIds = null)
{
var errors = new List<string>();
if (!IsNumericCid(cid))
{
return errors;
}

foreach (var definition in definitions)
{
if (!definition.IsLocal || definition.Protocol != BeamoProtocolType.EmbeddedMongoDb)
continue;
if (includeOnlyBeamoIds != null && !includeOnlyBeamoIds.Contains(definition.BeamoId))
continue;

var scope = definition.IsZoneScoped ? zid : pid;
if (string.IsNullOrEmpty(scope))
{
Log.Trace($"Skipping storage name length check for storage=[{definition.BeamoId}]; no " +
$"{(definition.IsZoneScoped ? "zid" : "pid")} is known.");
continue;
}

if (StorageDatabaseName.IsTooLong(cid, scope, definition.BeamoId))
{
errors.Add(StorageDatabaseName.DescribeTooLong(cid, scope, definition.BeamoId));
}
}

return errors;
}

/// <summary>
/// Throws a single <see cref="CliException"/> listing every storage in <paramref name="errors"/>, if any.
/// </summary>
public static void ThrowIfStorageNamesTooLong(List<string> errors)
{
if (errors.Count == 0)
{
return;
}

var lines = string.Join(Environment.NewLine, errors.Select(e => " - " + e));
throw new CliException(
$"Some storage names are too long for MongoDB:{Environment.NewLine}{lines}{Environment.NewLine}" +
"Rename these storages to fit: change the <BeamId> property (or the .csproj name when <BeamId> isn't set) " +
"and the matching [StorageObject] attribute name.");
}

private static bool IsNumericCid(string cid) => !string.IsNullOrEmpty(cid) && cid.All(char.IsDigit);

public async Task<NewServiceInfo> CreateNewMicroservice(NewMicroserviceArgs args)
{
// check that we have the templates available
Expand Down
11 changes: 3 additions & 8 deletions cli/cli/Services/ServiceUploadUtil.cs
Original file line number Diff line number Diff line change
Expand Up @@ -191,14 +191,9 @@ void TrackProgress(float ratio)
// `{cid}.{zid}`) and send the zone id in the SAME x-ks-projectid header — no new headers. The repo
// itself is already the scoped one returned by /api/beamo/registry-uri for this scope.
var registryRequester = provider.GetService<IBeamableRequester>();
var registryClientId = ctx.Cid;
var registryProjectId = ctx.Pid;
if (registryRequester is CliRequester cliRequester && !string.IsNullOrEmpty(cliRequester.BeamScopeOverride))
{
var scopeParts = cliRequester.BeamScopeOverride.Split('.', 2);
registryClientId = scopeParts[0];
if (scopeParts.Length > 1) registryProjectId = scopeParts[1];
}
var (registryClientId, registryProjectId) = registryRequester is CliRequester cliRequester
? cliRequester.GetEffectiveScopeIds()
: (ctx.Cid, ctx.Pid);

Log.Information($"[{beamoId}] registry auth headers: x-ks-clientid=[{registryClientId}] x-ks-projectid=[{registryProjectId}]");
client.DefaultRequestHeaders.Add("x-ks-clientid", registryClientId);
Expand Down
169 changes: 169 additions & 0 deletions cli/tests/StorageNameLengthTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
using System.Collections.Generic;
using Beamable.Server;
using cli;
using cli.Services;
using NUnit.Framework;

namespace tests;

/// <summary>
/// A storage maps to the MongoDB database <c>{cid}{scope}_{storageName}</c> (scope = pid for realm storages, zid for
/// zone storages), and MongoDB rejects names longer than 63 characters, locally and remotely. The CLI must catch a
/// name that doesn't fit before running or deploying it.
/// </summary>
[TestFixture]
public class StorageNameLengthTests
{
const string Cid = "1706624984549280"; // 16
const string Pid = "DE_1706624984549283"; // 19 -> realm budget 63 - 16 - 19 - 1 = 27
const string Zid = "ZONE_100000000000000000"; // 23 -> zone budget 63 - 16 - 23 - 1 = 23

static BeamoServiceDefinition Storage(string beamoId, bool zone = false, bool local = true) => new()
{
BeamoId = beamoId,
Protocol = BeamoProtocolType.EmbeddedMongoDb,
ServiceScope = zone ? "zone" : null,
ProjectPath = local ? $"services/{beamoId}/{beamoId}.csproj" : null,
};

static BeamoServiceDefinition Service(string beamoId) => new()
{
BeamoId = beamoId,
Protocol = BeamoProtocolType.HttpMicroservice,
ProjectPath = $"services/{beamoId}/{beamoId}.csproj",
};

// ---- StorageDatabaseName ----

[Test]
public void Compose_ConcatenatesCidScopeAndName()
{
Assert.That(StorageDatabaseName.Compose(Cid, Pid, "Inv"), Is.EqualTo($"{Cid}{Pid}_Inv"));
}

[Test]
public void MaxStorageNameLength_IsWhatIsLeftOfTheDatabaseName()
{
Assert.That(StorageDatabaseName.MaxStorageNameLength(Cid, Pid), Is.EqualTo(27));
Assert.That(StorageDatabaseName.MaxStorageNameLength(Cid, Zid), Is.EqualTo(23));
}

[Test]
public void IsTooLong_At63Characters_Fits()
{
var name = new string('a', 27);
Assert.That(StorageDatabaseName.Compose(Cid, Pid, name).Length, Is.EqualTo(63));
Assert.That(StorageDatabaseName.IsTooLong(Cid, Pid, name), Is.False);
}

[Test]
public void IsTooLong_At64Characters_DoesNotFit()
{
Assert.That(StorageDatabaseName.IsTooLong(Cid, Pid, new string('a', 28)), Is.True);
}

[Test]
public void DescribeTooLong_NamesTheStorageDatabaseAndBudget()
{
var name = new string('a', 24);
var message = StorageDatabaseName.DescribeTooLong(Cid, Zid, name);

Assert.That(message, Does.Contain($"[{name}]"));
Assert.That(message, Does.Contain($"zone [{Zid}]"));
Assert.That(message, Does.Contain($"[{Cid}{Zid}_{name}]"));
Assert.That(message, Does.Contain("64 characters"));
Assert.That(message, Does.Contain("at most 23 characters"));
}

// ---- FindTooLongStorageNames ----

[Test]
public void FindTooLongStorageNames_ChecksRealmStoragesAgainstPidAndZoneStoragesAgainstZid()
{
// 25 characters fits the realm (27) but not the zone (23).
var name = new string('a', 25);
var errors = ProjectService.FindTooLongStorageNames(
new[] { Storage(name), Storage("Z" + name, zone: true) }, Cid, Pid, Zid);

Assert.That(errors, Has.Count.EqualTo(1));
Assert.That(errors[0], Does.Contain($"[Z{name}]"));
}

[Test]
public void FindTooLongStorageNames_SkipsZoneStoragesWhenNoZidIsKnown()
{
var errors = ProjectService.FindTooLongStorageNames(
new[] { Storage(new string('a', 40), zone: true) }, Cid, Pid, zid: null);

Assert.That(errors, Is.Empty);
}

[Test]
public void FindTooLongStorageNames_SkipsEverythingWithoutANumericCid()
{
var definitions = new[] { Storage(new string('a', 40)) };

Assert.That(ProjectService.FindTooLongStorageNames(definitions, null, Pid, Zid), Is.Empty);
Assert.That(ProjectService.FindTooLongStorageNames(definitions, "my-alias", Pid, Zid), Is.Empty);
}

[Test]
public void FindTooLongStorageNames_IgnoresServicesAndRemoteOnlyStorages()
{
var longName = new string('a', 40);
var errors = ProjectService.FindTooLongStorageNames(
new[] { Service(longName), Storage("R" + longName, local: false) }, Cid, Pid, Zid);

Assert.That(errors, Is.Empty);
}

[Test]
public void FindTooLongStorageNames_OnlyChecksIncludedStorages()
{
var tooLong = new string('a', 30);
var definitions = new[] { Storage(tooLong), Storage("Other") };

Assert.That(ProjectService.FindTooLongStorageNames(definitions, Cid, Pid, Zid, new HashSet<string> { "Other" }),
Is.Empty);
}

[Test]
public void ThrowIfStorageNamesTooLong_ListsEveryOffenderInOneError()
{
var errors = ProjectService.FindTooLongStorageNames(
new[] { Storage(new string('a', 30)), Storage(new string('b', 30)) }, Cid, Pid, Zid);

var ex = Assert.Throws<CliException>(() => ProjectService.ThrowIfStorageNamesTooLong(errors));
Assert.That(ex!.Message, Does.Contain(new string('a', 30)));
Assert.That(ex.Message, Does.Contain(new string('b', 30)));
}

[Test]
public void ThrowIfStorageNamesTooLong_WithNoErrors_DoesNothing()
{
Assert.DoesNotThrow(() => ProjectService.ThrowIfStorageNamesTooLong(new List<string>()));
}

// ---- ValidateNewStorageName ----

[Test]
public void ValidateNewStorageName_WithKnownTarget_ThrowsWhenTooLong()
{
Assert.Throws<CliException>(() =>
ProjectService.ValidateNewStorageName(Cid, Zid, new string('a', 24), isZone: true));
}

[Test]
public void ValidateNewStorageName_WithKnownTarget_PassesAtTheLimit()
{
Assert.DoesNotThrow(() =>
ProjectService.ValidateNewStorageName(Cid, Zid, new string('a', 23), isZone: true));
}

[Test]
public void ValidateNewStorageName_WithoutTarget_OnlyWarns()
{
Assert.DoesNotThrow(() =>
ProjectService.ValidateNewStorageName(null, null, new string('a', 40), isZone: true));
}
}
Loading
Loading