sources/Google.Solutions.IapDesktop.Core/ProjectModel/ProjectWorkspace.cs (385 lines of code) (raw):
//
// Copyright 2021 Google LLC
//
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
//
using Google.Solutions.Apis;
using Google.Solutions.Apis.Compute;
using Google.Solutions.Apis.Crm;
using Google.Solutions.Apis.Locator;
using Google.Solutions.Common.Diagnostics;
using Google.Solutions.Common.Linq;
using Google.Solutions.Common.Threading;
using Google.Solutions.Common.Util;
using Google.Solutions.IapDesktop.Core.ClientModel.Traits;
using Google.Solutions.IapDesktop.Core.ObjectModel;
using Google.Solutions.IapDesktop.Core.ProjectModel.Nodes;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Google.Solutions.IapDesktop.Core.ProjectModel
{
/// <summary>
/// Represents the in-memory model (or workspace) of projects and
/// instances. Data is cached, but read-only.
/// </summary>
public interface IProjectWorkspace : IDisposable
{
/// <summary>
/// Add a project so that it will be considered when
/// the model is next (force-) reloaded.
/// </summary>
Task AddProjectAsync(ProjectLocator project);
/// <summary>
/// Remove project so that it will not be considered when
/// the model is next (force-) reloaded.
/// </summary>
Task RemoveProjectAsync(ProjectLocator project);
/// <summary>
/// Load projects without loading zones and instances.
/// Uses cached data if available.
/// </summary>
Task<IProjectModelCloudNode> GetRootNodeAsync(
bool forceReload,
CancellationToken token);
/// <summary>
/// Load zones and instances. Uses cached data if available.
/// </summary>
Task<IReadOnlyCollection<IProjectModelZoneNode>> GetZoneNodesAsync(
ProjectLocator project,
bool forceReload,
CancellationToken token);
/// <summary>
/// Load any node. Uses cached data if available.
/// </summary>
Task<IProjectModelNode?> GetNodeAsync(
ComputeEngineLocator locator,
CancellationToken token);
/// <summary>
/// Gets the active/selected node. The selection
/// is kept across reloads.
/// </summary>
Task<IProjectModelNode> GetActiveNodeAsync(CancellationToken token);
/// <summary>
/// Gets the active/selected node. The selection
/// is kept across reloads.
/// </summary>
Task SetActiveNodeAsync(
IProjectModelNode node,
CancellationToken token);
/// <summary>
/// Gets the active/selected node. The selection
/// is kept across reloads.
/// </summary>
Task SetActiveNodeAsync(
ComputeEngineLocator? locator,
CancellationToken token);
}
public class ProjectWorkspace : IProjectWorkspace
{
private readonly IComputeEngineClient computeClient;
private readonly IResourceManagerClient resourceManagerClient;
private readonly IProjectRepository projectRepository;
private readonly IEventQueue eventQueue;
private ComputeEngineLocator? activeNode;
private readonly AsyncLock cacheLock = new AsyncLock();
private CloudNode? cachedRoot = null;
private readonly Dictionary<ProjectLocator, IReadOnlyCollection<IProjectModelZoneNode>> cachedZones =
new Dictionary<ProjectLocator, IReadOnlyCollection<IProjectModelZoneNode>>();
// For testing only.
internal int CachedProjectsCount => this.cachedZones.Count;
//---------------------------------------------------------------------
// Data loading (uncached).
//---------------------------------------------------------------------
private async Task<CloudNode> LoadProjectsAsync(
CancellationToken token)
{
using (CoreTraceSource.Log.TraceMethod().WithoutParameters())
{
var accessibleProjects = new List<ProjectNode>();
//
// Load projects in parallel.
//
// NB. The Compute Engine project.get resource does not include the
// project name, so we have to use the Resource Manager API instead.
//
var tasks = new Dictionary<ProjectLocator, Task<Google.Apis.CloudResourceManager.v1.Data.Project>>();
foreach (var project in await this.projectRepository
.ListProjectsAsync()
.ConfigureAwait(false))
{
tasks.Add(
new ProjectLocator(project.ProjectId),
this.resourceManagerClient
.GetProjectAsync(project.Project, token));
}
foreach (var task in tasks)
{
//
// NB. Some projects might not be accessible anymore,
// either because they have been deleted or the user
// lost access.
//
try
{
var project = await task.Value.ConfigureAwait(false);
Debug.Assert(project != null);
accessibleProjects.Add(new ProjectNode(
task.Key,
true,
project!.Name));
CoreTraceSource.Log.TraceVerbose(
"Successfully loaded project {0}", task.Key);
}
catch (Exception e) when (e.IsReauthError())
{
// Propagate reauth errors so that the reauth logic kicks in.
throw e.Unwrap();
}
catch (Exception e)
{
//
// Add as inaccessible project and continue.
//
accessibleProjects.Add(new ProjectNode(
task.Key,
false,
task.Key.Name)); // Use ID instead of name.
CoreTraceSource.Log.TraceError(
"Failed to load project {0}: {1}",
task.Key,
e);
}
}
return new CloudNode(accessibleProjects);
}
}
private async Task<IReadOnlyCollection<IProjectModelZoneNode>> LoadZonesAsync(
ProjectLocator project,
CancellationToken token)
{
using (CoreTraceSource.Log.TraceMethod().WithoutParameters())
{
var instances = await this.computeClient
.ListInstancesAsync(project.Project, token)
.ConfigureAwait(false);
var zoneLocators = instances
.EnsureNotNull()
.Select(i => ZoneLocator.Parse(i.Zone))
.ToHashSet();
var zones = new List<ZoneNode>();
foreach (var zoneLocator in zoneLocators.OrderBy(z => z.Name))
{
var instancesInZone = instances
.Where(i => ZoneLocator.Parse(i.Zone) == zoneLocator)
.Where(i => i.Disks != null && i.Disks.Any())
.OrderBy(i => i.Name)
.Select(i => new InstanceNode(
this,
i.Id!.Value,
new InstanceLocator(
zoneLocator.ProjectId,
zoneLocator.Name,
i.Name),
TraitDetector.DetectTraits(i),
i.Status))
.ToList();
zones.Add(new ZoneNode(
zoneLocator,
instancesInZone));
}
return zones;
}
}
internal async Task ControlInstanceAsync(
InstanceLocator instance,
InstanceControlCommand command,
CancellationToken cancellationToken)
{
using (CoreTraceSource.Log.TraceMethod()
.WithParameters(instance, command))
{
await this.computeClient.ControlInstanceAsync(
instance,
command,
cancellationToken)
.ConfigureAwait(false);
await this.eventQueue.PublishAsync(
new InstanceStateChangedEvent(
instance,
command == InstanceControlCommand.Start ||
command == InstanceControlCommand.Resume ||
command == InstanceControlCommand.Reset))
.ConfigureAwait(false);
}
}
//---------------------------------------------------------------------
// Ctor.
//---------------------------------------------------------------------
public ProjectWorkspace(
IComputeEngineClient computeClient,
IResourceManagerClient resourceManagerAdapter,
IProjectRepository projectRepository,
IEventQueue eventQueue)
{
this.computeClient = computeClient.ExpectNotNull(nameof(computeClient));
this.resourceManagerClient = resourceManagerAdapter.ExpectNotNull(nameof(resourceManagerAdapter));
this.projectRepository = projectRepository.ExpectNotNull(nameof(projectRepository));
this.eventQueue = eventQueue.ExpectNotNull(nameof(eventQueue));
}
//---------------------------------------------------------------------
// IProjectModelService.
//---------------------------------------------------------------------
public async Task AddProjectAsync(ProjectLocator project)
{
using (CoreTraceSource.Log.TraceMethod().WithParameters(project))
{
this.projectRepository.AddProject(project);
await this.eventQueue
.PublishAsync(new ProjectAddedEvent(project.ProjectId))
.ConfigureAwait(false);
}
}
public async Task RemoveProjectAsync(ProjectLocator project)
{
using (CoreTraceSource.Log.TraceMethod().WithParameters(project))
{
this.projectRepository.RemoveProject(project);
//
// Purge from cache.
//
using (await this.cacheLock.AcquireAsync(CancellationToken.None)
.ConfigureAwait(false))
{
this.cachedZones.Remove(project);
}
await this.eventQueue
.PublishAsync(new ProjectDeletedEvent(project.ProjectId))
.ConfigureAwait(false);
}
}
public async Task<IProjectModelCloudNode> GetRootNodeAsync(
bool forceReload,
CancellationToken token)
{
using (await this.cacheLock.AcquireAsync(token).ConfigureAwait(false))
{
if (this.cachedRoot == null || forceReload)
{
//
// Load from backend and cache.
//
this.cachedRoot = await LoadProjectsAsync(token)
.ConfigureAwait(false);
//
// Load zones for all projects. This serves two purposes:
//
// - On startup, it ensures that all zones are loaded in
// parallel, resulting in faster startup experience.
// - On force-reload, it ensures that we not only reload
// the list of projects, but also their contents (zones).
//
var loadProjectTasks = this.cachedRoot
.Projects
.Select(p => new {
p.Project,
Zones = LoadZonesAsync(p.Project, token)
})
//
// Force eager evaluation, otherwise we're not triggering
// the network call.
//
.ToList();
foreach (var loadProjectTask in loadProjectTasks)
{
try
{
this.cachedZones[loadProjectTask.Project] = await loadProjectTask
.Zones
.ConfigureAwait(false);
}
catch (Exception e) when (
e.Is<ResourceAccessDeniedException>() ||
e.Is<ResourceNotFoundException>())
{
//
// Project is inaccessible or it doesn't exist. The project will
// still show up in the model, but with with an empty list of zones.
//
this.cachedZones[loadProjectTask.Project] =
new List<IProjectModelZoneNode>();
}
}
}
}
Debug.Assert(this.cachedRoot != null);
return this.cachedRoot!;
}
public async Task<IReadOnlyCollection<IProjectModelZoneNode>> GetZoneNodesAsync(
ProjectLocator project,
bool forceReload,
CancellationToken token)
{
using (CoreTraceSource.Log.TraceMethod().WithParameters(project, forceReload))
{
IReadOnlyCollection<IProjectModelZoneNode>? zones = null;
using (await this.cacheLock.AcquireAsync(token).ConfigureAwait(false))
{
if (!this.cachedZones.TryGetValue(
project,
out zones) || forceReload)
{
//
// Load from backend and cache.
//
zones = await LoadZonesAsync(project, token)
.ConfigureAwait(false);
this.cachedZones[project] = zones;
}
}
Debug.Assert(zones != null);
return zones!;
}
}
public async Task<IProjectModelNode?> GetNodeAsync(
ComputeEngineLocator locator,
CancellationToken token)
{
using (CoreTraceSource.Log.TraceMethod().WithParameters(locator))
{
if (locator is ProjectLocator projectLocator)
{
var root = await GetRootNodeAsync(false, token)
.ConfigureAwait(false);
return root.Projects.FirstOrDefault(p => p.Project == projectLocator);
}
else if (locator is ZoneLocator zoneLocator)
{
var project = await GetNodeAsync(
new ProjectLocator(zoneLocator.ProjectId), token)
.ConfigureAwait(false);
if (project == null)
{
// Don't load a zone if the parent project has not been added.
return null;
}
var zones = await GetZoneNodesAsync(
new ProjectLocator(zoneLocator.ProjectId),
false,
token)
.ConfigureAwait(false);
return zones.FirstOrDefault(z => z.Zone == zoneLocator);
}
else if (locator is InstanceLocator instanceLocator)
{
var project = await GetNodeAsync(
new ProjectLocator(instanceLocator.ProjectId), token)
.ConfigureAwait(false);
if (project == null)
{
// Don't load a instance if the parent project has not been added.
return null;
}
var zones = await GetZoneNodesAsync(
new ProjectLocator(instanceLocator.ProjectId),
false,
token)
.ConfigureAwait(false);
return zones
.SelectMany(z => z.Instances)
.FirstOrDefault(i => i.Instance == instanceLocator);
}
else
{
throw new ArgumentException("Unrecognized locator " + locator);
}
}
}
public async Task<IProjectModelNode> GetActiveNodeAsync(CancellationToken token)
{
//
// Look up the node. It's possible that no node has been
// selected or that the node is not there anymore because
// it was removed by a reload - then default to the root.
//
if (this.activeNode != null)
{
var node = await GetNodeAsync(this.activeNode, token)
.ConfigureAwait(false);
if (node != null)
{
return node;
}
}
return await GetRootNodeAsync(false, token)
.ConfigureAwait(false);
}
public Task SetActiveNodeAsync(
IProjectModelNode node,
CancellationToken token)
{
if (node is IProjectModelInstanceNode instanceNode)
{
return SetActiveNodeAsync(instanceNode.Instance, token);
}
else if (node is IProjectModelZoneNode zoneNode)
{
return SetActiveNodeAsync(zoneNode.Zone, token);
}
else if (node is IProjectModelProjectNode projectNode)
{
return SetActiveNodeAsync(projectNode.Project, token);
}
else
{
return SetActiveNodeAsync((ComputeEngineLocator?)null, token);
}
}
public async Task SetActiveNodeAsync(
ComputeEngineLocator? locator,
CancellationToken token)
{
using (CoreTraceSource.Log.TraceMethod().WithParameters(locator))
{
IProjectModelNode? node;
if (locator != null)
{
node = await GetNodeAsync(locator, token)
.ConfigureAwait(false);
if (node != null)
{
//
// Node found -> set as active and fire event.
//
this.activeNode = locator;
await this.eventQueue
.PublishAsync(new ActiveProjectChangedEvent(node))
.ConfigureAwait(true);
return;
}
}
//
// Locator was null or pointed to nonexisting node ->
// set root as active.
//
this.activeNode = null;
if (this.cachedRoot != null)
{
await this.eventQueue
.PublishAsync(new ActiveProjectChangedEvent(this.cachedRoot))
.ConfigureAwait(true);
}
}
}
//---------------------------------------------------------------------
// IDisposable.
//---------------------------------------------------------------------
protected virtual void Dispose(bool disposing)
{
this.cacheLock.Dispose();
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
}
}