sources/Google.Solutions.IapDesktop.Core/ResourceModel/ProjectWorkspace.cs (249 lines of code) (raw):

// // Copyright 2024 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.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.EntityModel; using System; using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; using CrmProject = Google.Apis.CloudResourceManager.v1.Data.Project; namespace Google.Solutions.IapDesktop.Core.ResourceModel { /// <summary> /// Contains a user-selected set of projects, aggregated /// by the organization they belong to. /// </summary> public class ProjectWorkspace : IEntitySearcher<WildcardQuery, Organization>, IEntityNavigator<OrganizationLocator, Project>, IAsyncEntityAspectProvider<OrganizationLocator, Organization>, IAsyncEntityAspectProvider<ProjectLocator, Project> { private readonly IProjectWorkspaceSettings settings; private readonly IAncestryCache ancestryCache; private readonly IResourceManagerClient resourceManager; private readonly AsyncLock cacheLock = new AsyncLock(); private State? cache = null; private volatile bool cacheIsDirty = false; public ProjectWorkspace( IProjectWorkspaceSettings settings, IAncestryCache ancestryCache, IResourceManagerClient resourceManager) { this.settings = settings; this.ancestryCache = ancestryCache; this.resourceManager = resourceManager; this.settings.PropertyChanged += (_, args) => { if (args.PropertyName == nameof(settings.Projects)) { // // Force cache invalidation next time it's accessed. // this.cacheIsDirty = true; } }; } //---------------------------------------------------------------------- // State loading. //---------------------------------------------------------------------- /// <summary> /// Tracks the (cached) state. /// </summary> private class State { /// <summary> /// Organizations used in this workspace. /// </summary> internal IDictionary<OrganizationLocator, Organization> Organizations { get; } /// <summary> /// Projects used in this workspace. /// </summary> internal IDictionary<ProjectLocator, Project> Projects { get; } internal State( IDictionary<OrganizationLocator, Organization> organizations, IDictionary<ProjectLocator, Project> projects) { this.Organizations = organizations; this.Projects = projects; } } private class ProjectWithAncestry { internal ProjectLocator Locator; internal CrmProject? CrmProject; internal bool IsAccessible => this.CrmProject != null; internal OrganizationLocator? OrganizationLocator; public ProjectWithAncestry(ProjectLocator locator) { this.Locator = locator; this.CrmProject = null; this.OrganizationLocator = null; } } /// <summary> /// Load list of projects from API. /// </summary> private static async Task<State> LoadStateAsync( IProjectWorkspaceSettings context, IAncestryCache ancestryCache, IResourceManagerClient resourceManager, CancellationToken cancellationToken) { // // NB. The Compute Engine project.get resource does not include the // project name, so we have to use the Resource Manager API instead. // var getProjectTasks = context .Projects .EnsureNotNull() .Select(p => new { Locator = p, Task = resourceManager.GetProjectAsync(p, cancellationToken) }) .ToList(); var projects = new List<ProjectWithAncestry>(); foreach (var task in getProjectTasks) { // // NB. Some projects might not be accessible anymore, // either because they have been deleted or the user // lost access. // try { var project = new ProjectWithAncestry(task.Locator) { CrmProject = await task.Task.ConfigureAwait(false) }; projects.Add(project); // // Add cached ancestry, if available. // ancestryCache.TryGetAncestry(project.Locator, out project.OrganizationLocator); CoreTraceSource.Log.TraceVerbose( "Successfully loaded project {0}", project.Locator); } 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. // projects.Add(new ProjectWithAncestry(task.Locator)); CoreTraceSource.Log.TraceError( "Failed to load project {0}: {1}", task.Locator, e); } } Debug.Assert(projects.Count == getProjectTasks.Count); // // At this point, we have all projects, but we might not know the // org ID for all projects yet. // var findOrgIdsTasks = projects .Where(p => p.IsAccessible && p.OrganizationLocator == null) .Select(p => new { Project = p, Task = resourceManager.FindOrganizationAsync( p.Locator, cancellationToken) }) .ToList(); foreach (var task in findOrgIdsTasks) { // // Amend ancestry (if available). // task.Project.OrganizationLocator = await task.Task.ConfigureAwait(false); if (task.Project.OrganizationLocator != null) { // // Cache ancestry information to speed up future lookups. // ancestryCache.SetAncestry(task.Project.Locator, task.Project.OrganizationLocator); } } // // Finally, resolve all org IDs. // var findOrgTasks = projects .Where(p => p.OrganizationLocator != null) .Select(p => p.OrganizationLocator) .Distinct() .Select(loc => new { Organization = loc!, Task = resourceManager.GetOrganizationAsync(loc!, cancellationToken) }) .ToList(); var organizations = new Dictionary<OrganizationLocator, Organization>(); foreach (var task in findOrgTasks) { try { var org = await task.Task.ConfigureAwait(false); organizations[task.Organization] = new Organization( task.Organization, org.DisplayName); } catch (Exception e) when (e.IsReauthError()) { // // Propagate reauth errors so that the reauth logic kicks in. // throw e.Unwrap(); } catch { // // Organization inaccessible (even though we do have its ID), // use default. // organizations[task.Organization] = Organization.Default; } } if (projects.Any(p => p.OrganizationLocator == null)) { // // Include default org. // organizations[Organization.Default.Locator] = Organization.Default; } return new State( organizations, projects.ToDictionary( p => p.Locator, p => new Project( p.OrganizationLocator ?? Organization.Default.Locator, p.Locator, p.CrmProject != null ? p.CrmProject.Name // Actual name. : p.Locator.Name, // Project inaccessible, use ID. p.CrmProject != null))); } private async Task<State> GetStateAsync( CancellationToken cancellationToken) { using (await this.cacheLock .AcquireAsync(cancellationToken) .ConfigureAwait(false)) { if (this.cache == null || this.cacheIsDirty) { // // Populate cache. If this fails, we rethrow the exception // and try again next time. // this.cache = await LoadStateAsync( this.settings, this.ancestryCache, this.resourceManager, cancellationToken) .ConfigureAwait(false); this.cacheIsDirty = false; } return this.cache; } } /// <summary> /// Preload cache. /// </summary> public Task PreloadCacheAsync() { return LoadStateAsync( this.settings, this.ancestryCache, this.resourceManager, CancellationToken.None); } //---------------------------------------------------------------------- // Projects. //---------------------------------------------------------------------- public async Task<IEnumerable<Project>> ListDescendantsAsync( OrganizationLocator locator, CancellationToken cancellationToken) { var state = await GetStateAsync(cancellationToken) .ConfigureAwait(false); return state.Projects .Values .Where(p => p.Organization == locator) .ToList(); } public async Task<Project?> QueryAspectAsync( ProjectLocator locator, CancellationToken cancellationToken) { var state = await GetStateAsync(cancellationToken) .ConfigureAwait(false); return state.Projects.TryGet(locator); } //---------------------------------------------------------------------- // Organizations. //---------------------------------------------------------------------- public async Task<IEnumerable<Organization>> SearchAsync( WildcardQuery query, CancellationToken cancellationToken) { var state = await GetStateAsync(cancellationToken) .ConfigureAwait(false); return state.Organizations.Values; } public async Task<Organization?> QueryAspectAsync( OrganizationLocator locator, CancellationToken cancellationToken) { var state = await GetStateAsync(cancellationToken) .ConfigureAwait(false); return state.Organizations.TryGet(locator); } } //---------------------------------------------------------------------- // Context. //---------------------------------------------------------------------- /// <summary> /// Pesistent settings for a workspace. /// </summary> public interface IProjectWorkspaceSettings : INotifyPropertyChanged { /// <summary> /// List of projects in this workspace. /// </summary> /// <remarks> /// Raises a PropertyChanged events when changed. /// </remarks> IEnumerable<ProjectLocator> Projects { get; } } /// <summary> /// Cache for project ancestry information. /// </summary> public interface IAncestryCache { /// <summary> /// Get cached project's ancestry, in top-to-bottom order. /// /// The ancestry path might be incomplete or empty if the current /// doesn't have sufficient access to resolve the full ancestry. /// </summary> /// <returns>false if ancestry hasn't been set before</returns> bool TryGetAncestry(ProjectLocator project, out OrganizationLocator? ancestry); /// <summary> /// Cache project ancestry path, in top-to-bottom order. /// /// The ancestry path might be incomplete or empty if the current /// doesn't have sufficient access to resolve the full ancestry. /// </summary> void SetAncestry(ProjectLocator project, OrganizationLocator ancestry); } }