mirror of
https://github.com/OutlineFoundation/outline-server.git
synced 2026-08-04 14:37:34 +00:00
Adds GCP create server flow (#864)
* gitignore * Adds GCP token to CloudAccounts * Checkpoint * Checkpoint * Refactors cloud accounts * Addresses comments * Auto-formatting * Reverts gallery webpack GCP local server * Reverts webpack config change * Minor cleanup * Removes unused class * Reverts initial feature flag implementation * Removes unneeded logging * Adds credentials getters to CloudAccounts and updates tests * Auto-format * Adds FakeCloudAccounts * Removes credentialsGetter from CloudAccounts * Removes account factories from CloudAccounts * Addresses review comments * Addresses review commetns * Auto-formatting * Adds GcpAccount implementation * Auto-format * Auto-format * Replace OAuth config client id * Adds method to fetch OpenID userinfo * Adds method to fetch OpenID userinfo * Checkpoint * Auto-format * Adds method to refresh GCP access token * Uncommenting * Checkpoint Auto-formatting Updates intro step with gcp account Auto-format typo * Add comment * fetch refactor * Auto-format * Remove account from CloudAccounts * Refactors refresh token exchange * Auto-format * Auto-format * Initial GCP create server UI * Auto-format * Addresses review comments * URL encoding * Encode URL * Adds GCP strings * Addresses review comments * Addresses review comments * Addresses review comments * Adds GCP create server app to gallery; Addresses review comments; * Auto-formatting * Addresses review comments * Auto-format * Addresses review comments * Auto-format * Addresses review comments * Addresses review comments * Auto-format * Addresses review comment * Auto-format * Addresses review comments * Minor fix * Removes comments * Addresses review comments * Auto-format * Addresses review comments
This commit is contained in:
parent
2c575bb8a5
commit
b7cda5c7ae
21 changed files with 1844 additions and 64 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,6 +2,7 @@
|
|||
/build
|
||||
node_modules/
|
||||
/src/server_manager/install_scripts/do_install_script.ts
|
||||
/src/server_manager/install_scripts/gcp_install_script.ts
|
||||
yarn-error.log
|
||||
.vscode/
|
||||
.idea/
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
"node": "^12"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf src/*/node_modules/ build/ node_modules/ src/server_manager/install_scripts/do_install_script.ts third_party/shellcheck/download/",
|
||||
"clean": "rm -rf src/*/node_modules/ build/ node_modules/ src/server_manager/install_scripts/do_install_script.ts src/server_manager/install_scripts/gcp_install_script.ts third_party/shellcheck/download/",
|
||||
"do": "bash ./scripts/do_action.sh",
|
||||
"lint": "yarn shellcheck && yarn tslint",
|
||||
"shellcheck": "bash ./scripts/shellcheck.sh",
|
||||
|
|
|
|||
577
src/server_manager/cloud/gcp_api.ts
Normal file
577
src/server_manager/cloud/gcp_api.ts
Normal file
|
|
@ -0,0 +1,577 @@
|
|||
// Copyright 2021 The Outline Authors
|
||||
//
|
||||
// Licensed 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.
|
||||
|
||||
// TODO: Share the same OAuth config between electron app and renderer.
|
||||
// Keep this in sync with {@link gcp_oauth.ts#OAUTH_CONFIG}
|
||||
const GCP_OAUTH_CLIENT_ID =
|
||||
'946220775492-osi1dm2rhhpo4upm6qqfv9fiivv1qu6c.apps.googleusercontent.com';
|
||||
|
||||
/** @see https://cloud.google.com/compute/docs/reference/rest/v1/instances */
|
||||
export type Instance = Readonly<{
|
||||
id: string; creationTimestamp: string; name: string; description: string;
|
||||
tags: {items: string[]; fingerprint: string;};
|
||||
machineType: string;
|
||||
zone: string;
|
||||
networkInterfaces: Array<{
|
||||
network: string; subnetwork: string; networkIP: string; ipv6Address: string; name: string;
|
||||
accessConfigs: Array<{
|
||||
type: string; name: string; natIP: string; setPublicPtr: boolean; publicPtrDomainName: string;
|
||||
networkTier: string;
|
||||
kind: string;
|
||||
}>;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* @see https://cloud.google.com/compute/docs/reference/rest/v1/instances/getGuestAttributes#response-body
|
||||
*/
|
||||
type GuestAttributes = Readonly<{
|
||||
variableKey: string; variableValue: string; queryPath: string;
|
||||
queryValue: {items: Array<{namespace: string; key: string; value: string;}>;};
|
||||
}>;
|
||||
|
||||
/** @see https://cloud.google.com/compute/docs/reference/rest/v1/zones */
|
||||
type Zone = Readonly<{
|
||||
id: string; creationTimestamp: string; name: string; description: string; status: 'UP' | 'DOWN';
|
||||
region: string;
|
||||
}>;
|
||||
|
||||
type Status = Readonly<{code: number; message: string}>;
|
||||
|
||||
/** @see https://cloud.google.com/resource-manager/reference/rest/Shared.Types/Operation */
|
||||
export type ResourceManagerOperation = Readonly<{name: string; done: boolean; error: Status;}>;
|
||||
|
||||
/**
|
||||
* @see https://cloud.google.com/compute/docs/reference/rest/v1/globalOperations
|
||||
* @see https://cloud.google.com/compute/docs/reference/rest/v1/zoneOperations
|
||||
*/
|
||||
type ComputeEngineOperation = Readonly<
|
||||
{id: string; name: string; targetId: string; status: string; error: {errors: Status[]}}>;
|
||||
|
||||
/**
|
||||
* @see https://cloud.google.com/service-usage/docs/reference/rest/Shared.Types/ListOperationsResponse#Operation
|
||||
*/
|
||||
type ServiceUsageOperation = Readonly<{name: string; done: boolean; error: Status;}>;
|
||||
|
||||
/** @see https://cloud.google.com/resource-manager/reference/rest/v1/projects */
|
||||
export type Project =
|
||||
Readonly<{projectNumber: string; projectId: string; name: string, lifecycleState: string;}>;
|
||||
|
||||
/** @see https://cloud.google.com/compute/docs/reference/rest/v1/firewalls/get#response-body */
|
||||
type Firewall = Readonly<{id: string; name: string;}>;
|
||||
|
||||
/** https://cloud.google.com/billing/docs/reference/rest/v1/billingAccounts */
|
||||
export type BillingAccount =
|
||||
Readonly<{name: string; open: boolean; displayName: string; masterBillingAccount: string;}>;
|
||||
|
||||
/** https://cloud.google.com/billing/docs/reference/rest/v1/ProjectBillingInfo */
|
||||
export type ProjectBillingInfo = Readonly<
|
||||
{name: string; projectId: string; billingAccountName?: string; billingEnabled?: boolean;}>;
|
||||
|
||||
/**
|
||||
* @see https://accounts.google.com/.well-known/openid-configuration for
|
||||
* supported claims.
|
||||
*
|
||||
* Note: The supported claims are optional and not guaranteed to be in the
|
||||
* response.
|
||||
*/
|
||||
export type UserInfo = Readonly<{email: string;}>;
|
||||
|
||||
type Service = Readonly<
|
||||
{name: string; config: {name: string;}; state: 'STATE_UNSPECIFIED' | 'DISABLED' | 'ENABLED';}>;
|
||||
|
||||
type ListInstancesResponse = Readonly<{items: Instance[]; nextPageToken: string;}>;
|
||||
type ListZonesResponse = Readonly<{items: Zone[]; nextPageToken: string;}>;
|
||||
type ListProjectsResponse = Readonly<{projects: Project[]; nextPageToken: string;}>;
|
||||
type ListFirewallsResponse = Readonly<{items: Firewall[]; nextPageToken: string;}>;
|
||||
type ListBillingAccountsResponse =
|
||||
Readonly<{billingAccounts: BillingAccount[]; nextPageToken: string}>;
|
||||
type ListEnabledServicesResponse = Readonly<{services: Service[]; nextPageToken: string;}>;
|
||||
type RefreshAccessTokenResponse = Readonly<{access_token: string; expires_in: number;}>;
|
||||
|
||||
export class HttpError extends Error {
|
||||
constructor(private statusCode: number, message?: string) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
getStatusCode(): number {
|
||||
return this.statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
export class RestApiClient {
|
||||
private readonly GCP_HEADERS = new Map<string, string>([
|
||||
['Content-type', 'application/json'],
|
||||
['Accept', 'application/json'],
|
||||
]);
|
||||
|
||||
private accessToken: string;
|
||||
|
||||
constructor(private refreshToken: string) {}
|
||||
|
||||
/**
|
||||
* Creates a new Google Compute Engine VM instance in a specified GCP project.
|
||||
*
|
||||
* @see https://cloud.google.com/compute/docs/reference/rest/v1/instances/insert
|
||||
*
|
||||
* @param projectId - The GCP project ID.
|
||||
* @param zoneId - The zone in which to create the instance.
|
||||
* @param data - Request body data. See documentation.
|
||||
*/
|
||||
async createInstance(projectId: string, zoneId: string, data: {}): Promise<ComputeEngineOperation> {
|
||||
const operation = await this.fetchAuthenticated<ComputeEngineOperation>(
|
||||
'POST',
|
||||
new URL(`https://compute.googleapis.com/compute/v1/projects/${projectId}/zones/${
|
||||
zoneId}/instances`),
|
||||
this.GCP_HEADERS, null, data);
|
||||
return await this.computeEngineOperationZoneWait(projectId, zoneId, operation.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a specified Google Compute Engine VM instance.
|
||||
*
|
||||
* @see https://cloud.google.com/compute/docs/reference/rest/v1/instances/delete
|
||||
*
|
||||
* @param projectId - The GCP project ID.
|
||||
* @param instanceId - The ID of the instance to delete.
|
||||
* @param zoneId - The zone in which the instance resides.
|
||||
*/
|
||||
async deleteInstance(projectId: string, instanceId: string, zoneId: string): Promise<void> {
|
||||
const operation = await this.fetchAuthenticated<ComputeEngineOperation>(
|
||||
'DELETE',
|
||||
new URL(`https://compute.googleapis.com/compute/v1/projects/${projectId}/zones/${
|
||||
zoneId}/instances/${instanceId}`),
|
||||
this.GCP_HEADERS);
|
||||
await this.computeEngineOperationZoneWait(projectId, zoneId, operation.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the specified Google Compute Engine VM instance resource.
|
||||
*
|
||||
* @see https://cloud.google.com/compute/docs/reference/rest/v1/instances/get
|
||||
*
|
||||
* @param projectId - The GCP project ID.
|
||||
* @param instanceId - The ID of the instance to retrieve.
|
||||
* @param zoneId - The zone in which the instance resides.
|
||||
*/
|
||||
getInstance(projectId: string, instanceId: string, zoneId: string): Promise<Instance> {
|
||||
return this.fetchAuthenticated(
|
||||
'GET',
|
||||
new URL(`https://compute.googleapis.com/compute/v1/projects/${projectId}/zones/${
|
||||
zoneId}/instances/${instanceId}`),
|
||||
this.GCP_HEADERS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the Google Compute Engine VM instances in a specified zone.
|
||||
*
|
||||
* @see https://cloud.google.com/compute/docs/reference/rest/v1/instances/list
|
||||
*
|
||||
* @param projectId - The GCP project ID.
|
||||
* @param zoneId - The zone to query.
|
||||
* @param filter - See documentation.
|
||||
*/
|
||||
// TODO: Pagination
|
||||
listInstances(projectId: string, zoneId: string, filter?: string):
|
||||
Promise<ListInstancesResponse> {
|
||||
let parameters = null;
|
||||
if (filter) {
|
||||
parameters = new Map<string, string>([
|
||||
['filter', filter],
|
||||
]);
|
||||
}
|
||||
return this.fetchAuthenticated(
|
||||
'GET',
|
||||
new URL(`https://compute.googleapis.com/compute/v1/projects/${projectId}/zones/${
|
||||
zoneId}/instances`),
|
||||
this.GCP_HEADERS, parameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a static IP address.
|
||||
*
|
||||
* If no IP address is provided, a new static IP address is created. If an
|
||||
* ephemeral IP address is provided, it is promoted to a static IP address.
|
||||
*
|
||||
* @see https://cloud.google.com/compute/docs/reference/rest/v1/addresses/insert
|
||||
*
|
||||
* @param projectId - The GCP project ID.
|
||||
* @param regionId - The GCP region.
|
||||
* @param data - Request body data. See documentation.
|
||||
*/
|
||||
async createStaticIp(projectId: string, regionId: string, data: {}): Promise<ComputeEngineOperation> {
|
||||
const operation = await this.fetchAuthenticated<ComputeEngineOperation>(
|
||||
'POST',
|
||||
new URL(`https://compute.googleapis.com/compute/v1/projects/${projectId}/regions/${
|
||||
regionId}/addresses`),
|
||||
this.GCP_HEADERS, null, data);
|
||||
return await this.computeEngineOperationRegionWait(projectId, regionId, operation.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a static IP address.
|
||||
*
|
||||
* @see https://cloud.google.com/compute/docs/reference/rest/v1/addresses/delete
|
||||
*
|
||||
* @param projectId - The GCP project ID.
|
||||
* @param addressName - The name of the static IP address resource.
|
||||
* @param regionId - The GCP region of the resource.
|
||||
*/
|
||||
async deleteStaticIp(projectId: string, addressName: string, regionId: string): Promise<void> {
|
||||
const operation = await this.fetchAuthenticated<ComputeEngineOperation>(
|
||||
'DELETE',
|
||||
new URL(`https://compute.googleapis.com/compute/v1/projects/${projectId}/regions/${
|
||||
regionId}/addresses/${addressName}`),
|
||||
this.GCP_HEADERS);
|
||||
await this.computeEngineOperationRegionWait(projectId, regionId, operation.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the guest attributes applied to the specified Google Compute Engine VM instance.
|
||||
*
|
||||
* @see https://cloud.google.com/compute/docs/storing-retrieving-metadata#guest_attributes
|
||||
* @see https://cloud.google.com/compute/docs/reference/rest/v1/instances/getGuestAttributes
|
||||
*
|
||||
* @param projectId - The GCP project ID.
|
||||
* @param instanceId - The ID of the VM instance.
|
||||
* @param zoneId - The zone in which the instance resides.
|
||||
* @param namespace - The namespace of the guest attributes.
|
||||
*/
|
||||
async getGuestAttributes(
|
||||
projectId: string, instanceId: string, zoneId: string,
|
||||
namespace: string): Promise<GuestAttributes|undefined> {
|
||||
try {
|
||||
const parameters = new Map<string, string>([['queryPath', namespace]]);
|
||||
// We must await the call to getGuestAttributes to properly catch any exceptions.
|
||||
return await this.fetchAuthenticated(
|
||||
'GET',
|
||||
new URL(`https://compute.googleapis.com/compute/v1/projects/${projectId}/zones/${
|
||||
zoneId}/instances/${instanceId}/getGuestAttributes`),
|
||||
this.GCP_HEADERS, parameters);
|
||||
} catch (error) {
|
||||
// TODO: Distinguish between 404 not found and other errors.
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a firewall under the specified GCP project.
|
||||
*
|
||||
* @see https://cloud.google.com/compute/docs/reference/rest/v1/firewalls/insert
|
||||
*
|
||||
* @param projectId - The GCP project ID.
|
||||
* @param data - Request body data. See documentation.
|
||||
*/
|
||||
async createFirewall(projectId: string, data: {}): Promise<ComputeEngineOperation> {
|
||||
const operation = await this.fetchAuthenticated<ComputeEngineOperation>(
|
||||
'POST',
|
||||
new URL(`https://compute.googleapis.com/compute/v1/projects/${projectId}/global/firewalls`),
|
||||
this.GCP_HEADERS, null, data);
|
||||
return await this.computeEngineOperationGlobalWait(projectId, operation.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param projectId - The GCP project ID.
|
||||
* @param name - The firewall name.
|
||||
*/
|
||||
// TODO: Replace with getFirewall (and handle 404 NotFound)
|
||||
listFirewalls(projectId: string, name: string): Promise<ListFirewallsResponse> {
|
||||
const filter = `name=${name}`;
|
||||
const parameters = new Map<string, string>([['filter', filter]]);
|
||||
return this.fetchAuthenticated(
|
||||
'GET',
|
||||
new URL(`https://compute.googleapis.com/compute/v1/projects/${projectId}/global/firewalls`),
|
||||
this.GCP_HEADERS, parameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the zones available to a given GCP project.
|
||||
*
|
||||
* @see https://cloud.google.com/compute/docs/reference/rest/v1/zones/list
|
||||
*
|
||||
* @param projectId - The GCP project ID.
|
||||
*/
|
||||
// TODO: Pagination
|
||||
listZones(projectId: string): Promise<ListZonesResponse> {
|
||||
return this.fetchAuthenticated(
|
||||
'GET', new URL(`https://compute.googleapis.com/compute/v1/projects/${projectId}/zones`),
|
||||
this.GCP_HEADERS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all services that have been enabled on the project.
|
||||
*
|
||||
* @param projectId - The GCP project ID.
|
||||
*/
|
||||
listEnabledServices(projectId: string): Promise<ListEnabledServicesResponse> {
|
||||
const parameters = new Map<string, string>([['filter', 'state:ENABLED']]);
|
||||
return this.fetchAuthenticated(
|
||||
'GET', new URL(`https://serviceusage.googleapis.com/v1/projects/${projectId}/services`),
|
||||
this.GCP_HEADERS, parameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param projectId - The GCP project ID.
|
||||
* @param data - Request body data. See documentation.
|
||||
*/
|
||||
enableServices(projectId: string, data: {}): Promise<ServiceUsageOperation> {
|
||||
return this.fetchAuthenticated(
|
||||
'POST',
|
||||
new URL(
|
||||
`https://serviceusage.googleapis.com/v1/projects/${projectId}/services:batchEnable`),
|
||||
this.GCP_HEADERS, null, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new GCP project
|
||||
*
|
||||
* The project ID must conform to the following:
|
||||
* - must be 6 to 30 lowercase letters, digits, or hyphens
|
||||
* - must start with a letter
|
||||
* - no trailing hyphens
|
||||
*
|
||||
* @see https://cloud.google.com/resource-manager/reference/rest/v1/projects/create
|
||||
*
|
||||
* @param projectId - The unique user-assigned project ID.
|
||||
* @param data - Request body data. See documentation.
|
||||
*/
|
||||
createProject(projectId: string, data: {}): Promise<ResourceManagerOperation> {
|
||||
return this.fetchAuthenticated(
|
||||
'POST', new URL('https://cloudresourcemanager.googleapis.com/v1/projects'),
|
||||
this.GCP_HEADERS, null, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the GCP projects that the user has access to.
|
||||
*
|
||||
* @see https://cloud.google.com/resource-manager/reference/rest/v1/projects/list
|
||||
*
|
||||
* @param filter - See documentation.
|
||||
*/
|
||||
listProjects(filter?: string): Promise<ListProjectsResponse> {
|
||||
let parameters = null;
|
||||
if (filter) {
|
||||
parameters = new Map<string, string>([
|
||||
['filter', filter],
|
||||
]);
|
||||
}
|
||||
return this.fetchAuthenticated(
|
||||
'GET', new URL('https://cloudresourcemanager.googleapis.com/v1/projects'), this.GCP_HEADERS,
|
||||
parameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the billing information for a specified GCP project.
|
||||
*
|
||||
* @see https://cloud.google.com/billing/docs/reference/rest/v1/projects/getBillingInfo
|
||||
*
|
||||
* @param projectId - The GCP project ID.
|
||||
*/
|
||||
getProjectBillingInfo(projectId: string): Promise<ProjectBillingInfo> {
|
||||
return this.fetchAuthenticated(
|
||||
'GET', new URL(`https://cloudbilling.googleapis.com/v1/projects/${projectId}/billingInfo`),
|
||||
this.GCP_HEADERS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Associates a GCP project with a billing account.
|
||||
*
|
||||
* @see https://cloud.google.com/billing/docs/reference/rest/v1/projects/updateBillingInfo
|
||||
*
|
||||
* @param projectId - The GCP project ID.
|
||||
* @param data - Request body data. See documentation.
|
||||
*/
|
||||
updateProjectBillingInfo(projectId: string, data: {}): Promise<ProjectBillingInfo> {
|
||||
return this.fetchAuthenticated(
|
||||
'PUT', new URL(`https://cloudbilling.googleapis.com/v1/projects/${projectId}/billingInfo`),
|
||||
this.GCP_HEADERS, null, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the billing accounts that the user has access to.
|
||||
*
|
||||
* @see https://cloud.google.com/billing/docs/reference/rest/v1/billingAccounts/list
|
||||
*/
|
||||
listBillingAccounts(): Promise<ListBillingAccountsResponse> {
|
||||
return this.fetchAuthenticated(
|
||||
'GET', new URL(`https://cloudbilling.googleapis.com/v1/billingAccounts`), this.GCP_HEADERS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for a specified Google Compute Engine zone operation to complete.
|
||||
*
|
||||
* @see https://cloud.google.com/compute/docs/reference/rest/v1/zoneOperations/wait
|
||||
*
|
||||
* @param projectId - The GCP project ID.
|
||||
* @param zoneId - The zone ID.
|
||||
* @param operationId - The operation ID.
|
||||
*/
|
||||
computeEngineOperationZoneWait(projectId: string, zoneId: string, operationId: string):
|
||||
Promise<ComputeEngineOperation> {
|
||||
return this.fetchAuthenticated(
|
||||
'POST',
|
||||
new URL(`https://compute.googleapis.com/compute/v1/projects/${projectId}/zones/${
|
||||
zoneId}/operations/${operationId}/wait`),
|
||||
this.GCP_HEADERS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for a specified Google Compute Engine region operation to complete.
|
||||
*
|
||||
* @see https://cloud.google.com/compute/docs/reference/rest/v1/regionOperations/wait
|
||||
*
|
||||
* @param projectId - The GCP project ID.
|
||||
* @param regionId - The region ID.
|
||||
* @param operationId - The operation ID.
|
||||
*/
|
||||
computeEngineOperationRegionWait(projectId: string, regionId: string, operationId: string):
|
||||
Promise<ComputeEngineOperation> {
|
||||
return this.fetchAuthenticated(
|
||||
'POST',
|
||||
new URL(`https://compute.googleapis.com/compute/v1/projects/${projectId}/regions/${
|
||||
regionId}/operations/${operationId}/wait`),
|
||||
this.GCP_HEADERS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for a specified Google Compute Engine global operation to complete.
|
||||
*
|
||||
* @see https://cloud.google.com/compute/docs/reference/rest/v1/globalOperations/wait
|
||||
*
|
||||
* @param projectId - The GCP project ID.
|
||||
* @param operationId - The operation ID.
|
||||
*/
|
||||
computeEngineOperationGlobalWait(projectId: string, operationId: string):
|
||||
Promise<ComputeEngineOperation> {
|
||||
return this.fetchAuthenticated(
|
||||
'POST',
|
||||
new URL(`https://compute.googleapis.com/compute/v1/projects/${
|
||||
projectId}/global/operations/${operationId}/wait`),
|
||||
this.GCP_HEADERS);
|
||||
}
|
||||
|
||||
resourceManagerOperationGet(operationId: string): Promise<ResourceManagerOperation> {
|
||||
return this.fetchAuthenticated(
|
||||
'GET', new URL(`https://cloudresourcemanager.googleapis.com/v1/${operationId}`),
|
||||
this.GCP_HEADERS);
|
||||
}
|
||||
|
||||
serviceUsageOperationGet(operationId: string): Promise<ServiceUsageOperation> {
|
||||
return this.fetchAuthenticated(
|
||||
'GET', new URL(`https://serviceusage.googleapis.com/v1/${operationId}`), this.GCP_HEADERS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the OpenID Connect profile information.
|
||||
*
|
||||
* For a list of the supported Google OpenID claims
|
||||
* @see https://accounts.google.com/.well-known/openid-configuration.
|
||||
*
|
||||
* The OpenID standard, including the "userinfo" response and core claims, is
|
||||
* defined in the links below:
|
||||
* @see https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse
|
||||
* @see https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
|
||||
*/
|
||||
getUserInfo(): Promise<UserInfo> {
|
||||
const parameters = new Map<string, string>([['access_token', this.accessToken]]);
|
||||
return this.fetchAuthenticated(
|
||||
'POST', new URL('https://openidconnect.googleapis.com/v1/userinfo'), this.GCP_HEADERS);
|
||||
}
|
||||
|
||||
private async refreshGcpAccessToken(refreshToken: string): Promise<string> {
|
||||
const headers = new Map<string, string>(
|
||||
[['Host', 'oauth2.googleapis.com'], ['Content-Type', 'application/x-www-form-urlencoded']]);
|
||||
const data = {
|
||||
// TODO: Consider moving client ID to the caller.
|
||||
client_id: GCP_OAUTH_CLIENT_ID,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
};
|
||||
const encodedData = this.encodeFormData(data);
|
||||
const response: RefreshAccessTokenResponse = await this.fetchUnauthenticated(
|
||||
'POST', new URL('https://oauth2.googleapis.com/token'), headers, null, encodedData);
|
||||
return response.access_token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes a token.
|
||||
*
|
||||
* @see https://developers.google.com/identity/protocols/oauth2/native-app
|
||||
*
|
||||
* @param token - A refresh token or access token
|
||||
*/
|
||||
private async revokeGcpToken(token: string): Promise<void> {
|
||||
const headers = new Map<string, string>(
|
||||
[['Host', 'oauth2.googleapis.com'], ['Content-Type', 'application/x-www-form-urlencoded']]);
|
||||
const parameters = new Map<string, string>([['token', token]]);
|
||||
return this.fetchUnauthenticated(
|
||||
'GET', new URL('https://oauth2.googleapis.com/revoke'), headers, parameters);
|
||||
}
|
||||
|
||||
// tslint:disable-next-line:no-any
|
||||
private async fetchAuthenticated<T>(method: string, url: URL, headers: Map<string, string>, parameters?: Map<string, string>, data?: any): Promise<T> {
|
||||
const httpHeaders = new Map(headers);
|
||||
|
||||
// TODO: Handle token expiration/revokation.
|
||||
if (!this.accessToken) {
|
||||
this.accessToken = await this.refreshGcpAccessToken(this.refreshToken);
|
||||
}
|
||||
httpHeaders.set('Authorization', `Bearer ${this.accessToken}`);
|
||||
return this.fetchUnauthenticated(method, url, httpHeaders, parameters, data);
|
||||
}
|
||||
|
||||
// tslint:disable-next-line:no-any
|
||||
private async fetchUnauthenticated<T>(method: string, url: URL, headers: Map<string, string>, parameters?: Map<string, string>, data?: any): Promise<T> {
|
||||
const customHeaders = new Headers();
|
||||
headers.forEach((value, key) => {
|
||||
customHeaders.append(key, value);
|
||||
});
|
||||
if (parameters) {
|
||||
parameters.forEach((value: string, key: string) => url.searchParams.append(key, value));
|
||||
}
|
||||
|
||||
// TODO: More robust handling of data types
|
||||
if (typeof data === 'object') {
|
||||
data = JSON.stringify(data);
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: method.toUpperCase(),
|
||||
headers: customHeaders,
|
||||
...(data && {body: data}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new HttpError(response.status, response.statusText);
|
||||
}
|
||||
|
||||
try {
|
||||
let result = undefined;
|
||||
if (response.status !== 204) {
|
||||
result = await response.json();
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
throw new Error('Error parsing response body: ' + JSON.stringify(e));
|
||||
}
|
||||
}
|
||||
|
||||
private encodeFormData(data: object): string {
|
||||
return Object.entries(data)
|
||||
.map(entry => {
|
||||
return encodeURIComponent(entry[0]) + '=' + encodeURIComponent(entry[1]);
|
||||
})
|
||||
.join('&');
|
||||
}
|
||||
}
|
||||
|
|
@ -22,7 +22,11 @@ const OAUTH_CONFIG = {
|
|||
client_id: '946220775492-osi1dm2rhhpo4upm6qqfv9fiivv1qu6c.apps.googleusercontent.com',
|
||||
scopes: [
|
||||
'https://www.googleapis.com/auth/userinfo.email',
|
||||
'https://www.googleapis.com/auth/cloud-platform',
|
||||
'https://www.googleapis.com/auth/compute',
|
||||
'https://www.googleapis.com/auth/cloudplatformprojects',
|
||||
'https://www.googleapis.com/auth/cloud-billing',
|
||||
'https://www.googleapis.com/auth/service.management',
|
||||
'https://www.googleapis.com/auth/cloud-platform.read-only',
|
||||
],
|
||||
};
|
||||
const REDIRECT_PATH = '/gcp/oauth/callback';
|
||||
|
|
|
|||
BIN
src/server_manager/images/flags/unknown.png
Normal file
BIN
src/server_manager/images/flags/unknown.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
|
|
@ -0,0 +1,29 @@
|
|||
// Copyright 2021 The Outline Authors
|
||||
//
|
||||
// Licensed 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.
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const tarballBinary = fs.readFileSync(process.argv[2]);
|
||||
const base64Tarball = new Buffer(tarballBinary).toString('base64');
|
||||
const scriptText = `
|
||||
(base64 --decode | tar --extract --gzip ) <<EOM
|
||||
${base64Tarball}
|
||||
EOM
|
||||
./gcp_install_server.sh
|
||||
`;
|
||||
|
||||
console.log(`export const SCRIPT = ${JSON.stringify(scriptText)};`);
|
||||
124
src/server_manager/install_scripts/gcp_install_server.sh
Executable file
124
src/server_manager/install_scripts/gcp_install_server.sh
Executable file
|
|
@ -0,0 +1,124 @@
|
|||
#!/bin/bash
|
||||
#
|
||||
# Copyright 2021 The Outline Authors
|
||||
#
|
||||
# Licensed 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.
|
||||
|
||||
# Script to install Shadowbox on a GCP Compute Engine instance
|
||||
|
||||
# You may set the following environment variables, overriding their defaults:
|
||||
# SB_IMAGE: Shadowbox Docker image to install, e.g. quay.io/outline/shadowbox:nightly
|
||||
# SB_API_PORT: The port number of the management API.
|
||||
# SENTRY_API_URL: Url to post Sentry report to on error.
|
||||
# WATCHTOWER_REFRESH_SECONDS: refresh interval in seconds to check for updates,
|
||||
# defaults to 3600.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
export SHADOWBOX_DIR="${SHADOWBOX_DIR:-${HOME:-/root}/shadowbox}"
|
||||
mkdir -p "${SHADOWBOX_DIR}"
|
||||
|
||||
# Save output for debugging
|
||||
exec &> "${SHADOWBOX_DIR}/install-shadowbox-output"
|
||||
|
||||
function cloud::public_ip() {
|
||||
curl curl -H "Metadata-Flavor: Google" "http://metadata.google.internal/computeMetadata/v1/instance/network-interfaces/0/access-configs/0/external-ip"
|
||||
}
|
||||
|
||||
# Initialize sentry log file.
|
||||
export SENTRY_LOG_FILE="${SHADOWBOX_DIR}/sentry-log-file.txt"
|
||||
true > "${SENTRY_LOG_FILE}"
|
||||
function log_for_sentry() {
|
||||
echo "[$(date "+%Y-%m-%d@%H:%M:%S")]" "gcp_install_server.sh" "$@" >> "${SENTRY_LOG_FILE}"
|
||||
}
|
||||
|
||||
function post_sentry_report() {
|
||||
if [[ -n "${SENTRY_API_URL}" ]]; then
|
||||
# Get JSON formatted string. This command replaces newlines with literal '\n'
|
||||
# but otherwise assumes that there are no other characters to escape for JSON.
|
||||
# If we need better escaping, we can install the jq command line tool.
|
||||
local -ir SENTRY_PAYLOAD_BYTE_LIMIT=8000
|
||||
local SENTRY_PAYLOAD
|
||||
SENTRY_PAYLOAD="{\"message\": \"Install error:\n$(awk '{printf "%s\\n", $0}' < "${SENTRY_LOG_FILE}" | tail --bytes "${SENTRY_PAYLOAD_BYTE_LIMIT}")\"}"
|
||||
# See Sentry documentation at:
|
||||
# https://media.readthedocs.org/pdf/sentry/7.1.0/sentry.pdf
|
||||
curl "${SENTRY_API_URL}" -H "Origin: shadowbox" --data-binary "${SENTRY_PAYLOAD}"
|
||||
fi
|
||||
}
|
||||
|
||||
# Applies a guest attribute to the GCE VM.
|
||||
function cloud::set_guest_attribute() {
|
||||
local label_key="$1"
|
||||
local label_value="$2"
|
||||
|
||||
local GUEST_ATTIBUTE_NAMESPACE="outline"
|
||||
local SET_GUEST_ATTRIBUTE_URL="http://metadata.google.internal/computeMetadata/v1/instance/guest-attributes/${GUEST_ATTIBUTE_NAMESPACE}/${label_key}"
|
||||
curl -H "Metadata-Flavor: Google" -X PUT -d "${label_value}" "${SET_GUEST_ATTRIBUTE_URL}"
|
||||
}
|
||||
|
||||
# Enable BBR.
|
||||
# Recent DigitalOcean one-click images are based on Ubuntu 18 and have kernel 4.15+.
|
||||
log_for_sentry "Enabling BBR"
|
||||
cat >> /etc/sysctl.conf << EOF
|
||||
|
||||
# Added by Outline.
|
||||
net.core.default_qdisc=fq
|
||||
net.ipv4.tcp_congestion_control=bbr
|
||||
EOF
|
||||
sysctl -p
|
||||
|
||||
log_for_sentry "Initializing ACCESS_CONFIG"
|
||||
export ACCESS_CONFIG="${SHADOWBOX_DIR}/access.txt"
|
||||
true > "${ACCESS_CONFIG}"
|
||||
|
||||
# Set trap which publishes an error tag and sentry report only if there is an error.
|
||||
function finish {
|
||||
INSTALL_SERVER_EXIT_CODE=$?
|
||||
log_for_sentry "In EXIT trap, exit code ${INSTALL_SERVER_EXIT_CODE}"
|
||||
if ! ( grep --quiet apiUrl "${ACCESS_CONFIG}" && grep --quiet certSha256 "${ACCESS_CONFIG}" ); then
|
||||
echo "INSTALL_SCRIPT_FAILED: ${INSTALL_SERVER_EXIT_CODE}" | cloud::set_guest_attribute "install-error" "true"
|
||||
# Post error report to sentry.
|
||||
post_sentry_report
|
||||
fi
|
||||
}
|
||||
trap finish EXIT
|
||||
|
||||
# Run install script asynchronously, so tags can be written as soon as they are ready.
|
||||
log_for_sentry "Running install_server.sh"
|
||||
./install_server.sh&
|
||||
declare -ir install_pid=$!
|
||||
|
||||
# Save tags for access information.
|
||||
log_for_sentry "Reading tags from ACCESS_CONFIG"
|
||||
tail -f "${ACCESS_CONFIG}" "--pid=${install_pid}" | while IFS=: read -r key value; do
|
||||
case "${key}" in
|
||||
certSha256)
|
||||
log_for_sentry "Writing certSha256 tag"
|
||||
echo "case certSha256: ${key}/${value}"
|
||||
# The value is hex(fingerprint) and Electron expects base64(fingerprint).
|
||||
hex_fingerprint="${value}"
|
||||
base64_fingerprint="$(echo -n "${hex_fingerprint}" | xxd -revert -p -c 255 | base64)"
|
||||
cloud::set_guest_attribute "${key}" "${base64_fingerprint}"
|
||||
;;
|
||||
apiUrl)
|
||||
log_for_sentry "Writing apiUrl tag"
|
||||
echo "case apiUrl: ${key}/${value}"
|
||||
url_value=$(echo -n "${value}")
|
||||
cloud::set_guest_attribute "${key}" "${url_value}"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Wait for install script to finish, so that if there is any error in install_server.sh,
|
||||
# the finish trap in this file will be able to access its error code.
|
||||
wait "${install_pid}"
|
||||
|
|
@ -94,6 +94,8 @@
|
|||
"gcp-create-project": "Create a Google Cloud project",
|
||||
"gcp-create-server": "Create your server",
|
||||
"gcp-create-vm": "Create a VM instance",
|
||||
"gcp-disconnect": "Disconnect",
|
||||
"gcp-disconnect-account": "Disconnect Google Cloud Platform account",
|
||||
"gcp-firewall-create-0": "{openLink}Add a new firewall rule{closeLink} to your Compute Engine project.",
|
||||
"gcp-firewall-create-1": "Type 'outline' in the 'Name' field.",
|
||||
"gcp-firewall-create-2": "Type 'outline' in the 'Target tags' field.",
|
||||
|
|
@ -187,6 +189,7 @@
|
|||
"server-usage": "Usage (last 30 days)",
|
||||
"servers-add": "Add server",
|
||||
"servers-digitalocean": "DigitalOcean servers",
|
||||
"servers-gcp": "Google Cloud Platform servers",
|
||||
"servers-manual": "Servers",
|
||||
"settings-access-key-port": "Port for new access keys",
|
||||
"settings-metrics-header": "Share anonymous metrics",
|
||||
|
|
|
|||
|
|
@ -483,6 +483,14 @@
|
|||
"message": "Create a VM Instance",
|
||||
"description": "This string appears as a header for a set of instructions for creating a new Google Cloud VM"
|
||||
},
|
||||
"gcp_disconnect": {
|
||||
"message": "Disconnect",
|
||||
"description": "This string appears as a button in a dialog to disconnect the user's Google Cloud Platform account from the application. Clicking it signs the user out of Google Cloud Platform, a cloud server provider."
|
||||
},
|
||||
"gcp_disconnect_account": {
|
||||
"message": "Disconnect Google Cloud Platform account",
|
||||
"description": "This string appears as a header in a dialog to disconnect the user's Google Cloud Platform account from the application. Google Cloud Platform is a cloud server provider name and should not be translated."
|
||||
},
|
||||
"gcp_firewall_create_0": {
|
||||
"message": "$START_OF_LINK$Add a new firewall rule$END_OF_LINK$ to your Compute Engine project.",
|
||||
"description": "This string appears within the server setup view as an item of a list that provides instructions to configure a firewall in Google Cloud Platform. Compute Engine is a product of Google Cloud Platform and should not be translated.",
|
||||
|
|
@ -887,6 +895,10 @@
|
|||
"message": "DigitalOcean servers",
|
||||
"description": "This string appears in an application drawer as the header of a section that displays the list of DigitalOcean servers. DigitalOcean is a cloud server provider name and should not be translated."
|
||||
},
|
||||
"servers_gcp": {
|
||||
"message": "Google Cloud Platform servers",
|
||||
"description": "This string appears in an application drawer as the header of a section that displays the list of Google Cloud Platform servers. Google Cloud Platform is a cloud server provider name and should not be translated."
|
||||
},
|
||||
"servers_manual": {
|
||||
"message": "Servers",
|
||||
"description": "This string appears in an application drawer as the header of a section that displays a list of servers."
|
||||
|
|
|
|||
|
|
@ -12,9 +12,82 @@
|
|||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// TODO: This is just a stub atm and will need to define and implement the rest
|
||||
// of the functionality.
|
||||
import {ManagedServer, RegionId} from './server';
|
||||
|
||||
// Keys are region IDs like "us-central1".
|
||||
// Values are zones like ["us-central1-a", "us-central1-b"].
|
||||
export type ZoneId = string;
|
||||
export type ZoneMap = {
|
||||
[regionId: string]: ZoneId[]
|
||||
};
|
||||
|
||||
export type Project = {
|
||||
id: string,
|
||||
name: string,
|
||||
};
|
||||
|
||||
export type BillingAccount = {
|
||||
id: string,
|
||||
name: string,
|
||||
};
|
||||
|
||||
/**
|
||||
* The Google Cloud Platform account model.
|
||||
*/
|
||||
export interface Account {
|
||||
// Returns a user-friendly name associated with the account.
|
||||
/**
|
||||
* Returns a globally unique identifier for this Account.
|
||||
*/
|
||||
getId(): string;
|
||||
|
||||
/**
|
||||
* Returns a user-friendly name associated with the account.
|
||||
*/
|
||||
getName(): Promise<string>;
|
||||
|
||||
/**
|
||||
* Creates an Outline server on a Google Compute Engine VM instance.
|
||||
*
|
||||
* This method returns after the VM instance has been created. The Shadowbox
|
||||
* Outline server may not be fully installed. See {@link ManagedServer#waitOnInstall}
|
||||
* to be notified when the server installation has completed.
|
||||
*
|
||||
* @param projectId - The GCP project ID.
|
||||
* @param name - The name to be given to the server.
|
||||
* @param zoneId - The ID of the GCP zone to create the server in.
|
||||
*/
|
||||
createServer(projectId: string, name: string, zoneId: string): Promise<ManagedServer>;
|
||||
|
||||
/**
|
||||
* Lists the Outline servers in a given GCP project.
|
||||
*
|
||||
* @param projectId - The GCP project ID.
|
||||
*/
|
||||
listServers(projectId: string): Promise<ManagedServer[]>;
|
||||
|
||||
/**
|
||||
* Lists the Google Compute Engine locations available to given GCP project.
|
||||
*
|
||||
* @param projectId - The GCP project ID.
|
||||
*/
|
||||
listLocations(projectId: string): Promise<ZoneMap>;
|
||||
|
||||
/**
|
||||
* Creates a new Google Cloud Platform project.
|
||||
*
|
||||
* The project ID must conform to the following:
|
||||
* - must be 6 to 30 lowercase letters, digits, or hyphens
|
||||
* - must start with a letter
|
||||
* - no trailing hyphens
|
||||
*
|
||||
* @param id - The project ID.
|
||||
* @param billingAccount - The billing account ID.
|
||||
*/
|
||||
createProject(id: string, billingAccountId: string): Promise<Project>;
|
||||
|
||||
/** Lists the Google Cloud Platform projects available with the user. */
|
||||
listProjects(): Promise<Project[]>;
|
||||
|
||||
/** Lists the Google Cloud Platform billing accounts associated with the user. */
|
||||
listBillingAccounts(): Promise<BillingAccount[]>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,11 +23,12 @@ import * as digitalocean from '../model/digitalocean';
|
|||
import * as gcp from '../model/gcp';
|
||||
import * as server from '../model/server';
|
||||
|
||||
import {bytesToDisplayDataAmount, DisplayDataAmount, displayDataAmountToBytes,} from './data_formatting';
|
||||
import {DisplayDataAmount, displayDataAmountToBytes,} from './data_formatting';
|
||||
import * as digitalocean_server from './digitalocean_server';
|
||||
import {DigitalOceanServer} from './digitalocean_server';
|
||||
import {GcpServer} from './gcp_server';
|
||||
import {parseManualServerConfig} from './management_urls';
|
||||
import {AppRoot, ServerListEntry} from './ui_components/app-root';
|
||||
import {OutlinePerKeyDataLimitDialog} from './ui_components/outline-per-key-data-limit-dialog.js';
|
||||
import {Location} from './ui_components/outline-region-picker-step';
|
||||
import {DisplayAccessKey, ServerView} from './ui_components/outline-server-view';
|
||||
|
||||
|
|
@ -56,9 +57,6 @@ const DIGITALOCEAN_FLAG_MAPPING: {[cityId: string]: string} = {
|
|||
nyc: `${FLAG_IMAGE_DIR}/us.png`,
|
||||
};
|
||||
|
||||
function dataLimitToDisplayDataAmount(limit: server.DataLimit): DisplayDataAmount|null {
|
||||
return bytesToDisplayDataAmount(limit?.bytes);
|
||||
}
|
||||
function displayDataAmountToDataLimit(dataAmount: DisplayDataAmount): server.DataLimit|null {
|
||||
if (!dataAmount) {
|
||||
return null;
|
||||
|
|
@ -149,13 +147,22 @@ export class App {
|
|||
appRoot.addEventListener(
|
||||
'ConnectGcpAccountRequested',
|
||||
async (event: CustomEvent) => this.handleConnectGcpAccountRequest());
|
||||
appRoot.addEventListener(
|
||||
'CreateGcpServerRequested',
|
||||
async (event: CustomEvent) => console.log('Received CreateGcpServerRequested event'));
|
||||
appRoot.addEventListener('SignOutRequested', (event: CustomEvent) => {
|
||||
appRoot.addEventListener('CreateGcpServerRequested', async (event: CustomEvent) => {
|
||||
this.appRoot.getAndShowGcpCreateServerApp().start(this.gcpAccount);
|
||||
});
|
||||
appRoot.addEventListener('GcpServerCreated', (event: CustomEvent) => {
|
||||
const server = event.detail.server;
|
||||
this.addServer(this.gcpAccount.getId(), server);
|
||||
this.showServer(server);
|
||||
});
|
||||
appRoot.addEventListener('DigitalOceanSignOutRequested', (event: CustomEvent) => {
|
||||
this.disconnectDigitalOceanAccount();
|
||||
this.showIntro();
|
||||
});
|
||||
appRoot.addEventListener('GcpSignOutRequested', (event: CustomEvent) => {
|
||||
this.disconnectGcpAccount();
|
||||
this.showIntro();
|
||||
});
|
||||
|
||||
appRoot.addEventListener('SetUpServerRequested', (event: CustomEvent) => {
|
||||
this.createDigitalOceanServer(event.detail.regionId);
|
||||
|
|
@ -317,10 +324,10 @@ export class App {
|
|||
async start(): Promise<void> {
|
||||
this.showIntro();
|
||||
|
||||
// Load server list. Fetch manual and managed servers in parallel.
|
||||
// Load connected accounts and servers.
|
||||
await Promise.all([
|
||||
this.loadDigitalOceanServers(this.cloudAccounts.getDigitalOceanAccount()),
|
||||
this.loadManualServers()
|
||||
this.loadDigitalOceanAccount(this.cloudAccounts.getDigitalOceanAccount()),
|
||||
this.loadGcpAccount(this.cloudAccounts.getGcpAccount()), this.loadManualServers()
|
||||
]);
|
||||
|
||||
// Show last displayed server, if any.
|
||||
|
|
@ -333,7 +340,7 @@ export class App {
|
|||
}
|
||||
}
|
||||
|
||||
private async loadDigitalOceanServers(digitalOceanAccount: digitalocean.Account):
|
||||
private async loadDigitalOceanAccount(digitalOceanAccount: digitalocean.Account):
|
||||
Promise<server.ManagedServer[]> {
|
||||
if (!digitalOceanAccount) {
|
||||
return [];
|
||||
|
|
@ -361,6 +368,26 @@ export class App {
|
|||
return [];
|
||||
}
|
||||
|
||||
private async loadGcpAccount(gcpAccount: gcp.Account): Promise<server.ManagedServer[]> {
|
||||
if (!gcpAccount) {
|
||||
return [];
|
||||
}
|
||||
|
||||
this.gcpAccount = gcpAccount;
|
||||
this.appRoot.gcpAccount = {id: this.gcpAccount.getId(), name: await this.gcpAccount.getName()};
|
||||
|
||||
const result = [];
|
||||
const gcpProjects = await this.gcpAccount.listProjects();
|
||||
for (const gcpProject of gcpProjects) {
|
||||
const servers = await this.gcpAccount.listServers(gcpProject.id);
|
||||
for (const server of servers) {
|
||||
this.addServer(this.gcpAccount.getId(), server);
|
||||
result.push(server);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private async loadManualServers() {
|
||||
for (const server of await this.manualServerRepository.listServers()) {
|
||||
this.addServer(null, server);
|
||||
|
|
@ -379,10 +406,14 @@ export class App {
|
|||
private makeDisplayName(server: server.Server): string {
|
||||
let name = server.getName() ?? server.getHostnameForAccessKeys();
|
||||
if (!name) {
|
||||
if (isManagedServer(server)) {
|
||||
// Newly created servers will not have a name.
|
||||
name = this.makeLocalizedServerName(server.getHost().getRegionId());
|
||||
let location = null;
|
||||
// Newly created servers will not have a name.
|
||||
if (server instanceof DigitalOceanServer) {
|
||||
location = this.getLocalizedCityName(server.getHost().getRegionId());
|
||||
} else if (server instanceof GcpServer) {
|
||||
location = server.getHost().getRegionId();
|
||||
}
|
||||
name = this.makeLocalizedServerName(location);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
|
@ -567,7 +598,7 @@ export class App {
|
|||
}
|
||||
|
||||
private async handleConnectDigitalOceanAccountRequest(): Promise<void> {
|
||||
let digitalOceanAccount: digitalocean.Account;
|
||||
let digitalOceanAccount: digitalocean.Account = null;
|
||||
try {
|
||||
const accessToken = await this.runDigitalOceanOauthFlow();
|
||||
digitalOceanAccount = this.cloudAccounts.connectDigitalOceanAccount(accessToken);
|
||||
|
|
@ -581,18 +612,20 @@ export class App {
|
|||
}
|
||||
return;
|
||||
}
|
||||
const doServers = await this.loadDigitalOceanServers(digitalOceanAccount);
|
||||
|
||||
const doServers = await this.loadDigitalOceanAccount(digitalOceanAccount);
|
||||
if (doServers.length > 0) {
|
||||
this.showServer(doServers[0]);
|
||||
} else {
|
||||
await this.showDigitalOceanCreateServer(digitalOceanAccount);
|
||||
await this.showDigitalOceanCreateServer(this.digitalOceanAccount);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleConnectGcpAccountRequest(): Promise<void> {
|
||||
let gcpAccount: gcp.Account = null;
|
||||
try {
|
||||
const refreshToken = await this.runGcpOauthFlow();
|
||||
this.gcpAccount = this.cloudAccounts.connectGcpAccount(refreshToken);
|
||||
gcpAccount = this.cloudAccounts.connectGcpAccount(refreshToken);
|
||||
} catch (error) {
|
||||
this.disconnectGcpAccount();
|
||||
this.showIntro();
|
||||
|
|
@ -604,7 +637,7 @@ export class App {
|
|||
return;
|
||||
}
|
||||
|
||||
this.appRoot.gcpAccountName = await this.gcpAccount.getName();
|
||||
await this.loadGcpAccount(gcpAccount);
|
||||
this.showIntro();
|
||||
}
|
||||
|
||||
|
|
@ -627,9 +660,19 @@ export class App {
|
|||
|
||||
// Clears the GCP credentials and returns to the intro screen.
|
||||
private disconnectGcpAccount(): void {
|
||||
if (!this.gcpAccount) {
|
||||
// Not connected.
|
||||
return;
|
||||
}
|
||||
const accountId = this.gcpAccount.getId();
|
||||
this.cloudAccounts.disconnectGcpAccount();
|
||||
this.gcpAccount = null;
|
||||
this.appRoot.gcpAccountName = '';
|
||||
for (const serverEntry of this.appRoot.serverList) {
|
||||
if (serverEntry.accountId === accountId) {
|
||||
this.removeServer(serverEntry.id);
|
||||
}
|
||||
}
|
||||
this.appRoot.gcpAccount = null;
|
||||
}
|
||||
|
||||
// Opens the screen to create a server.
|
||||
|
|
@ -669,7 +712,8 @@ export class App {
|
|||
// Shadowbox may not be fully installed once this promise is fulfilled.
|
||||
public async createDigitalOceanServer(regionId: server.RegionId): Promise<void> {
|
||||
try {
|
||||
const serverName = this.makeLocalizedServerName(regionId);
|
||||
const serverLocation = this.getLocalizedCityName(regionId);
|
||||
const serverName = this.makeLocalizedServerName(serverLocation);
|
||||
const server = await this.digitalOceanRetry(() => {
|
||||
return this.digitalOceanAccount.createServer(regionId, serverName);
|
||||
});
|
||||
|
|
@ -686,9 +730,8 @@ export class App {
|
|||
return this.appRoot.localize(`city-${cityId}`);
|
||||
}
|
||||
|
||||
private makeLocalizedServerName(regionId: server.RegionId): string {
|
||||
const serverLocation = this.getLocalizedCityName(regionId);
|
||||
return this.appRoot.localize('server-name', 'serverLocation', serverLocation);
|
||||
private makeLocalizedServerName(location: string): string {
|
||||
return this.appRoot.localize('server-name', 'serverLocation', location);
|
||||
}
|
||||
|
||||
public showServer(server: server.Server): void {
|
||||
|
|
|
|||
|
|
@ -23,11 +23,14 @@ rm -rf "${OUT_DIR}"
|
|||
mkdir -p "${OUT_DIR}"
|
||||
|
||||
pushd "${ROOT_DIR}/src/server_manager/install_scripts" > /dev/null
|
||||
tar --create --gzip -f "${OUT_DIR}/scripts.tgz" ./*.sh
|
||||
tar --create --gzip -f "${OUT_DIR}/do_scripts.tgz" ./install_server.sh ./do_install_server.sh
|
||||
tar --create --gzip -f "${OUT_DIR}/gcp_scripts.tgz" ./install_server.sh ./gcp_install_server.sh
|
||||
|
||||
# Node.js on Cygwin doesn't like absolute Unix-style paths.
|
||||
# So, we use a relative path as input.
|
||||
cd "${ROOT_DIR}"
|
||||
node src/server_manager/install_scripts/build_install_script_ts.node.js \
|
||||
build/server_manager/web_app/sh/scripts.tgz > "${ROOT_DIR}/src/server_manager/install_scripts/do_install_script.ts"
|
||||
node src/server_manager/install_scripts/build_do_install_script_ts.node.js \
|
||||
build/server_manager/web_app/sh/do_scripts.tgz > "${ROOT_DIR}/src/server_manager/install_scripts/do_install_script.ts"
|
||||
node src/server_manager/install_scripts/build_gcp_install_script_ts.node.js \
|
||||
build/server_manager/web_app/sh/gcp_scripts.tgz > "${ROOT_DIR}/src/server_manager/install_scripts/gcp_install_script.ts"
|
||||
popd > /dev/null
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ export class CloudAccounts implements accounts.CloudAccounts {
|
|||
}
|
||||
|
||||
private createGcpAccount(refreshToken: string): GcpAccount {
|
||||
return new GcpAccount(refreshToken);
|
||||
return new GcpAccount('gcp', refreshToken);
|
||||
}
|
||||
|
||||
private save(): void {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
import '../ui_components/outline-about-dialog';
|
||||
import '../ui_components/outline-do-oauth-step';
|
||||
import '../ui_components/outline-gcp-oauth-step';
|
||||
import '../ui_components/outline-gcp-create-server-app';
|
||||
import '../ui_components/outline-feedback-dialog';
|
||||
import '../ui_components/outline-share-dialog';
|
||||
import '../ui_components/outline-sort-span';
|
||||
|
|
@ -25,6 +26,9 @@ import '@polymer/paper-checkbox/paper-checkbox';
|
|||
import {PaperCheckboxElement} from '@polymer/paper-checkbox/paper-checkbox';
|
||||
import IntlMessageFormat from 'intl-messageformat';
|
||||
import {css, customElement, html, LitElement, property} from 'lit-element';
|
||||
|
||||
import * as gcp from '../../model/gcp';
|
||||
import {FakeGcpAccount} from '../testing/models';
|
||||
import {OutlinePerKeyDataLimitDialog} from '../ui_components/outline-per-key-data-limit-dialog';
|
||||
|
||||
async function makeLocalize(language: string) {
|
||||
|
|
@ -51,12 +55,22 @@ async function makeLocalize(language: string) {
|
|||
};
|
||||
}
|
||||
|
||||
const GCP_LOCATIONS: gcp.ZoneMap = {
|
||||
'us-central1': ['us-central1-a', 'us-central1-b', 'us-central1-c'],
|
||||
'asia-east1': ['asia-east1-a', 'asia-east1-b'],
|
||||
'europe-west1': ['europe-west1-a', 'europe-west1-b', 'europe-west1-c'],
|
||||
};
|
||||
const GCP_BILLING_ACCOUNTS: gcp.BillingAccount[] =
|
||||
[{id: '1234-123456', name: 'My Billing Account'}];
|
||||
|
||||
@customElement('outline-test-app')
|
||||
export class TestApp extends LitElement {
|
||||
@property({type: String}) dir = 'ltr';
|
||||
@property({type: Function}) localize: (...args: string[]) => string;
|
||||
@property({type: Boolean}) savePerKeyDataLimitSuccessful = true;
|
||||
@property({type: Number}) keyDataLimit: number|undefined;
|
||||
@property({type: String}) gcpRefreshToken = '';
|
||||
@property({type: Boolean}) gcpAccountHasBillingAccounts = false;
|
||||
private language = '';
|
||||
|
||||
static get styles() {
|
||||
|
|
@ -120,6 +134,22 @@ export class TestApp extends LitElement {
|
|||
<h1>Outline Manager Components Gallery</h1>
|
||||
${this.pageControls}
|
||||
|
||||
<div class="widget">
|
||||
<h2>outline-gcp-create-server-app</h2>
|
||||
<button @tap=${() => {
|
||||
const billingAccounts = this.gcpAccountHasBillingAccounts ? GCP_BILLING_ACCOUNTS : [];
|
||||
const account = new FakeGcpAccount('refresh-token', billingAccounts, GCP_LOCATIONS);
|
||||
this.select('outline-gcp-create-server-app').start(account);
|
||||
}}>
|
||||
Create server</button>
|
||||
<paper-checkbox
|
||||
?checked=${this.gcpAccountHasBillingAccounts}
|
||||
@tap=${() => this.gcpAccountHasBillingAccounts = !this.gcpAccountHasBillingAccounts}
|
||||
>Fake billing accounts</paper-checkbox>
|
||||
<outline-gcp-create-server-app .localize=${
|
||||
this.localize}></outline-gcp-create-server-app>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="widget"
|
||||
id="key-settings-widget"
|
||||
|
|
|
|||
|
|
@ -12,16 +12,302 @@
|
|||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import * as gcp_api from '../cloud/gcp_api';
|
||||
import {sleep} from '../infrastructure/sleep';
|
||||
import {SCRIPT} from '../install_scripts/gcp_install_script';
|
||||
import * as gcp from '../model/gcp';
|
||||
import {BillingAccount, Project} from '../model/gcp';
|
||||
import * as server from '../model/server';
|
||||
|
||||
import {GcpServer} from './gcp_server';
|
||||
|
||||
/**
|
||||
* The Google Cloud Platform account model.
|
||||
*/
|
||||
export class GcpAccount implements gcp.Account {
|
||||
constructor(private refreshToken: string) {}
|
||||
private static readonly OUTLINE_PROJECT_NAME = 'Outline servers';
|
||||
private static readonly OUTLINE_FIREWALL_NAME = 'outline';
|
||||
private static readonly MACHINE_SIZE = 'f1-micro';
|
||||
private static readonly REQUIRED_GCP_SERVICES = ['compute.googleapis.com'];
|
||||
|
||||
async getName(): Promise<string> {
|
||||
return 'placeholder';
|
||||
private readonly apiClient: gcp_api.RestApiClient;
|
||||
|
||||
constructor(private id: string, private refreshToken: string) {
|
||||
this.apiClient = new gcp_api.RestApiClient(refreshToken);
|
||||
}
|
||||
|
||||
getId(): string {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
/** @see {@link Account#getName}. */
|
||||
async getName(): Promise<string> {
|
||||
const userInfo = await this.apiClient.getUserInfo();
|
||||
return userInfo?.email;
|
||||
}
|
||||
|
||||
/** Returns the refresh token. */
|
||||
getRefreshToken(): string {
|
||||
return this.refreshToken;
|
||||
}
|
||||
|
||||
/** @see {@link Account#createServer}. */
|
||||
async createServer(projectId: string, name: string, zoneId: string):
|
||||
Promise<server.ManagedServer> {
|
||||
const instance = await this.createInstance(projectId, name, zoneId);
|
||||
const id = `${this.id}:${instance.id}`;
|
||||
return new GcpServer(id, projectId, instance, this.apiClient);
|
||||
}
|
||||
|
||||
/** @see {@link Account#listServers}. */
|
||||
async listServers(projectId: string): Promise<server.ManagedServer[]> {
|
||||
const result: GcpServer[] = [];
|
||||
const listZonesResponse = await this.apiClient.listZones(projectId);
|
||||
const listInstancesPromises = [];
|
||||
for (const zone of listZonesResponse.items) {
|
||||
const filter = 'labels.outline=true';
|
||||
const listInstancesPromise = this.apiClient.listInstances(projectId, zone.name, filter);
|
||||
listInstancesPromises.push(listInstancesPromise);
|
||||
}
|
||||
const listInstancesResponses = await Promise.all(listInstancesPromises);
|
||||
for (const response of listInstancesResponses) {
|
||||
const instances = response.items ?? [];
|
||||
instances.forEach((instance) => {
|
||||
const id = `${this.id}:${instance.id}`;
|
||||
const server = new GcpServer(id, projectId, instance, this.apiClient);
|
||||
result.push(server);
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** @see {@link Account#listLocations}. */
|
||||
async listLocations(projectId: string): Promise<gcp.ZoneMap> {
|
||||
const listZonesResponse = await this.apiClient.listZones(projectId);
|
||||
const zones = listZonesResponse.items ?? [];
|
||||
|
||||
const result: gcp.ZoneMap = {};
|
||||
zones.map((zone) => {
|
||||
const region = zone.region.substring(zone.region.lastIndexOf('/') + 1);
|
||||
if (!(region in result)) {
|
||||
result[region] = [];
|
||||
}
|
||||
if (zone.status === 'UP') {
|
||||
result[region].push(zone.name);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/** @see {@link Account#listProjects}. */
|
||||
async listProjects(): Promise<Project[]> {
|
||||
const filter = 'labels.outline=true AND lifecycleState=ACTIVE';
|
||||
const response = await this.apiClient.listProjects(filter);
|
||||
if (response.projects?.length > 0) {
|
||||
return response.projects.map(project => {
|
||||
return {
|
||||
id: project.projectId,
|
||||
name: project.name,
|
||||
};
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** @see {@link Account#createProject}. */
|
||||
async createProject(projectId: string, billingAccountId: string): Promise<Project> {
|
||||
// Create GCP project
|
||||
const createProjectData = {
|
||||
projectId,
|
||||
name: GcpAccount.OUTLINE_PROJECT_NAME,
|
||||
labels: {
|
||||
outline: 'true',
|
||||
},
|
||||
};
|
||||
const createProjectResponse = await this.apiClient.createProject(projectId, createProjectData);
|
||||
let createProjectOperation = null;
|
||||
while (!createProjectOperation?.done) {
|
||||
await sleep(2 * 1000);
|
||||
createProjectOperation =
|
||||
await this.apiClient.resourceManagerOperationGet(createProjectResponse.name);
|
||||
}
|
||||
if (createProjectOperation.error) {
|
||||
// TODO: Throw error. The project wasn't created so we should have nothing to delete.
|
||||
}
|
||||
|
||||
await this.configureProject(projectId, billingAccountId);
|
||||
|
||||
return {
|
||||
id: projectId,
|
||||
name: GcpAccount.OUTLINE_PROJECT_NAME,
|
||||
};
|
||||
}
|
||||
|
||||
async isProjectHealthy(projectId: string): Promise<boolean> {
|
||||
const projectBillingInfo = await this.apiClient.getProjectBillingInfo(projectId);
|
||||
if (!projectBillingInfo.billingEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const listEnabledServicesResponse = await this.apiClient.listEnabledServices(projectId);
|
||||
for (const requiredService of GcpAccount.REQUIRED_GCP_SERVICES) {
|
||||
const found = listEnabledServicesResponse.services.find(
|
||||
service => service.config.name === requiredService);
|
||||
if (!found) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async repairProject(projectId: string, billingAccountId: string): Promise<void> {
|
||||
return await this.configureProject(projectId, billingAccountId);
|
||||
}
|
||||
|
||||
/** @see {@link Account#listBillingAccounts}. */
|
||||
async listBillingAccounts(): Promise<BillingAccount[]> {
|
||||
const response = await this.apiClient.listBillingAccounts();
|
||||
if (response.billingAccounts?.length > 0) {
|
||||
return response.billingAccounts.map(billingAccount => {
|
||||
return {
|
||||
id: billingAccount.name.substring(billingAccount.name.lastIndexOf('/') + 1),
|
||||
name: billingAccount.displayName,
|
||||
};
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private async createInstance(projectId: string, name: string, zoneId: string):
|
||||
Promise<gcp_api.Instance> {
|
||||
// Configure Outline firewall
|
||||
const getFirewallResponse =
|
||||
await this.apiClient.listFirewalls(projectId, GcpAccount.OUTLINE_FIREWALL_NAME);
|
||||
if (!getFirewallResponse?.items || getFirewallResponse?.items?.length === 0) {
|
||||
const createFirewallData = this.makeCreateFirewallRequestData(name);
|
||||
const createFirewallOperation = await this.apiClient.createFirewall(projectId, createFirewallData);
|
||||
if (createFirewallOperation.error?.errors) {
|
||||
// TODO: Throw error.
|
||||
}
|
||||
}
|
||||
|
||||
// Create VM instance
|
||||
const createInstanceData = this.makeCreateInstanceRequestData(name, zoneId);
|
||||
const createInstanceOperation =
|
||||
await this.apiClient.createInstance(projectId, zoneId, createInstanceData);
|
||||
if (createInstanceOperation.error?.errors) {
|
||||
// TODO: Throw error.
|
||||
}
|
||||
|
||||
const instance =
|
||||
await this.apiClient.getInstance(projectId, createInstanceOperation.targetId, zoneId);
|
||||
|
||||
// Promote ephemeral IP to static IP
|
||||
const regionId = zoneId.substring(0, zoneId.lastIndexOf('-'));
|
||||
const ipAddress = instance.networkInterfaces[0].accessConfigs[0].natIP;
|
||||
const createStaticIpData = {
|
||||
name,
|
||||
address: ipAddress,
|
||||
};
|
||||
const createStaticIpOperation =
|
||||
await this.apiClient.createStaticIp(projectId, regionId, createStaticIpData);
|
||||
if (createStaticIpOperation.error?.errors) {
|
||||
// TODO: Delete VM instance. Throw error.
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
private async configureProject(projectId: string, billingAccountId: string): Promise<void> {
|
||||
// Link billing account
|
||||
const updateProjectBillingInfoData =
|
||||
this.makeUpdateProjectBillingInfoRequestData(projectId, billingAccountId);
|
||||
await this.apiClient.updateProjectBillingInfo(projectId, updateProjectBillingInfoData);
|
||||
|
||||
// Enable APIs
|
||||
const enableServicesData = {
|
||||
serviceIds: GcpAccount.REQUIRED_GCP_SERVICES,
|
||||
};
|
||||
const enableServicesResponse =
|
||||
await this.apiClient.enableServices(projectId, enableServicesData);
|
||||
let enableServicesOperation = null;
|
||||
while (!enableServicesOperation?.done) {
|
||||
await sleep(2 * 1000);
|
||||
enableServicesOperation =
|
||||
await this.apiClient.serviceUsageOperationGet(enableServicesResponse.name);
|
||||
}
|
||||
if (enableServicesResponse.error) {
|
||||
// TODO: Throw error.
|
||||
}
|
||||
}
|
||||
|
||||
private makeCreateFirewallRequestData(name: string): {} {
|
||||
return {
|
||||
name: GcpAccount.OUTLINE_FIREWALL_NAME,
|
||||
direction: 'INGRESS',
|
||||
priority: 1000,
|
||||
targetTags: [name],
|
||||
allowed: [
|
||||
{
|
||||
IPProtocol: 'all',
|
||||
},
|
||||
],
|
||||
sourceRanges: ['0.0.0.0/0'],
|
||||
};
|
||||
}
|
||||
|
||||
private makeCreateInstanceRequestData(name: string, zoneId: string): {} {
|
||||
const installScript = this.getInstallScript();
|
||||
return {
|
||||
name,
|
||||
machineType: `zones/${zoneId}/machineTypes/${GcpAccount.MACHINE_SIZE}`,
|
||||
disks: [
|
||||
{
|
||||
boot: true,
|
||||
initializeParams: {
|
||||
sourceImage: 'projects/ubuntu-os-cloud/global/images/family/ubuntu-1804-lts',
|
||||
},
|
||||
},
|
||||
],
|
||||
networkInterfaces: [
|
||||
{
|
||||
network: 'global/networks/default',
|
||||
// Empty accessConfigs necessary to allocate ephemeral IP
|
||||
accessConfigs: [{}],
|
||||
},
|
||||
],
|
||||
labels: {
|
||||
outline: 'true',
|
||||
},
|
||||
tags: {
|
||||
// This must match the firewall name.
|
||||
items: ['outline'],
|
||||
},
|
||||
metadata: {
|
||||
items: [
|
||||
{
|
||||
key: 'enable-guest-attributes',
|
||||
value: 'TRUE',
|
||||
},
|
||||
{
|
||||
key: 'user-data',
|
||||
value: installScript,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private makeUpdateProjectBillingInfoRequestData(projectId: string, billingAccountId: string): {} {
|
||||
return {
|
||||
name: `projects/${projectId}/billingInfo`,
|
||||
projectId,
|
||||
billingAccountName: `billingAccounts/${billingAccountId}`,
|
||||
};
|
||||
}
|
||||
|
||||
private getInstallScript(): string {
|
||||
return '#!/bin/bash -eu\n' + SCRIPT;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
122
src/server_manager/web_app/gcp_server.ts
Normal file
122
src/server_manager/web_app/gcp_server.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
// Copyright 2021 The Outline Authors
|
||||
//
|
||||
// Licensed 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.
|
||||
|
||||
import * as gcp_api from '../cloud/gcp_api';
|
||||
import * as errors from '../infrastructure/errors';
|
||||
import {sleep} from '../infrastructure/sleep';
|
||||
import * as server from '../model/server';
|
||||
import {DataAmount, ManagedServerHost, MonetaryCost} from '../model/server';
|
||||
|
||||
import {ShadowboxServer} from './shadowbox_server';
|
||||
|
||||
enum InstallState {
|
||||
// Unknown state - server may still be installing.
|
||||
UNKNOWN = 0,
|
||||
// Server is running and has the API URL and certificate fingerprint set.
|
||||
SUCCESS,
|
||||
// Server is in an error state.
|
||||
ERROR,
|
||||
// Server has been deleted.
|
||||
DELETED
|
||||
}
|
||||
|
||||
export class GcpServer extends ShadowboxServer implements server.ManagedServer {
|
||||
private static readonly GUEST_ATTRIBUTES_POLLING_INTERVAL_MS = 5 * 1000;
|
||||
|
||||
private readonly gcpHost: GcpHost;
|
||||
private installState: InstallState = InstallState.UNKNOWN;
|
||||
|
||||
constructor(
|
||||
id: string, private projectId: string, private instance: gcp_api.Instance,
|
||||
private apiClient: gcp_api.RestApiClient) {
|
||||
super(id);
|
||||
this.gcpHost = new GcpHost(projectId, instance, apiClient, this.onDelete.bind(this));
|
||||
}
|
||||
|
||||
getHost(): ManagedServerHost {
|
||||
return this.gcpHost;
|
||||
}
|
||||
|
||||
isInstallCompleted(): boolean {
|
||||
return this.installState !== InstallState.UNKNOWN;
|
||||
}
|
||||
|
||||
async waitOnInstall(): Promise<void> {
|
||||
while (this.installState === InstallState.UNKNOWN) {
|
||||
const zoneId = this.instance.zone.substring(this.instance.zone.lastIndexOf('/') + 1);
|
||||
const outlineGuestAttributes =
|
||||
await this.getOutlineGuestAttributes(this.projectId, this.instance.id, zoneId);
|
||||
if (outlineGuestAttributes.has('apiUrl') && outlineGuestAttributes.has('certSha256')) {
|
||||
const certSha256 = outlineGuestAttributes.get('certSha256');
|
||||
const apiUrl = outlineGuestAttributes.get('apiUrl');
|
||||
trustCertificate(certSha256);
|
||||
this.setManagementApiUrl(apiUrl);
|
||||
this.installState = InstallState.SUCCESS;
|
||||
} else if (outlineGuestAttributes.has('install-error')) {
|
||||
this.installState = InstallState.ERROR;
|
||||
throw new errors.ServerInstallFailedError();
|
||||
}
|
||||
|
||||
await sleep(GcpServer.GUEST_ATTRIBUTES_POLLING_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
private async getOutlineGuestAttributes(projectId: string, instanceId: string, zone: string):
|
||||
Promise<Map<string, string>> {
|
||||
const result = new Map<string, string>();
|
||||
const guestAttributes =
|
||||
await this.apiClient.getGuestAttributes(projectId, instanceId, zone, 'outline/');
|
||||
const attributes = guestAttributes?.queryValue?.items ?? [];
|
||||
attributes.forEach((entry) => {
|
||||
result.set(entry.key, entry.value);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private onDelete() {
|
||||
// TODO: Consider setInstallState.
|
||||
this.installState = InstallState.DELETED;
|
||||
}
|
||||
}
|
||||
|
||||
class GcpHost implements server.ManagedServerHost {
|
||||
constructor(
|
||||
private projectId: string, private instance: gcp_api.Instance,
|
||||
private apiClient: gcp_api.RestApiClient, private deleteCallback: Function) {}
|
||||
|
||||
// TODO: Throw error and show message on failure
|
||||
async delete(): Promise<void> {
|
||||
const zoneId = this.instance.zone.substring(this.instance.zone.lastIndexOf('/') + 1);
|
||||
const regionId = zoneId.substring(0, zoneId.lastIndexOf('-'));
|
||||
await this.apiClient.deleteStaticIp(this.projectId, this.instance.name, regionId);
|
||||
this.apiClient.deleteInstance(this.projectId, this.instance.id, zoneId);
|
||||
this.deleteCallback();
|
||||
}
|
||||
|
||||
getHostId(): string {
|
||||
return this.instance.id;
|
||||
}
|
||||
|
||||
getMonthlyCost(): MonetaryCost {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getMonthlyOutboundTransferLimit(): DataAmount {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getRegionId(): string {
|
||||
return this.instance.zone.substring(this.instance.zone.lastIndexOf('/') + 1);
|
||||
}
|
||||
}
|
||||
|
|
@ -25,7 +25,6 @@ export class FakeDigitalOceanAccount implements digitalocean.Account {
|
|||
getId(): string {
|
||||
return 'account-id';
|
||||
}
|
||||
|
||||
async getName(): Promise<string> {
|
||||
return 'fake-digitalocean-account-name';
|
||||
}
|
||||
|
|
@ -49,14 +48,43 @@ export class FakeDigitalOceanAccount implements digitalocean.Account {
|
|||
}
|
||||
|
||||
export class FakeGcpAccount implements gcp.Account {
|
||||
constructor(private refreshToken = 'fake-access-token') {}
|
||||
constructor(
|
||||
private refreshToken = 'fake-access-token',
|
||||
private billingAccounts: gcp.BillingAccount[] = [], private locations: gcp.ZoneMap = {}) {}
|
||||
|
||||
getId() {
|
||||
return 'id';
|
||||
}
|
||||
async getName(): Promise<string> {
|
||||
return 'fake-gcp-account-name';
|
||||
}
|
||||
getRefreshToken(): string {
|
||||
return this.refreshToken;
|
||||
}
|
||||
createServer(projectId: string, name: string, zoneId: string): Promise<server.ManagedServer> {
|
||||
return undefined;
|
||||
}
|
||||
async listLocations(projectId: string): Promise<Readonly<gcp.ZoneMap>> {
|
||||
return this.locations;
|
||||
}
|
||||
async listServers(projectId: string): Promise<server.ManagedServer[]> {
|
||||
return [];
|
||||
}
|
||||
async createProject(id: string, billingAccountId: string): Promise<gcp.Project> {
|
||||
return {
|
||||
id: 'project-id',
|
||||
name: 'project-name',
|
||||
};
|
||||
}
|
||||
async isProjectHealthy(projectId: string): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
async listBillingAccounts(): Promise<gcp.BillingAccount[]> {
|
||||
return this.billingAccounts;
|
||||
}
|
||||
async listProjects(): Promise<gcp.Project[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeServer implements server.Server {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import './cloud-install-styles.js';
|
|||
import './outline-about-dialog.js';
|
||||
import './outline-do-oauth-step.js';
|
||||
import './outline-gcp-oauth-step';
|
||||
import './outline-gcp-create-server-app';
|
||||
import './outline-feedback-dialog.js';
|
||||
import './outline-survey-dialog.js';
|
||||
import './outline-intro-step.js';
|
||||
|
|
@ -46,8 +47,6 @@ import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
|
|||
import {html} from '@polymer/polymer/lib/utils/html-tag.js';
|
||||
import {PolymerElement} from '@polymer/polymer/polymer-element.js';
|
||||
|
||||
import {displayDataAmountToBytes} from '../data_formatting';
|
||||
|
||||
import {ServerView} from './outline-server-view.js';
|
||||
|
||||
const TOS_ACK_LOCAL_STORAGE_KEY = 'tos-ack';
|
||||
|
|
@ -389,9 +388,10 @@ export class AppRoot extends mixinBehaviors
|
|||
<app-header-layout>
|
||||
<div class="app-container">
|
||||
<iron-pages attr-for-selected="id" selected="{{ currentPage }}">
|
||||
<outline-intro-step id="intro" digital-ocean-account-name="{{digitalOceanAccount.name}}" gcp-account-name="{{gcpAccountName}}" localize="[[localize]]"></outline-intro-step>
|
||||
<outline-intro-step id="intro" digital-ocean-account-name="{{digitalOceanAccount.name}}" gcp-account-name="{{gcpAccount.name}}" localize="[[localize]]"></outline-intro-step>
|
||||
<outline-do-oauth-step id="digitalOceanOauth" localize="[[localize]]"></outline-do-oauth-step>
|
||||
<outline-gcp-oauth-step id="gcpOauth" localize="[[localize]]"></outline-gcp-oauth-step>
|
||||
<outline-gcp-create-server-app id="gcpCreateServer" localize="[[localize]]"></outline-gcp-create-server-app>
|
||||
<outline-manual-server-entry id="manualEntry" localize="[[localize]]"></outline-manual-server-entry>
|
||||
<outline-region-picker-step id="regionPicker" localize="[[localize]]"></outline-region-picker-step>
|
||||
<outline-server-list id="serverView" server-list="[[serverList]]" selected-server-id="[[selectedServerId]]" language="[[language]]" localize="[[localize]]"></outline-server-list>
|
||||
|
|
@ -461,7 +461,7 @@ export class AppRoot extends mixinBehaviors
|
|||
<div class="do-overflow-menu" slot="dropdown-content">
|
||||
<h4>[[localize('digitalocean-disconnect-account')]]</h4>
|
||||
<div class="account-info"><img src="images/digital_ocean_logo.svg">[[digitalOceanAccount.name]]</div>
|
||||
<div class="sign-out-button" on-tap="signOutTapped">[[localize('digitalocean-disconnect')]]</div>
|
||||
<div class="sign-out-button" on-tap="_digitalOceanSignOutTapped">[[localize('digitalocean-disconnect')]]</div>
|
||||
</div>
|
||||
</paper-menu-button>
|
||||
</div>
|
||||
|
|
@ -474,7 +474,28 @@ export class AppRoot extends mixinBehaviors
|
|||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<!-- TODO(fortuna): Insert GCP servers here -->
|
||||
<!-- GCP servers -->
|
||||
<div class="servers-section" hidden\$="[[!gcpAccount]]">
|
||||
<div class="servers-header">
|
||||
<span>[[localize('servers-gcp')]]</span>
|
||||
<paper-menu-button horizontal-align="left" class="" close-on-activate="" no-animations="" dynamic-align="" no-overlap="">
|
||||
<paper-icon-button icon="more-vert" slot="dropdown-trigger"></paper-icon-button>
|
||||
<div class="do-overflow-menu" slot="dropdown-content">
|
||||
<h4>[[localize('gcp-disconnect-account')]]</h4>
|
||||
<div class="account-info"><img src="images/gcp-logo.svg">[[gcpAccount.name]]</div>
|
||||
<div class="sign-out-button" on-tap="_gcpSignOutTapped">[[localize('gcp-disconnect')]]</div>
|
||||
</div>
|
||||
</paper-menu-button>
|
||||
</div>
|
||||
<div class="servers-container">
|
||||
<template is="dom-repeat" items="[[serverList]]" as="server" filter="[[_accountServerFilter(gcpAccount)]]" sort="_sortServersByName">
|
||||
<div class\$="server [[_computeServerClasses(selectedServerId, server)]]" data-server\$="[[server]]" on-tap="_showServer">
|
||||
<img class="server-icon" src\$="images/[[_computeServerImage(selectedServerId, server)]]">
|
||||
<span>[[server.name]]</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Manual servers -->
|
||||
<div class="servers-section" hidden\$="[[!_hasManualServers(serverList)]]">
|
||||
<div class="servers-header">
|
||||
|
|
@ -503,7 +524,15 @@ export class AppRoot extends mixinBehaviors
|
|||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- TODO(fortuna): Insert GCP servers here -->
|
||||
<!-- GCP servers -->
|
||||
<div class="side-bar-section servers-section" hidden\$="[[!gcpAccount]]">
|
||||
<img class="provider-icon" src="images/gcp-logo.svg">
|
||||
<template is="dom-repeat" items="[[serverList]]" as="server" filter="[[_accountServerFilter(gcpAccount)]]" sort="_sortServersByName">
|
||||
<div class\$="server [[_computeServerClasses(selectedServerId, server)]]" data-server\$="[[server]]" on-tap="_showServer">
|
||||
<img class="server-icon" src\$="images/[[_computeServerImage(selectedServerId, server)]]">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Manual servers -->
|
||||
<div class="side-bar-section servers-section" hidden\$="[[!_hasManualServers(serverList)]]">
|
||||
<img class="provider-icon" src="images/cloud.svg">
|
||||
|
|
@ -650,6 +679,12 @@ export class AppRoot extends mixinBehaviors
|
|||
return oauthFlow;
|
||||
}
|
||||
|
||||
/** @return {GcpCreateServerApp} */
|
||||
getAndShowGcpCreateServerApp() {
|
||||
this.currentPage = 'gcpCreateServer';
|
||||
return this.$.gcpCreateServer;
|
||||
}
|
||||
|
||||
getAndShowRegionPicker() {
|
||||
this.currentPage = 'regionPicker';
|
||||
this.$.regionPicker.reset();
|
||||
|
|
@ -817,8 +852,12 @@ export class AppRoot extends mixinBehaviors
|
|||
this.maybeCloseDrawer();
|
||||
}
|
||||
|
||||
signOutTapped() {
|
||||
this.fire('SignOutRequested');
|
||||
_digitalOceanSignOutTapped() {
|
||||
this.fire('DigitalOceanSignOutRequested');
|
||||
}
|
||||
|
||||
_gcpSignOutTapped() {
|
||||
this.fire('GcpSignOutRequested');
|
||||
}
|
||||
|
||||
openManualInstallFeedback(/** @type {string} */ prepopulatedMessage) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,408 @@
|
|||
// Copyright 2021 The Outline Authors
|
||||
//
|
||||
// Licensed 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.
|
||||
|
||||
import '@polymer/paper-dropdown-menu/paper-dropdown-menu.js';
|
||||
import '@polymer/paper-listbox/paper-listbox.js';
|
||||
import '@polymer/paper-input/paper-input.js';
|
||||
import '@polymer/paper-item/paper-item.js';
|
||||
import './outline-region-picker-step';
|
||||
|
||||
import {css, customElement, html, internalProperty, LitElement, property} from 'lit-element';
|
||||
|
||||
import {BillingAccount, Project} from '../../model/gcp';
|
||||
import {GcpAccount} from '../gcp_account';
|
||||
import {COMMON_STYLES} from './cloud-install-styles';
|
||||
import {Location, OutlineRegionPicker} from './outline-region-picker-step';
|
||||
|
||||
// TODO: Map region ids to country codes.
|
||||
/** @see https://cloud.google.com/compute/docs/regions-zones */
|
||||
const LOCATION_MAP = new Map<string, string>([
|
||||
['asia-east1', 'Changhua County, Taiwan'],
|
||||
['asia-east2', 'Hong Kong'],
|
||||
['asia-northeast1', 'Tokyo, Japan'],
|
||||
['asia-northeast2', 'Osaka, Japan'],
|
||||
['asia-northeast3', 'Seoul, South Korea'],
|
||||
['asia-south1', 'Mumbai, India'],
|
||||
['asia-southeast1', 'Jurong West, Singapore'],
|
||||
['asia-southeast2', 'Jakarta, Indonesia'],
|
||||
['australia-southeast1', 'Sydney, Australia'],
|
||||
['europe-north1', 'Hamina, Finland'],
|
||||
['europe-west1', 'St. Ghislain, Belgium'],
|
||||
['europe-west2', 'London, England, UK'],
|
||||
['europe-west3', 'Frankfurt, Germany'],
|
||||
['europe-west4', 'Eemshaven, Netherlands'],
|
||||
['europe-west6', 'Zürich, Switzerland'],
|
||||
['europe-central2', 'Warsaw, Poland, Europe'],
|
||||
['northamerica-northeast1', 'Montréal, Québec, Canada'],
|
||||
['southamerica-east1', 'Osasco (São Paulo), Brazil'],
|
||||
['us-central1', 'Council Bluffs, Iowa, USA'],
|
||||
['us-east1', 'Moncks Corner, South Carolina, USA'],
|
||||
['us-east4', 'Ashburn, Northern Virginia, USA'],
|
||||
['us-west1', 'The Dalles, Oregon, USA'],
|
||||
['us-west2', 'Los Angeles, California, USA'],
|
||||
['us-west3', 'Salt Lake City, Utah, USA'],
|
||||
['us-west4', 'Las Vegas, Nevada, USA'],
|
||||
]);
|
||||
|
||||
// GCP mapping of regions to flags
|
||||
const FLAG_IMAGE_DIR = 'images/flags';
|
||||
const GCP_FLAG_MAPPING: {[regionId: string]: string} = {
|
||||
// 'asia-east1': `${FLAG_IMAGE_DIR}/unknown.png`,
|
||||
// 'asia-east2': `${FLAG_IMAGE_DIR}/unknown.png`,
|
||||
// 'asia-northeast1': `${FLAG_IMAGE_DIR}/unknown.png`,
|
||||
// 'asia-northeast2': `${FLAG_IMAGE_DIR}/unknown.png`,
|
||||
// 'asia-northeast3': `${FLAG_IMAGE_DIR}/unknown.png`,
|
||||
'asia-south1': `${FLAG_IMAGE_DIR}/india.png`,
|
||||
'asia-southeast1': `${FLAG_IMAGE_DIR}/singapore.png`,
|
||||
// 'asia-southeast2': `${FLAG_IMAGE_DIR}/unknown.png`,
|
||||
// 'australia-southeast1': `${FLAG_IMAGE_DIR}/unknown.png`,
|
||||
// 'europe-north1': `${FLAG_IMAGE_DIR}/unknown.png`,
|
||||
// 'europe-west1': `${FLAG_IMAGE_DIR}/unknown.png`,
|
||||
'europe-west2': `${FLAG_IMAGE_DIR}/uk.png`,
|
||||
'europe-west3': `${FLAG_IMAGE_DIR}/germany.png`,
|
||||
'europe-west4': `${FLAG_IMAGE_DIR}/netherlands.png`,
|
||||
// 'europe-west6': `${FLAG_IMAGE_DIR}/unknown.png`,
|
||||
// 'europe-central2': `${FLAG_IMAGE_DIR}/unknown.png`,
|
||||
'northamerica-northeast1': `${FLAG_IMAGE_DIR}/canada.png`,
|
||||
// 'southamerica-east1': `${FLAG_IMAGE_DIR}/unknown.png`,
|
||||
'us-central1': `${FLAG_IMAGE_DIR}/us.png`,
|
||||
'us-east1': `${FLAG_IMAGE_DIR}/us.png`,
|
||||
'us-east4': `${FLAG_IMAGE_DIR}/us.png`,
|
||||
'us-west1': `${FLAG_IMAGE_DIR}/us.png`,
|
||||
'us-west2': `${FLAG_IMAGE_DIR}/us.png`,
|
||||
'us-west3': `${FLAG_IMAGE_DIR}/us.png`,
|
||||
'us-west4': `${FLAG_IMAGE_DIR}/us.png`,
|
||||
};
|
||||
|
||||
// TODO: Handle network and authentication errors
|
||||
@customElement('outline-gcp-create-server-app')
|
||||
export class GcpCreateServerApp extends LitElement {
|
||||
@property({type: Function}) localize: Function;
|
||||
@internalProperty() private currentPage = '';
|
||||
@internalProperty() private selectedProjectId = '';
|
||||
@internalProperty() private selectedBillingAccountId = '';
|
||||
@internalProperty() private isProjectBeingCreated = false;
|
||||
|
||||
private account: GcpAccount;
|
||||
private project: Project;
|
||||
private billingAccounts: BillingAccount[] = [];
|
||||
private regionPicker: OutlineRegionPicker;
|
||||
|
||||
static get styles() {
|
||||
return [
|
||||
COMMON_STYLES, css`
|
||||
:host {
|
||||
--paper-input-container-input-color: var(--medium-gray);
|
||||
}
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
padding: 132px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: space-between;
|
||||
margin: 24px 0;
|
||||
padding: 24px;
|
||||
background: var(--background-contrast-color);
|
||||
box-shadow: 0 0 2px 0 rgba(0, 0, 0, 0.14), 0 2px 2px 0 rgba(0, 0, 0, 0.12), 0 1px 3px 0 rgba(0, 0, 0, 0.2);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.section {
|
||||
padding: 24px 12px;
|
||||
color: var(--light-gray);
|
||||
background: var(--background-contrast-color);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.section:not(:first-child) {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.section-header {
|
||||
padding: 0 6px 0;
|
||||
display: flex;
|
||||
}
|
||||
.section-content {
|
||||
padding: 0 48px;
|
||||
}
|
||||
.instructions {
|
||||
font-size: 16px;
|
||||
line-height: 26px;
|
||||
margin-left: 16px;
|
||||
flex: 2;
|
||||
}
|
||||
.stepcircle {
|
||||
height: 26px;
|
||||
width: 26px;
|
||||
font-size: 14px;
|
||||
border-radius: 50%;
|
||||
float: left;
|
||||
vertical-align: middle;
|
||||
color: #000;
|
||||
background-color: #fff;
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
line-height: 26px;
|
||||
}
|
||||
@media (min-width: 1025px) {
|
||||
paper-card {
|
||||
/* Set min with for the paper-card to grow responsively. */
|
||||
min-width: 600px;
|
||||
}
|
||||
}
|
||||
.card p {
|
||||
color: var(--light-gray);
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
#projectName {
|
||||
width: 250px;
|
||||
}
|
||||
#billingAccount {
|
||||
width: 250px;
|
||||
}
|
||||
paper-button {
|
||||
background: var(--primary-green);
|
||||
color: var(--light-gray);
|
||||
width: 100%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 2px;
|
||||
}
|
||||
paper-button[disabled] {
|
||||
color: var(--medium-gray);
|
||||
background: transparent;
|
||||
}`
|
||||
];
|
||||
}
|
||||
|
||||
render() {
|
||||
switch (this.currentPage) {
|
||||
case 'billingAccountSetup':
|
||||
return this.renderBillingAccountSetup();
|
||||
case 'projectSetup':
|
||||
return this.renderProjectSetup();
|
||||
case 'regionPicker':
|
||||
return this.renderRegionPicker();
|
||||
default: {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private renderBillingAccountSetup() {
|
||||
return html`
|
||||
<outline-step-view id="billingAccountSetup" display-action="">
|
||||
<span slot="step-title">Activate your Google Cloud Platform account.</span>
|
||||
<span slot="step-description">Enter your billing information on Google Cloud Platform.</span>
|
||||
<span slot="step-action">
|
||||
<paper-button id="createServerButton" @tap="${this.handleBillingVerificationNextTap}">
|
||||
NEXT
|
||||
</paper-button>
|
||||
</span>
|
||||
<paper-card class="card">
|
||||
<div class="container">
|
||||
<img src="images/do_oauth_billing.svg">
|
||||
<p>Enter you billing information on Google Cloud Platform</p>
|
||||
<!-- TODO: Add call to action to open GCP billing accounts page -->
|
||||
<!-- https://console.cloud.google.com/billing -->
|
||||
</div>
|
||||
</paper-card>
|
||||
</outline-step-view>`;
|
||||
}
|
||||
|
||||
private renderProjectSetup() {
|
||||
return html`
|
||||
<outline-step-view id="projectSetup" display-action="">
|
||||
<span slot="step-title">Create your Google Cloud Platform project.</span>
|
||||
<span slot="step-description">This will create a new project on your GCP account to hold your Outline servers.</span>
|
||||
<span slot="step-action">
|
||||
<paper-button
|
||||
id="createServerButton"
|
||||
@tap="${this.handleProjectSetupNextTap}"
|
||||
?disabled="${
|
||||
!this.isProjectSetupNextEnabled(this.selectedProjectId, this.selectedBillingAccountId)}">
|
||||
CREATE PROJECT
|
||||
</paper-button>
|
||||
</span>
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<span class="stepcircle">1</span>
|
||||
<div class="instructions">
|
||||
Name your new Google Cloud Project
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-content">
|
||||
<!-- TODO: Make readonly if project already exists -->
|
||||
<paper-input id="projectName" value="${this.selectedProjectId}"
|
||||
label="Project ID" always-float-label="" maxlength="100" @value-changed="${
|
||||
this.onProjectIdChanged}"></paper-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<span class="stepcircle">2</span>
|
||||
<div class="instructions">
|
||||
Choose your preferred billing method for this project
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-content">
|
||||
<paper-dropdown-menu id="billingAccount" no-label-float="">
|
||||
<paper-listbox slot="dropdown-content" selected="${
|
||||
this.selectedBillingAccountId}" attr-for-selected="name" @selected-changed="${
|
||||
this.onBillingAccountSelected}">
|
||||
${this.billingAccounts.map(billingAccount => {
|
||||
return html`<paper-item name="${billingAccount.id}">${billingAccount.name}</paper-item>`;
|
||||
})}
|
||||
</paper-listbox>
|
||||
</paper-dropdown-menu>
|
||||
</div>
|
||||
</div>
|
||||
${
|
||||
this.isProjectBeingCreated ?
|
||||
html`<paper-progress indeterminate="" class="slow"></paper-progress>` :
|
||||
''}
|
||||
</outline-step-view>`;
|
||||
}
|
||||
|
||||
private renderRegionPicker() {
|
||||
return html`
|
||||
<outline-region-picker-step id="regionPicker" .localize=${this.localize} @RegionSelected="${
|
||||
this.onRegionSelected}">
|
||||
</outline-region-picker-step>`;
|
||||
}
|
||||
|
||||
async start(account: GcpAccount): Promise<void> {
|
||||
this.init();
|
||||
this.account = account;
|
||||
|
||||
this.billingAccounts = await this.account.listBillingAccounts();
|
||||
const projects = await this.account.listProjects();
|
||||
// TODO: We don't support multiple projects atm, but we will want to allow
|
||||
// the user to choose the appropriate one.
|
||||
this.project = projects?.[0];
|
||||
const isProjectHealthy =
|
||||
this.project ? await this.account.isProjectHealthy(this.project.id) : false;
|
||||
if (this.project && isProjectHealthy) {
|
||||
this.showRegionPicker();
|
||||
} else {
|
||||
if (!this.billingAccounts || this.billingAccounts.length === 0) {
|
||||
this.showBillingAccountSetup();
|
||||
} else {
|
||||
this.showProjectSetup(this.project);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private init() {
|
||||
this.currentPage = '';
|
||||
this.selectedProjectId = '';
|
||||
this.selectedBillingAccountId = '';
|
||||
}
|
||||
|
||||
private showBillingAccountSetup(): void {
|
||||
this.currentPage = 'billingAccountSetup';
|
||||
}
|
||||
|
||||
private async handleBillingVerificationNextTap(): Promise<void> {
|
||||
this.showProjectSetup();
|
||||
}
|
||||
|
||||
private async showProjectSetup(existingProject?: Project): Promise<void> {
|
||||
this.billingAccounts = await this.account.listBillingAccounts();
|
||||
if (!this.billingAccounts || this.billingAccounts.length === 0) {
|
||||
return this.showBillingAccountSetup();
|
||||
}
|
||||
|
||||
this.project = existingProject ?? null;
|
||||
this.selectedProjectId = this.project?.id ?? this.makeProjectName();
|
||||
this.selectedBillingAccountId = this.billingAccounts[0].id;
|
||||
this.currentPage = 'projectSetup';
|
||||
}
|
||||
|
||||
private isProjectSetupNextEnabled(projectId: string, billingAccountId: string): boolean {
|
||||
// TODO: Proper validation
|
||||
return projectId !== '' && billingAccountId !== '';
|
||||
}
|
||||
|
||||
private async handleProjectSetupNextTap(): Promise<void> {
|
||||
this.isProjectBeingCreated = true;
|
||||
if (!this.project) {
|
||||
this.project =
|
||||
await this.account.createProject(this.selectedProjectId, this.selectedBillingAccountId);
|
||||
} else {
|
||||
await this.account.repairProject(this.project.id, this.selectedBillingAccountId);
|
||||
}
|
||||
this.isProjectBeingCreated = false;
|
||||
|
||||
this.showRegionPicker();
|
||||
}
|
||||
|
||||
private async showRegionPicker(): Promise<void> {
|
||||
const isProjectHealthy = await this.account.isProjectHealthy(this.project.id);
|
||||
if (!isProjectHealthy) {
|
||||
return this.showProjectSetup();
|
||||
}
|
||||
|
||||
this.currentPage = 'regionPicker';
|
||||
const regionMap = await this.account.listLocations(this.project.id);
|
||||
const locations = Object.entries(regionMap).map(([regionId, zoneIds]) => {
|
||||
return this.createLocationModel(regionId, zoneIds);
|
||||
});
|
||||
this.regionPicker = this.shadowRoot.querySelector('#regionPicker') as OutlineRegionPicker;
|
||||
this.regionPicker.locations = locations;
|
||||
}
|
||||
|
||||
private onProjectIdChanged(event: CustomEvent) {
|
||||
this.selectedProjectId = event.detail.value;
|
||||
}
|
||||
private onBillingAccountSelected(event: CustomEvent) {
|
||||
this.selectedBillingAccountId = event.detail.value;
|
||||
}
|
||||
|
||||
private async onRegionSelected(event: CustomEvent) {
|
||||
event.stopPropagation();
|
||||
|
||||
this.regionPicker.isServerBeingCreated = true;
|
||||
const name = this.makeServerName();
|
||||
const server =
|
||||
await this.account.createServer(this.project.id, name, event.detail.selectedRegionId);
|
||||
const params = {bubbles: true, composed: true, detail: {server}};
|
||||
const serverCreatedEvent = new CustomEvent('GcpServerCreated', params);
|
||||
this.dispatchEvent(serverCreatedEvent);
|
||||
}
|
||||
|
||||
private createLocationModel(regionId: string, zoneIds: string[]): Location {
|
||||
return {
|
||||
id: zoneIds.length > 0 ? zoneIds[0] : null,
|
||||
name: LOCATION_MAP.get(regionId) ?? regionId,
|
||||
flag: GCP_FLAG_MAPPING[regionId] || `${FLAG_IMAGE_DIR}/unknown.png`,
|
||||
available: zoneIds.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
private makeProjectName(): string {
|
||||
return `outline-${Math.random().toString(20).substring(3)}`;
|
||||
}
|
||||
|
||||
private makeServerName(): string {
|
||||
const now = new Date();
|
||||
return `outline-${now.getFullYear()}${now.getMonth()}${now.getDate()}-${now.getUTCHours()}${
|
||||
now.getUTCMinutes()}${now.getUTCSeconds()}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -181,38 +181,39 @@ Polymer({
|
|||
<div class="container">
|
||||
<div id="digital-ocean" class="card" on-tap="connectToDigitalOceanTapped">
|
||||
<div class="card-header">
|
||||
<div class="tag" hidden\$="{{isDigitalOceanAccountConnected}}">[[localize('setup-recommended')]]</div>
|
||||
<div class="email" hidden\$="{{!isDigitalOceanAccountConnected}}">{{digitalOceanAccountName}}</div>
|
||||
<div class="tag" hidden\$="[[_computeIsAccountConnected(digitalOceanAccountName)]]">[[localize('setup-recommended')]]</div>
|
||||
<div class="email" hidden\$="[[!_computeIsAccountConnected(digitalOceanAccountName)]]">[[digitalOceanAccountName]]</div>
|
||||
<img src="images/do_white_logo.svg">
|
||||
</div>
|
||||
<div class="card-title">DigitalOcean</div>
|
||||
<div class="card-body">
|
||||
<div class="description">
|
||||
<ul hidden\$="{{isDigitalOceanAccountConnected}}">
|
||||
<ul hidden\$="[[_computeIsAccountConnected(digitalOceanAccountName)]]">
|
||||
<li>[[localize('setup-do-easiest')]]</li>
|
||||
<li>[[localize('setup-do-cost')]]</li>
|
||||
<li>[[localize('setup-do-data')]]</li>
|
||||
<li>[[localize('setup-do-cancel')]]</li>
|
||||
</ul>
|
||||
<p hidden\$="{{!isDigitalOceanAccountConnected}}">
|
||||
<p hidden\$="[[!_computeIsAccountConnected(digitalOceanAccountName)]]">
|
||||
[[localize('setup-do-create')]]
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<paper-button class="primary" hidden\$="{{isDigitalOceanAccountConnected}}">[[localize('setup-action')]]</paper-button>
|
||||
<paper-button class="primary" hidden\$="{{!isDigitalOceanAccountConnected}}">[[localize('setup-create')]]</paper-button>
|
||||
<paper-button class="primary" hidden\$="[[_computeIsAccountConnected(digitalOceanAccountName)]]">[[localize('setup-action')]]</paper-button>
|
||||
<paper-button class="primary" hidden\$="[[!_computeIsAccountConnected(digitalOceanAccountName)]]">[[localize('setup-create')]]</paper-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="gcp" class="card" on-tap="setUpGcpTapped">
|
||||
<div class="card-header">
|
||||
<div class="tag">[[localize('setup-advanced')]]</div>
|
||||
<div class="tag" hidden\$="[[_computeIsAccountConnected(gcpAccountName)]]">[[localize('setup-advanced')]]</div>
|
||||
<div class="email" hidden\$="[[!_computeIsAccountConnected(gcpAccountName)]]">[[gcpAccountName]]</div>
|
||||
<img src="images/gcp-logo.svg">
|
||||
</div>
|
||||
<div class="card-title">Google Cloud Platform</div>
|
||||
<div class="card-body">
|
||||
<div class="description">
|
||||
<div class="description" hidden\$="[[_computeIsAccountConnected(gcpAccountName)]]">
|
||||
<ul>
|
||||
<li>[[localize('setup-step-by-step')]]</li>
|
||||
<li>[[localize('setup-firewall-instructions')]]</li>
|
||||
|
|
@ -221,7 +222,8 @@ Polymer({
|
|||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<paper-button on-tap="setUpGcpTapped" class="primary">[[localize('setup-action')]]</paper-button>
|
||||
<paper-button class="primary" hidden\$="[[_computeIsAccountConnected(gcpAccountName)]]">[[localize('setup-action')]]</paper-button>
|
||||
<paper-button class="primary" hidden\$="[[!_computeIsAccountConnected(gcpAccountName)]]">[[localize('setup-create')]]</paper-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -274,10 +276,6 @@ Polymer({
|
|||
type: String,
|
||||
value: null,
|
||||
},
|
||||
isDigitalOceanAccountConnected: {
|
||||
type: Boolean,
|
||||
computed: '_computeIsDigitalOceanAccountConnected(digitalOceanAccountName)',
|
||||
},
|
||||
gcpAccountName: {
|
||||
type: String,
|
||||
value: null,
|
||||
|
|
@ -288,12 +286,12 @@ Polymer({
|
|||
},
|
||||
},
|
||||
|
||||
_computeIsDigitalOceanAccountConnected(digitalOceanAccountName) {
|
||||
return Boolean(digitalOceanAccountName);
|
||||
_computeIsAccountConnected(accountName) {
|
||||
return Boolean(accountName);
|
||||
},
|
||||
|
||||
connectToDigitalOceanTapped: function() {
|
||||
if (this.isDigitalOceanAccountConnected) {
|
||||
if (this.digitalOceanAccountName) {
|
||||
this.fire('CreateDigitalOceanServerRequested');
|
||||
} else {
|
||||
this.fire('ConnectDigitalOceanAccountRequested');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue