Public Access
ONE PRESS WAS DOING TWO JOBS. A group card was the only place a group could be selected — it is what EDIT and DELETE aim at — and it was also the control that narrowed the grid to that group. So there was no way to name a group in order to rename it without every host outside it leaving the screen at the same moment, and no way back except a SHOW ALL button that appeared beside the heading. Two gestures instead. A click selects and does nothing else; a double-click opens, which is what the host cards below already do to get a shell and what the transfers screen's directories already do to go inside one. The grid now has one vocabulary rather than one per list. The gesture is wired in the code-behind beside the host one, and guarded the same way: a double-click on the space around the cards must not open whichever group happened to be selected. THE CARDS ARE ONE LEVEL NOW, not every group in the keychain. Groups nest, and drawing all of them flat was the only honest thing to do while a card was a filter — a filter nobody can see is a filter nobody can turn off. Once opening one became navigation the cards became its contents, and VisibleGroups is that level beside Groups the way VisibleHosts sits beside Hosts. Groups itself is untouched: it is what every lookup reads and what the phone's headings are built from, and the phone binds none of the new members. Which is what the trail is for. A level with no name and no way out is a grid that has quietly hidden things, so a breadcrumb sits above the cards — drawn exactly as the transfers screen draws a directory path, same flat crumbs and same separator, because it is the same control answering the same question and a window with two breadcrumbs that look different has two ideas of what a path is. The first crumb is always there and always goes back to every host, which is what SHOW ALL was; that button went with it, because a control that only says "stop" beside a trail that says where you are is a second control for one job, and this one also gets you back one level rather than all the way. EDIT AND DELETE AIM AT GroupTarget: the selected card, or the open group when no card is selected. Without the fallback a group with nothing inside it could be opened and then never edited, because opening a group is exactly what takes its own card off the screen. It is also what a file manager does — act on the selection, and on the current folder when there is none — and the pair is now hidden with nothing to act on rather than shown doing nothing. A DANGLING PARENT AND A CYCLE BOTH HAD TO END UP SOMEWHERE REACHABLE. Neither is prevented: a parent id may point at a group deleted on another machine, and two clients can each re-parent A under B and B under A while offline, which no merge can see because the pointer is inside the payload. EffectiveParents promotes both to the outermost level, which is the same degradation the resolver's visited set produces for inheritance. The repair for either is the group's own editor and the editor is opened from its card, so a group drawn nowhere would be a broken state with the fix locked inside it. Three tests in HostGridTests: the split rule through the properties the cards bind, the pointer gesture itself in two windows so that "one press still only selects" is asserted separately from the pair, and a nested group opened, emptied of cards and walked back out of one level. The last presses the trail as it is actually rendered rather than calling the command, because a crumb reaches the vault through a $parent binding — a string that compiles whether or not it resolves, and would otherwise leave a row of buttons that do nothing. 85 layout tests and 234 shell-flow tests pass. Manual-checks 3.2, 7.6 and 7.7 follow the new gestures, and 3.2a and 3.4a are new: nesting, and the two states above, both of which need two machines and neither of which headless Avalonia can reach.
463 lines
20 KiB
C#
463 lines
20 KiB
C#
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Headless;
|
|
using Avalonia.Input;
|
|
using Avalonia.Interactivity;
|
|
using Avalonia.Threading;
|
|
using Avalonia.VisualTree;
|
|
using DodoSSH.Client.App.Views;
|
|
using DodoSSH.Client.Session;
|
|
using DodoSSH.Client.Session.Tests;
|
|
using DodoSSH.Client.Shell.ViewModels;
|
|
using DodoSSH.Client.Ssh;
|
|
using DodoSSH.Client.Storage;
|
|
using DodoSSH.Client.Terminal;
|
|
using DodoSSH.Crypto;
|
|
using NSubstitute;
|
|
|
|
namespace DodoSSH.Client.App.Layout.Tests;
|
|
|
|
/// <summary>
|
|
/// How the grid of host cards answers a pointer.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// This was <c>HostSidebarTests</c>, and it moved with the list: the cards are on
|
|
/// <see cref="HostsScreen"/> now, and so is every handler that was wired to them. See
|
|
/// <c>HostsScreen.axaml.cs</c>.
|
|
/// </para>
|
|
/// <para>
|
|
/// Separate from <see cref="ScreenLayoutTests"/>, which measures these controls rather than driving them.
|
|
/// What is here is the one gesture that cannot be expressed as a binding and cannot be checked by
|
|
/// measuring: a right click has to move the selection <em>before</em> the menu opens, because all three of
|
|
/// that menu's commands read the vault's host selection. A menu that quietly acted on whichever host
|
|
/// happened to be selected would delete the wrong machine, which is the version of this mistake worth a
|
|
/// suite.
|
|
/// </para>
|
|
/// <para>
|
|
/// A real <see cref="VaultViewModel"/> over a real unlocked vault, for the reason the other suites here use
|
|
/// one: compiled bindings resolve against the declared type, and the grid is built out of the vault's own
|
|
/// hosts and groups.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class HostGridTests : IAsyncLifetime
|
|
{
|
|
private const string Passphrase = "a sufficiently long passphrase";
|
|
private const string ServerUrl = "https://dodossh.example";
|
|
|
|
/// <remarks>Far below the shipped profile: nothing here attacks a wrap.</remarks>
|
|
private static readonly Argon2Profile CheapProfile =
|
|
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
|
|
|
|
private readonly FakeAccountServer server = new();
|
|
private readonly StubKeyBinding keyBinding = new();
|
|
private readonly VaultKnownHostStore knownHosts = new();
|
|
|
|
private ClientCacheFactory caches = null!;
|
|
private TerminalWorkspace workspace = null!;
|
|
private VaultSession session = null!;
|
|
private VaultViewModel vault = null!;
|
|
|
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask InitializeAsync()
|
|
{
|
|
caches = ClientCacheFactory.ForMemory($"sidebar-{Guid.CreateVersion7():N}");
|
|
await caches.MigrateAsync(Token);
|
|
|
|
await new AccountProvisioner(server, keyBinding, caches, TimeProvider.System, CheapProfile)
|
|
.EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
|
|
|
|
var outcome = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token);
|
|
outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
|
|
session = outcome.Session!;
|
|
|
|
workspace = new TerminalWorkspace(
|
|
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
|
|
Substitute.For<ISshConnectionFactory>(),
|
|
TimeProvider.System);
|
|
|
|
await knownHosts.OpenAsync(session, Token);
|
|
|
|
vault = new VaultViewModel(session, workspace, knownHosts, static () => null);
|
|
|
|
await SeedAsync();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
knownHosts.Close();
|
|
await workspace.DisposeAsync();
|
|
await vault.DisposeAsync();
|
|
caches.Dispose();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The rule the menu depends on. Without it the three commands would act on whatever was selected
|
|
/// before, which for Delete is a question asked about one machine and answered about another.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ARightClickSelectsTheHostUnderThePointer()
|
|
{
|
|
await OnTheGridAsync((screen, window) =>
|
|
{
|
|
var first = Row(vault, "prod-db");
|
|
var other = Row(vault, "stage-web");
|
|
|
|
vault.SelectedHost = first;
|
|
|
|
RightClick(CardFor(screen, other), window);
|
|
|
|
vault.SelectedHost.ShouldBeSameAs(other);
|
|
|
|
var menu = screen.HostGrid.ContextMenu.ShouldNotBeNull();
|
|
menu.IsOpen.ShouldBeTrue();
|
|
|
|
// The commands are the vault's, which is the other half of putting the menu on the list rather
|
|
// than in the item template: a menu inside the template would have the row for its data context,
|
|
// and every one of these would silently bind to nothing.
|
|
var edit = menu.Items.OfType<MenuItem>().Single(item => item.Header is "Edit…");
|
|
edit.Command.ShouldBeSameAs(vault.EditSelectedHostCommand);
|
|
|
|
edit.Command!.Execute(null);
|
|
|
|
vault.IsEditing.ShouldBeTrue();
|
|
vault.EditorLabel.ShouldBe(other.Label, "the row that was right-clicked, not the one selected before");
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The space around the cards is part of the same <c>ListBox</c>, and a menu offering Connect, Edit and
|
|
/// Delete over it would be three entries acting on whichever machine happened to be selected — which is
|
|
/// the whole mistake this handler exists to prevent, reached by clicking nothing at all.
|
|
/// </para>
|
|
/// <para>
|
|
/// Raised on the list itself rather than clicked at a point known to be empty. What the handler reads is
|
|
/// the event's source, and a source that is the list rather than an item is exactly what the empty space
|
|
/// produces; a coordinate would additionally be asserting where the wrap put the cards.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ARightClickOffAnyCardOpensNothingAndMovesNothing()
|
|
{
|
|
await OnTheGridAsync((screen, _) =>
|
|
{
|
|
var selected = Row(vault, "prod-db");
|
|
vault.SelectedHost = selected;
|
|
|
|
screen.HostGrid.RaiseEvent(new ContextRequestedEventArgs
|
|
{
|
|
RoutedEvent = Control.ContextRequestedEvent,
|
|
Source = screen.HostGrid,
|
|
});
|
|
|
|
vault.SelectedHost.ShouldBeSameAs(selected, "the selection the menu would have acted on");
|
|
screen.HostGrid.ContextMenu.ShouldNotBeNull().IsOpen.ShouldBeFalse();
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// A host held over a group card would be filed there, and one held over another host card would not.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The group headings that used to sit between the cards are gone — see
|
|
/// <c>ScreenLayoutTests.TheHostsGridHoldsCardsAndNoGroupHeadings</c> — and with them went the thing a
|
|
/// dragged host was dropped onto. This holds the replacement in place, and the refusal with it: a card
|
|
/// dropped onto another card used to file it beside that one, which was legible while a heading said
|
|
/// which group the band of cards belonged to and is guesswork now that none does.
|
|
/// </para>
|
|
/// <para>
|
|
/// What is <em>not</em> here is the platform's half — picking the card up, the cursor, the drop itself.
|
|
/// Headless Avalonia has no native window and can synthesise none of it. The write at the end of the
|
|
/// gesture is <c>ShellFlowTests.MovingAHostToAGroup_FilesItAndLeavesItSelected</c>, and what neither
|
|
/// covers is in docs/manual-checks.md 7.6.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheGroupCardsAreWhatAcceptsADroppedHost()
|
|
{
|
|
await OnTheGridAsync((screen, _) =>
|
|
{
|
|
var carried = new DataTransfer();
|
|
carried.Add(DataTransferItem.Create(HostFormat, Row(vault, "prod-db")));
|
|
|
|
var onto = screen.GroupGrid
|
|
.GetVisualDescendants()
|
|
.OfType<ListBoxItem>()
|
|
.Single(item => item.DataContext is HostGroupRowViewModel);
|
|
|
|
var over = Over(onto, carried);
|
|
|
|
onto.Classes.ShouldContain("droptarget", "the card says it would take the host");
|
|
over.DragEffects.ShouldBe(DragDropEffects.Move);
|
|
|
|
var refused = Over(CardFor(screen, Row(vault, "stage-web")), carried);
|
|
|
|
refused.Handled.ShouldBeTrue("the screen answered rather than leaving it to the platform");
|
|
refused.DragEffects.ShouldBe(
|
|
DragDropEffects.None,
|
|
"a card dropped onto another card would be filed somewhere nothing on screen names");
|
|
|
|
// And the group card it was over a moment ago stops offering to take it, which is the half of
|
|
// this that is wrong far more often than the mark appearing at all.
|
|
onto.Classes.ShouldNotContain("droptarget");
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The rule one press was split into two gestures for. Selecting a group aims its EDIT and DELETE at it
|
|
/// and does nothing else; opening one is what narrows the grid, and the trail is the way back out of it.
|
|
/// While a single press meant both, a group could not be named without every host outside it leaving the
|
|
/// screen at the same moment.
|
|
/// </para>
|
|
/// <para>
|
|
/// Driven through the properties the cards bind rather than through a click, because what is worth
|
|
/// holding here is the rule; the pointer is put on the gesture itself in the test below. A click would
|
|
/// otherwise be testing Avalonia's <c>SelectedItem</c> binding, which is not this application's code.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task SelectingAGroupAimsItsButtonsAtItAndOpeningOneNarrowsTheGrid()
|
|
{
|
|
await vault.MoveHostToGroupCommand.ExecuteAsync(
|
|
new HostGroupMove(Row(vault, "prod-db"), vault.Groups.Single().EntityId));
|
|
|
|
// Re-found after the move, because the reload it ends with replaces every row in the list.
|
|
var production = vault.VisibleGroups.Single();
|
|
|
|
vault.SelectedGroup = production;
|
|
|
|
vault.GroupFilter.ShouldBeNull("one press selects a group and does not open it");
|
|
vault.VisibleHosts.Count.ShouldBe(2, "so the grid still holds the hosts outside it");
|
|
vault.GroupTarget.ShouldBeSameAs(production, "what EDIT and DELETE act on");
|
|
vault.ShowsGroupActions.ShouldBeTrue();
|
|
|
|
vault.OpenGroupCommand.Execute(production);
|
|
|
|
vault.VisibleHosts.Select(row => row.Label)
|
|
.ShouldBe(["prod-db"], "only what is filed under the group that is open");
|
|
|
|
vault.GroupTrail.Select(crumb => crumb.Name).ShouldBe(["ALL HOSTS", "production"]);
|
|
|
|
vault.SelectedGroup.ShouldBeNull("the card it was on is not one of the cards on screen any more");
|
|
vault.GroupTarget.ShouldBeSameAs(
|
|
production, "so the buttons fall back to the group whose contents are showing");
|
|
|
|
// Back out, which is the trail's first crumb and nothing else: SHOW ALL was a second control for the
|
|
// same job and went with the change.
|
|
vault.OpenGroupCommand.Execute(vault.GroupTrail[0].Group);
|
|
|
|
vault.VisibleHosts.Count.ShouldBe(2, "ALL HOSTS brings back the hosts outside the group");
|
|
vault.GroupTarget.ShouldBeNull("and nothing is aimed at once no group is open or selected");
|
|
vault.ShowsGroupActions.ShouldBeFalse("a pair of buttons with no subject is hidden rather than shown");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The gesture, performed. It is wired in the control rather than bound in the markup — which is exactly
|
|
/// the sort of wiring that compiles whether or not it is connected to anything — and the first half of
|
|
/// what it asserts is the half that would go unnoticed: one press must still only select, or the split
|
|
/// bought nothing.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task DoubleClickingAGroupCardOpensIt()
|
|
{
|
|
// One press, in a window of its own, because a second pair of clicks in the same one is the gesture
|
|
// this is separating that press from.
|
|
await OnTheGridAsync((screen, window) =>
|
|
{
|
|
var centre = Centre(GroupCard(screen), window);
|
|
|
|
window.MouseDown(centre, MouseButton.Left);
|
|
window.MouseUp(centre, MouseButton.Left);
|
|
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
vault.SelectedGroup.ShouldNotBeNull("one press selects the card");
|
|
vault.GroupFilter.ShouldBeNull("and opens nothing");
|
|
});
|
|
|
|
await OnTheGridAsync((screen, window) =>
|
|
{
|
|
var centre = Centre(GroupCard(screen), window);
|
|
|
|
window.MouseDown(centre, MouseButton.Left);
|
|
window.MouseUp(centre, MouseButton.Left);
|
|
window.MouseDown(centre, MouseButton.Left);
|
|
window.MouseUp(centre, MouseButton.Left);
|
|
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
vault.GroupFilter.ShouldNotBeNull().Label.ShouldBe("production");
|
|
vault.GroupTrail.Select(crumb => crumb.Name).ShouldBe(["ALL HOSTS", "production"]);
|
|
|
|
// And out again through the trail as it is actually drawn, rather than through the command. A
|
|
// crumb is an item in a template and the command it presses is the vault's, so the two are
|
|
// joined by a $parent binding — which is a string that compiles whether or not it resolves, and
|
|
// would leave a trail of buttons that do nothing.
|
|
var back = screen.GetVisualDescendants()
|
|
.OfType<Button>()
|
|
.First(button => button.DataContext is GroupCrumbViewModel { Group: null });
|
|
|
|
back.Command.ShouldNotBeNull("the crumb reached the vault's command").Execute(back.CommandParameter);
|
|
|
|
vault.GroupFilter.ShouldBeNull("ALL HOSTS is the way back out");
|
|
vault.VisibleHosts.Count.ShouldBe(2);
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// The cards are one level of the tree, and the trail is how that level was reached.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The grid used to draw every group at once, which was the only honest thing to do while a card was a
|
|
/// filter: a filter nobody can see is a filter nobody can turn off. Once opening a group became
|
|
/// navigation the cards became its contents — and a level with no name and no way back is a grid that
|
|
/// has quietly hidden things, which is what the trail is for.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task OpeningANestedGroupShowsWhatIsInsideItAndTheWayBack()
|
|
{
|
|
await AddGroupAsync("estate");
|
|
await FileGroupUnderAsync("production", "estate");
|
|
|
|
vault.VisibleGroups.Select(row => row.Label)
|
|
.ShouldBe(["estate"], "the outermost groups, and production is not one of them any more");
|
|
|
|
vault.OpenGroupCommand.Execute(vault.VisibleGroups.Single());
|
|
|
|
vault.VisibleGroups.Select(row => row.Label).ShouldBe(["production"], "what is inside estate");
|
|
vault.GroupTrail.Select(crumb => crumb.Name).ShouldBe(["ALL HOSTS", "estate"]);
|
|
|
|
vault.OpenGroupCommand.Execute(vault.VisibleGroups.Single());
|
|
|
|
vault.HasVisibleGroups.ShouldBeFalse("production has nothing inside it, so the cards fold away");
|
|
vault.GroupTrail.Select(crumb => crumb.Name).ShouldBe(["ALL HOSTS", "estate", "production"]);
|
|
|
|
// A middle crumb goes back one level rather than all the way out, which is the whole reason the
|
|
// trail is a row of buttons instead of a sentence saying where you are.
|
|
vault.OpenGroupCommand.Execute(vault.GroupTrail[1].Group);
|
|
|
|
vault.VisibleGroups.Select(row => row.Label).ShouldBe(["production"]);
|
|
vault.GroupTrail.Select(crumb => crumb.Name).ShouldBe(["ALL HOSTS", "estate"]);
|
|
}
|
|
|
|
// ---- Helpers ----
|
|
|
|
/// <summary>The same in-process format the screen's own drag carries.</summary>
|
|
/// <remarks>
|
|
/// Declared again here rather than made visible, because what the two have in common is the contract —
|
|
/// the name and the type — and a test holding the screen's own field would go on passing if the screen
|
|
/// started carrying something else under it.
|
|
/// </remarks>
|
|
private static readonly DataFormat<HostRowViewModel> HostFormat =
|
|
DataFormat.CreateInProcessFormat<HostRowViewModel>("dodossh-host-row");
|
|
|
|
/// <summary>Holds a dragged host over one control and returns what the screen said about it.</summary>
|
|
/// <remarks>
|
|
/// The nearest a headless test gets to the gesture. No platform drag can be synthesised — there is no
|
|
/// native window to start one — but <c>DragOver</c> is an ordinary routed event, and it is where every
|
|
/// decision this screen makes about a drop is taken: whether the thing under the pointer would accept
|
|
/// the host, and whether it is marked while it is being held there. The drop itself only repeats that
|
|
/// question and runs the command. See docs/manual-checks.md 7.6 for what is left over.
|
|
/// </remarks>
|
|
private static DragEventArgs Over(Interactive target, DataTransfer carried)
|
|
{
|
|
var over = new DragEventArgs(DragDrop.DragOverEvent, carried, target, default, KeyModifiers.None);
|
|
|
|
target.RaiseEvent(over);
|
|
|
|
return over;
|
|
}
|
|
|
|
private static void RightClick(Visual row, Visual window)
|
|
{
|
|
var at = Centre(row, window);
|
|
|
|
((Window)window).MouseDown(at, MouseButton.Right);
|
|
((Window)window).MouseUp(at, MouseButton.Right);
|
|
}
|
|
|
|
private Task OnTheGridAsync(Action<HostsScreen, Window> body) =>
|
|
LayoutHarness.OnTheUiThreadAsync(
|
|
() =>
|
|
{
|
|
var screen = new HostsScreen { DataContext = vault };
|
|
|
|
var window = LayoutHarness.HostAtMinimumSize(
|
|
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
|
|
|
|
try
|
|
{
|
|
body(screen, window);
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
},
|
|
Token);
|
|
|
|
private static ListBoxItem GroupCard(HostsScreen screen) =>
|
|
screen.GroupGrid
|
|
.GetVisualDescendants()
|
|
.OfType<ListBoxItem>()
|
|
.Single(item => item.DataContext is HostGroupRowViewModel);
|
|
|
|
private static ListBoxItem CardFor(Visual screen, HostRowViewModel host) =>
|
|
screen.GetVisualDescendants()
|
|
.OfType<ListBoxItem>()
|
|
.First(item => ReferenceEquals(item.DataContext, host));
|
|
|
|
private static HostRowViewModel Row(VaultViewModel vault, string label) =>
|
|
vault.Hosts.First(row => string.Equals(row.Label, label, StringComparison.Ordinal));
|
|
|
|
private static Point Centre(Visual control, Visual window) =>
|
|
control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window)
|
|
?? throw new InvalidOperationException("the control is not in this window's tree");
|
|
|
|
private async Task AddGroupAsync(string label)
|
|
{
|
|
vault.GroupEditorLabel = label;
|
|
|
|
await vault.SaveGroupCommand.ExecuteAsync(null);
|
|
}
|
|
|
|
/// <summary>Files one group under another the way a user can: through the group's own editor.</summary>
|
|
private async Task FileGroupUnderAsync(string group, string parent)
|
|
{
|
|
vault.SelectedGroup = vault.Groups.Single(
|
|
row => string.Equals(row.Label, group, StringComparison.Ordinal));
|
|
|
|
vault.EditGroupCommand.Execute(null);
|
|
|
|
vault.GroupEditorSelectedParent = vault.GroupEditorParentChoices.Single(
|
|
choice => string.Equals(choice.Label, parent, StringComparison.Ordinal));
|
|
|
|
await vault.SaveGroupCommand.ExecuteAsync(null);
|
|
}
|
|
|
|
/// <remarks>Two hosts and a group, so there is a heading in the list and a selection to move off.</remarks>
|
|
private async Task SeedAsync()
|
|
{
|
|
foreach (var label in new[] { "prod-db", "stage-web" })
|
|
{
|
|
vault.NewHostCommand.Execute(null);
|
|
vault.EditorLabel = label;
|
|
vault.EditorHostname = $"{label}.internal";
|
|
vault.EditorUsername = "deploy";
|
|
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
}
|
|
|
|
vault.GroupEditorLabel = "production";
|
|
await vault.SaveGroupCommand.ExecuteAsync(null);
|
|
|
|
await vault.LoadAsync(Token);
|
|
}
|
|
}
|