All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.google.appengine.tools.remoteapi.RemoteApiInstaller Maven / Gradle / Ivy

/*
 * Copyright 2021 Google LLC
 *
 * 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
 *
 *     https://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.
 */

package com.google.appengine.tools.remoteapi;

import com.google.apphosting.api.ApiProxy;
import com.google.apphosting.api.ApiProxy.Delegate;
import com.google.apphosting.api.ApiProxy.Environment;
import com.google.apphosting.api.ApiProxy.EnvironmentFactory;
import com.google.common.collect.ImmutableMap;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.logging.ConsoleHandler;
import java.util.logging.Formatter;
import java.util.logging.Level;
import java.util.logging.LogRecord;
import java.util.logging.Logger;
import java.util.logging.StreamHandler;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.http.cookie.Cookie;
import org.apache.http.impl.cookie.BasicClientCookie;
import org.checkerframework.checker.nullness.qual.Nullable;

/**
 * Installs and uninstalls the remote API. While the RemoteApi is installed, all App Engine calls
 * made by the same thread that performed the installation will be sent to a remote server.
 *
 * 

Instances of this class can only be used on a single thread. */ // NOTE: This class must be kept in sync with // InternalRemoteApiInstaller according to the very long comment in that class. public class RemoteApiInstaller { // Matches entries in a YAML map of app properties returned by the server. // Currently, apps running on Google return quoted values, and their IDs are more complex. // // Appspot Examples: Google Examples: // ~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // app_id: appengineapp app_id: 's~google.com:google_app_id' // rtok: 0 rtok: '0' private static final Pattern PAIR_REGEXP = Pattern.compile("([a-z0-9_-]+): +(['\\\"]?)([:~.a-z0-9_-]+)\\2"); /** * A key that can be put into {@link Environment#getAttributes()} to override the app id used by * the Datastore API. Note that this is copied from * com.google.appengine.api.datastore.DatastoreApiHelper to avoid a dependency on that class. It * must be kept in sync. */ // TODO: Can we just depend on that class? static final String DATASTORE_APP_ID_OVERRIDE_KEY = "com.google.appengine.datastore.AppIdOverride"; // Lazy load this because we might be running inside App Engine and this // class isn't on the allowlist. private static ConsoleHandler remoteMethodHandler; // TODO: Support something other than console handler for when we're // running in App Engine. private static synchronized StreamHandler getStreamHandler() { if (remoteMethodHandler == null) { remoteMethodHandler = new ConsoleHandler(); remoteMethodHandler.setFormatter(new Formatter() { @Override public String format(LogRecord record) { return record.getMessage() + "\n"; } }); remoteMethodHandler.setLevel(Level.FINE); } return remoteMethodHandler; } private InstallerState installerState; private static boolean installedForAllThreads = false; /* @VisibleForTesting */ void validateOptions(RemoteApiOptions options) { if (options.getHostname() == null) { throw new IllegalArgumentException("server not set in options"); } if (options.getUserEmail() == null && options.getOAuthCredential() == null) { throw new IllegalArgumentException("credentials not set in options"); } } private boolean installed() { return installerState != null || installedForAllThreads; } /** * Installs the remote API on all threads using the provided options. Logs * into the remote application using the credentials available via these * options. * *

Note that if installed using this method, the remote API cannot be * uninstalled.

* * @throws IllegalArgumentException if the server or credentials weren't provided. * @throws IllegalStateException if already installed * @throws LoginException if unable to log in. * @throws IOException if unable to connect to the remote API. */ public void installOnAllThreads(RemoteApiOptions options) throws IOException { final RemoteApiOptions finalOptions = options.copy(); validateOptions(finalOptions); // Synchronize on the class because we're manipulating global state (ApiProxy.Delegate). synchronized (getClass()) { if (installed()) { throw new IllegalStateException("remote API is already installed"); } installedForAllThreads = true; final RemoteApiClient client = login(finalOptions); @SuppressWarnings("unchecked") Delegate originalDelegate = ApiProxy.getDelegate(); RemoteApiDelegate globalRemoteApiDelegate = createDelegate(finalOptions, client, originalDelegate); ApiProxy.setDelegate(globalRemoteApiDelegate); // Single-thread install has the ability to leave the existing environment // in place and simply override the app id. That functionality is not // supported here. ApiProxy.setEnvironmentFactory(new EnvironmentFactory() { @Override public Environment newEnvironment() { return createEnv(finalOptions, client); } }); } } /** * Installs the remote API using the provided options. Logs into the remote * application using the credentials available via these options. * *

Warning: This method only installs the remote API on the current * thread. Do not share this instance across threads!

* * @throws IllegalArgumentException if the server or credentials weren't provided. * @throws IllegalStateException if already installed * @throws LoginException if unable to log in. * @throws IOException if unable to connect to the remote API. */ public void install(RemoteApiOptions options) throws IOException { options = options.copy(); validateOptions(options); // Synchronize on the class because we're manipulating global state (ApiProxy.Delegate). synchronized (getClass()) { if (installed()) { throw new IllegalStateException("remote API is already installed"); } // Need to be careful about how we transition from not needing an uninstall // to needing an uninstall. We want the installation to either completely // succeed or completely fail, so we'll gather up everything we need in // local vars, and then once we have everything we need we'll mutate our // state. That way if anything goes wrong before we're not stuck in an // inconsistent state. @SuppressWarnings("unchecked") Delegate originalDelegate = ApiProxy.getDelegate(); Environment originalEnv = ApiProxy.getCurrentEnvironment(); RemoteApiClient installedClient = login(options); RemoteApiDelegate remoteApiDelegate; if (originalDelegate instanceof ThreadLocalDelegate) { ThreadLocalDelegate installedDelegate = (ThreadLocalDelegate) originalDelegate; Delegate globalDelegate = installedDelegate.getGlobalDelegate(); remoteApiDelegate = createDelegate(options, installedClient, globalDelegate); // We've already got a ThreadLocalDelegate so just install the remote // api delegate for the current thread. if (installedDelegate.getDelegateForThread() != null) { throw new IllegalStateException("remote API is already installed"); } installedDelegate.setDelegateForThread(remoteApiDelegate); } else { remoteApiDelegate = createDelegate(options, installedClient, originalDelegate); // Current delegate is not a ThreadLocalDelegate. ApiProxy.setDelegate(new ThreadLocalDelegate( originalDelegate, remoteApiDelegate)); } Environment installedEnv = null; String appIdOverrideToRestore = null; if (originalEnv == null) { installedEnv = createEnv(options, installedClient); ApiProxy.setEnvironmentForCurrentThread(installedEnv); } else { // Add a marker to the existing Environment to cause the Datastore api to use the remote app // id. We'll stash the value that was there in order to restore it later. (Although we // don't expect anyone other than the remote api to be using this unadvertised feature.) appIdOverrideToRestore = (String) originalEnv.getAttributes().get(DATASTORE_APP_ID_OVERRIDE_KEY); originalEnv.getAttributes().put(DATASTORE_APP_ID_OVERRIDE_KEY, installedClient.getAppId()); } // Installation of the delegate is the point of no return installerState = new InstallerState( originalEnv, installedClient, remoteApiDelegate, installedEnv, appIdOverrideToRestore); } } /** * The state related to the installation of a {@link RemoteApiInstaller}. * It's just a struct, but it makes it easy for us to ensure that we don't * end up in an inconsistent state when installation fails part-way through. */ private static class InstallerState { @Nullable private final Environment originalEnv; private final RemoteApiClient installedClient; private final RemoteApiDelegate remoteApiDelegate; @Nullable private final Environment installedEnv; @Nullable String appIdOverrideToRestore; InstallerState( Environment originalEnv, RemoteApiClient installedClient, RemoteApiDelegate remoteApiDelegate, Environment installedEnv, String appIdOverrideToRestore) { this.originalEnv = originalEnv; this.installedClient = installedClient; this.remoteApiDelegate = remoteApiDelegate; this.installedEnv = installedEnv; this.appIdOverrideToRestore = appIdOverrideToRestore; } } /** * Uninstalls the remote API. If any async calls are in progress, waits for * them to finish. * *

If the remote API isn't installed, this method has no effect.

*/ public void uninstall() { // Synchronize on the class because we're manipulating global state (ApiProxy.Delegate) synchronized (getClass()) { if (installedForAllThreads) { throw new IllegalArgumentException( "cannot uninstall the remote API after installing on all threads"); } if (installerState == null) { throw new IllegalArgumentException("remote API is already uninstalled"); } if (installerState.installedEnv != null && installerState.installedEnv != ApiProxy.getCurrentEnvironment()) { throw new IllegalStateException( "Can't uninstall because the current environment has been modified."); } ApiProxy.Delegate currentDelegate = ApiProxy.getDelegate(); if (!(currentDelegate instanceof ThreadLocalDelegate)) { throw new IllegalStateException( "Can't uninstall because the current delegate has been modified."); } // Just clear the delegate for the current thread. We won't bother // uninstalling the ThreadLocalDelegate since it will just just delegate // to the original delegate that it wrapped once the threadlocal // value has been cleared. ThreadLocalDelegate tld = (ThreadLocalDelegate) currentDelegate; if (tld.getDelegateForThread() == null) { throw new IllegalArgumentException("remote API is already uninstalled"); } tld.clearThreadDelegate(); if (installerState.installedEnv != null) { // Note that originalEnv may be null. ApiProxy.setEnvironmentForCurrentThread(installerState.originalEnv); } else { // Remove any override we may have put on the existing Environment. Restore a previous // value if one was present, or just remove it from the attributes altogether. if (installerState.appIdOverrideToRestore != null) { ApiProxy.getCurrentEnvironment().getAttributes().put( DATASTORE_APP_ID_OVERRIDE_KEY, installerState.appIdOverrideToRestore); } else { ApiProxy.getCurrentEnvironment().getAttributes().remove(DATASTORE_APP_ID_OVERRIDE_KEY); } } installerState.remoteApiDelegate.shutdown(); // waits for any async calls to drain installerState = null; } } /** * Returns a string containing the cookies associated with this * connection. The string can be used to create a new connection * without logging in again by using {@link RemoteApiOptions#reuseCredentials}. * By storing credentials to a file, we can avoid repeated password * prompts in command-line tools. (Note that the cookies will expire * based on the setting under Application Settings in the admin console.) * *

Beware: it's important to keep this string private, as it * allows admin access to the app as the current user.

*/ public String serializeCredentials() { return installerState.installedClient.serializeCredentials(); } /** * Starts logging remote API method calls to the console. (Useful within tests.) */ public void logMethodCalls() { Logger logger = Logger.getLogger(RemoteApiDelegate.class.getName()); logger.setLevel(Level.FINE); if (!Arrays.asList(logger.getHandlers()).contains(getStreamHandler())) { logger.addHandler(getStreamHandler()); } } public void resetRpcCount() { installerState.remoteApiDelegate.resetRpcCount(); } /** * Returns the number of RPC calls made since the API was installed * or {@link #resetRpcCount} was called. */ public int getRpcCount() { return installerState.remoteApiDelegate.getRpcCount(); } // === subclass interface (to swap out the implementation for testing) === RemoteApiClient login(RemoteApiOptions options) throws IOException { return loginImpl(options); } RemoteApiDelegate createDelegate( RemoteApiOptions options, RemoteApiClient client, @Nullable Delegate originalDelegate) { return RemoteApiDelegate.newInstance(new RemoteRpc(client), options, originalDelegate); } Environment createEnv(RemoteApiOptions options, RemoteApiClient client) { return new ToolEnvironment(client.getAppId(), options.getUserEmail()); } // === default implementation of logging in === /** * Submits credentials and gets cookies for logging in to App Engine. * (Also downloads the appId from the remote API.) * @return an AppEngineClient containing credentials (if successful) * @throws LoginException for a login failure * @throws IOException for other connection failures */ private RemoteApiClient loginImpl(RemoteApiOptions options) throws IOException { List authCookies; if (!authenticationRequiresCookies(options)) { authCookies = Collections.emptyList(); } else if (options.getCredentialsToReuse() != null) { authCookies = parseSerializedCredentials(options.getUserEmail(), options.getHostname(), options.getCredentialsToReuse()); } else if (options.getHostname().equals("localhost")) { authCookies = Collections.singletonList( makeDevAppServerCookie(options.getHostname(), options.getUserEmail())); } else if (ApiProxy.getCurrentEnvironment() != null) { authCookies = new HostedClientLogin().login( options.getHostname(), options.getUserEmail(), options.getPassword()); } else { authCookies = new StandaloneClientLogin().login( options.getHostname(), options.getUserEmail(), options.getPassword()); } String appId = getAppIdFromServer(authCookies, options); return createAppEngineClient(options, authCookies, appId); } /** * @return {@code true} if the authentication to support the {@link RemoteApiOptions} requires * cookies, {@code false} otherwise */ boolean authenticationRequiresCookies(final RemoteApiOptions options) { return options.getOAuthCredential() == null; } RemoteApiClient createAppEngineClient( RemoteApiOptions options, List authCookies, @Nullable String appId) { if (options.getOAuthCredential() != null) { return new OAuthClient(options, appId); } if (ApiProxy.getCurrentEnvironment() != null) { // We'll talk to the remote_api servlet using UrlFetch. return new HostedAppEngineClient(options, authCookies, appId); } // We'll talk to the remote_api servlet using HttpClient. return new StandaloneAppEngineClient(options, authCookies, appId); } public static Cookie makeDevAppServerCookie(String hostname, String email) { String cookieValue = email + ":true:" + LoginCookieUtils.encodeEmailAsUserId(email); BasicClientCookie cookie = new BasicClientCookie(LoginCookieUtils.COOKIE_NAME, cookieValue); cookie.setDomain(hostname); cookie.setPath("/"); return cookie; } String getAppIdFromServer(List authCookies, RemoteApiOptions options) throws IOException { // Use a temporary RemoteApiClient to do an authenticated GET request. // (It's just like the real one except that appId is null.) RemoteApiClient tempClient = createAppEngineClient(options, authCookies, null); AppEngineClient.Response response = tempClient.get(options.getRemoteApiPath()); int status = response.getStatusCode(); if (status != 200) { if (response.getBodyAsBytes() == null) { throw new IOException("can't get appId from remote api; status code = " + status); } else { throw new IOException("can't get appId from remote api; status code = " + status + ", body: " + response.getBodyAsString()); } } String body = response.getBodyAsString(); Map props = parseYamlMap(body); String appId = props.get("app_id"); if (appId == null) { throw new IOException("unexpected response from remote api: " + body); } return appId; } /** Parses the response from the remote API as a YAML map. */ static ImmutableMap parseYamlMap(String input) { // NOTE: This method only supports a very limited subset of YAML. // It should handle adding another pair to the map, but not much else. Map result = new LinkedHashMap<>(); input = input.trim(); if (!input.startsWith("{") || !input.endsWith("}")) { return ImmutableMap.of(); } input = input.substring(1, input.length() - 1); String[] pairs = input.split(", +"); for (String pair : pairs) { Matcher matcher = PAIR_REGEXP.matcher(pair); if (matcher.matches()) { result.put(matcher.group(1), matcher.group(3)); } } return ImmutableMap.copyOf(result); } static List parseSerializedCredentials(String expectedEmail, String expectedHost, String serializedCredentials) throws IOException { Map> props = parseProperties(serializedCredentials); checkOneProperty(props, "email"); checkOneProperty(props, "host"); String email = props.get("email").get(0); if (!expectedEmail.equals(email)) { throw new IOException("credentials don't match current user email"); } String host = props.get("host").get(0); if (!expectedHost.equals(host)) { throw new IOException("credentials don't match current host"); } List result = new ArrayList(); for (String line : props.get("cookie")) { result.add(parseCookie(line, host)); } return result; } private static Cookie parseCookie(String line, String host) throws IOException { int firstEqual = line.indexOf('='); if (firstEqual < 1) { throw new IOException("invalid cookie in credentials"); } String key = line.substring(0, firstEqual); String value = line.substring(firstEqual + 1); BasicClientCookie cookie = new BasicClientCookie(key, value); cookie.setDomain(host); cookie.setPath("/"); return cookie; } private static void checkOneProperty(Map> props, String key) throws IOException { if (props.get(key).size() != 1) { String message = "invalid credential file (should have one property named '" + key + "')"; throw new IOException(message); } } private static Map> parseProperties(String serializedCredentials) { Map> props = new LinkedHashMap<>(); for (String line : serializedCredentials.split("\n")) { line = line.trim(); if (!line.startsWith("#") && line.contains("=")) { int firstEqual = line.indexOf('='); String key = line.substring(0, firstEqual); String value = line.substring(firstEqual + 1); List values = props.get(key); if (values == null) { values = new ArrayList(); props.put(key, values); } values.add(value); } } return props; } }




© 2015 - 2025 Weber Informatics LLC | Privacy Policy