Add support for Maven username & password authentication Progress on issue #264. RELNOTES: Maven servers that require username & password authentication are now supported (see maven_server documentation). -- MOS_MIGRATED_REVID=103583838
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/repository/MavenJarFunction.java b/src/main/java/com/google/devtools/build/lib/bazel/repository/MavenJarFunction.java index db08bd9..a13a752 100644 --- a/src/main/java/com/google/devtools/build/lib/bazel/repository/MavenJarFunction.java +++ b/src/main/java/com/google/devtools/build/lib/bazel/repository/MavenJarFunction.java
@@ -18,6 +18,7 @@ import com.google.common.base.Ascii; import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; import com.google.common.hash.Hasher; import com.google.common.hash.Hashing; import com.google.devtools.build.lib.analysis.RuleDefinition; @@ -37,16 +38,21 @@ import com.google.devtools.build.skyframe.SkyKey; import com.google.devtools.build.skyframe.SkyValue; +import org.apache.maven.settings.Server; import org.eclipse.aether.RepositorySystem; import org.eclipse.aether.RepositorySystemSession; import org.eclipse.aether.artifact.Artifact; import org.eclipse.aether.artifact.DefaultArtifact; +import org.eclipse.aether.repository.Authentication; +import org.eclipse.aether.repository.AuthenticationContext; +import org.eclipse.aether.repository.AuthenticationDigest; import org.eclipse.aether.repository.RemoteRepository; import org.eclipse.aether.resolution.ArtifactRequest; import org.eclipse.aether.resolution.ArtifactResolutionException; import org.eclipse.aether.resolution.ArtifactResult; import java.io.IOException; +import java.util.Map; import javax.annotation.Nullable; @@ -65,7 +71,7 @@ return null; } - String url; + MavenServerValue serverValue; AggregatingAttributeMapper mapper = AggregatingAttributeMapper.of(rule); boolean hasRepository = mapper.has("repository", Type.STRING) && !mapper.get("repository", Type.STRING).isEmpty(); @@ -77,30 +83,28 @@ + "'repository' and 'server', which are mutually exclusive options"), Transience.PERSISTENT); } else if (hasRepository) { - url = mapper.get("repository", Type.STRING); + serverValue = MavenServerValue.createFromUrl(mapper.get("repository", Type.STRING)); } else { String serverName = DEFAULT_SERVER; if (mapper.has("server", Type.STRING) && !mapper.get("server", Type.STRING).isEmpty()) { serverName = mapper.get("server", Type.STRING); } - MavenServerValue mavenServerValue = (MavenServerValue) env.getValue( - MavenServerValue.key(serverName)); - if (mavenServerValue == null) { + serverValue = (MavenServerValue) env.getValue(MavenServerValue.key(serverName)); + if (serverValue == null) { return null; } - url = mavenServerValue.getUrl(); } - MavenDownloader downloader = createMavenDownloader(mapper, url); + MavenDownloader downloader = createMavenDownloader(mapper, serverValue); return createOutputTree(downloader, env); } @VisibleForTesting - MavenDownloader createMavenDownloader(AttributeMap mapper, String url) { + MavenDownloader createMavenDownloader(AttributeMap mapper, MavenServerValue serverValue) { String name = mapper.getName(); Path outputDirectory = getExternalRepositoryDirectory().getRelative(name); - return new MavenDownloader(name, mapper, outputDirectory, url); + return new MavenDownloader(name, mapper, outputDirectory, serverValue); } SkyValue createOutputTree(MavenDownloader downloader, Environment env) @@ -159,9 +163,10 @@ @Nullable private final String sha1; private final String url; + private final Server server; public MavenDownloader( - String name, AttributeMap mapper, Path outputDirectory, String url) { + String name, AttributeMap mapper, Path outputDirectory, MavenServerValue serverValue) { this.name = name; this.outputDirectory = outputDirectory; @@ -173,7 +178,8 @@ + mapper.get("version", Type.STRING); } this.sha1 = (mapper.has("sha1", Type.STRING)) ? mapper.get("sha1", Type.STRING) : null; - this.url = url; + this.url = serverValue.getUrl(); + this.server = serverValue.getServer(); } /** @@ -198,7 +204,11 @@ RepositorySystem system = connector.newRepositorySystem(); RepositorySystemSession session = connector.newRepositorySystemSession(system); - RemoteRepository repository = new RemoteRepository.Builder(name, "default", url).build(); + + RemoteRepository repository = new RemoteRepository.Builder( + name, MavenServerValue.DEFAULT_ID, url) + .setAuthentication(new MavenAuthentication(server)) + .build(); ArtifactRequest artifactRequest = new ArtifactRequest(); Artifact artifact; try { @@ -230,4 +240,37 @@ } } + private static class MavenAuthentication implements Authentication { + + private final Map<String, String> authenticationInfo; + + private MavenAuthentication(Server server) { + ImmutableMap.Builder builder = ImmutableMap.<String, String>builder(); + // From https://maven.apache.org/settings.html: "If you use a private key to login to the + // server, make sure you omit the <password> element. Otherwise, the key will be ignored." + if (server.getPassword() != null) { + builder.put(AuthenticationContext.USERNAME, server.getUsername()); + builder.put(AuthenticationContext.PASSWORD, server.getPassword()); + } else if (server.getPrivateKey() != null) { + // getPrivateKey sounds like it returns the key, but it actually returns a path to it. + builder.put(AuthenticationContext.PRIVATE_KEY_PATH, server.getPrivateKey()); + builder.put(AuthenticationContext.PRIVATE_KEY_PASSPHRASE, server.getPassphrase()); + } + authenticationInfo = builder.build(); + } + + @Override + public void fill( + AuthenticationContext authenticationContext, String s, Map<String, String> map) { + for (Map.Entry<String, String> entry : authenticationInfo.entrySet()) { + authenticationContext.put(entry.getKey(), entry.getValue()); + } + } + + @Override + public void digest(AuthenticationDigest authenticationDigest) { + // No-op. + } + } + }
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/repository/MavenServerFunction.java b/src/main/java/com/google/devtools/build/lib/bazel/repository/MavenServerFunction.java index 6dcfc6c..89b8199 100644 --- a/src/main/java/com/google/devtools/build/lib/bazel/repository/MavenServerFunction.java +++ b/src/main/java/com/google/devtools/build/lib/bazel/repository/MavenServerFunction.java
@@ -14,6 +14,8 @@ package com.google.devtools.build.lib.bazel.repository; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; import com.google.devtools.build.lib.analysis.BlazeDirectories; import com.google.devtools.build.lib.analysis.RuleDefinition; import com.google.devtools.build.lib.bazel.rules.workspace.MavenServerRule; @@ -37,9 +39,9 @@ import org.apache.maven.settings.building.SettingsBuildingException; import org.apache.maven.settings.building.SettingsBuildingResult; -import java.io.File; import java.io.IOException; import java.util.Arrays; +import java.util.Map; import javax.annotation.Nullable; @@ -49,6 +51,9 @@ public class MavenServerFunction extends RepositoryFunction { public static final SkyFunctionName NAME = SkyFunctionName.create("MAVEN_SERVER_FUNCTION"); + private static final String USER_KEY = "user"; + private static final String SYSTEM_KEY = "system"; + public MavenServerFunction(BlazeDirectories directories) { setDirectories(directories); } @@ -58,48 +63,75 @@ public SkyValue compute(SkyKey skyKey, Environment env) throws RepositoryFunctionException { String repository = skyKey.argument().toString(); Package externalPackage = RepositoryFunction.getExternalPackage(env); + if (externalPackage == null) { + return null; + } Rule repositoryRule = externalPackage.getRule(repository); + String serverName; + String url; + Map<String, FileValue> settingsFiles; boolean foundRepoRule = repositoryRule != null && repositoryRule.getRuleClass().equals(MavenServerRule.NAME); if (!foundRepoRule) { if (repository.equals(MavenServerValue.DEFAULT_ID)) { - // The default repository is being used and the WORKSPACE is not overriding the default. - return new MavenServerValue(); + settingsFiles = getDefaultSettingsFile(env); + serverName = MavenServerValue.DEFAULT_ID; + url = MavenConnector.getMavenCentralRemote().getUrl(); + } else { + throw new RepositoryFunctionException( + new IOException("Could not find maven repository " + repository), + Transience.TRANSIENT); } - throw new RepositoryFunctionException( - new IOException("Could not find maven repository " + repository), Transience.TRANSIENT); - } + } else { + AggregatingAttributeMapper mapper = AggregatingAttributeMapper.of(repositoryRule); + serverName = repositoryRule.getName(); + url = mapper.get("url", Type.STRING); + if (!mapper.has("settings_file", Type.STRING) + || mapper.get("settings_file", Type.STRING).isEmpty()) { + settingsFiles = getDefaultSettingsFile(env); + } else { + PathFragment settingsFilePath = new PathFragment(mapper.get("settings_file", Type.STRING)); + RootedPath settingsPath = RootedPath.toRootedPath( + getWorkspace().getRelative(settingsFilePath), PathFragment.EMPTY_FRAGMENT); + FileValue fileValue = (FileValue) env.getValue(FileValue.key(settingsPath)); + if (fileValue == null) { + return null; + } - AggregatingAttributeMapper mapper = AggregatingAttributeMapper.of(repositoryRule); - String serverName = repositoryRule.getName(); - String url = mapper.get("url", Type.STRING); - if (!mapper.has("settings_file", Type.STRING) - || mapper.get("settings_file", Type.STRING).isEmpty()) { - return new MavenServerValue(serverName, url, new Server()); + if (!fileValue.exists()) { + throw new RepositoryFunctionException( + new IOException("Could not find settings file " + settingsPath), + Transience.TRANSIENT); + } + settingsFiles = ImmutableMap.<String, FileValue>builder().put( + USER_KEY, fileValue).build(); + } } - PathFragment settingsFilePath = new PathFragment(mapper.get("settings_file", Type.STRING)); - RootedPath settingsPath = RootedPath.toRootedPath( - getWorkspace().getRelative(settingsFilePath), PathFragment.EMPTY_FRAGMENT); - FileValue settingsFile = (FileValue) env.getValue(FileValue.key(settingsPath)); - if (settingsFile == null) { + if (settingsFiles == null) { return null; } - if (!settingsFile.exists()) { - throw new RepositoryFunctionException( - new IOException("Could not find settings file " + settingsPath), Transience.TRANSIENT); + if (settingsFiles.isEmpty()) { + return new MavenServerValue(serverName, url, new Server()); } DefaultSettingsBuildingRequest request = new DefaultSettingsBuildingRequest(); - request.setUserSettingsFile(new File(settingsFile.realRootedPath().asPath().toString())); + if (settingsFiles.containsKey(SYSTEM_KEY)) { + request.setGlobalSettingsFile( + settingsFiles.get(SYSTEM_KEY).realRootedPath().asPath().getPathFile()); + } + if (settingsFiles.containsKey(USER_KEY)) { + request.setUserSettingsFile( + settingsFiles.get(USER_KEY).realRootedPath().asPath().getPathFile()); + } DefaultSettingsBuilder builder = (new DefaultSettingsBuilderFactory()).newInstance(); SettingsBuildingResult result; try { result = builder.build(request); } catch (SettingsBuildingException e) { throw new RepositoryFunctionException( - new IOException("Error parsing settings file " + settingsFile + ": " + e.getMessage()), + new IOException("Error parsing settings files: " + e.getMessage()), Transience.TRANSIENT); } if (!result.getProblems().isEmpty()) { @@ -108,11 +140,53 @@ + Arrays.toString(result.getProblems().toArray())), Transience.PERSISTENT); } Settings settings = result.getEffectiveSettings(); - Server server = settings.getServer(mapper.getName()); + Server server = settings.getServer(serverName); server = server == null ? new Server() : server; return new MavenServerValue(serverName, url, server); } + private Map<String, FileValue> getDefaultSettingsFile(Environment env) { + // The system settings file is at $M2_HOME/conf/settings.xml. + String m2Home = System.getenv("M2_HOME"); + ImmutableList.Builder<SkyKey> settingsFilesBuilder = ImmutableList.builder(); + SkyKey systemKey = null; + if (m2Home != null) { + PathFragment mavenInstallSettings = new PathFragment(m2Home).getRelative("conf/settings.xml"); + systemKey = FileValue.key( + RootedPath.toRootedPath(getWorkspace().getRelative(mavenInstallSettings), + PathFragment.EMPTY_FRAGMENT)); + settingsFilesBuilder.add(systemKey); + } + + // The user settings file is at $HOME/.m2/settings.xml. + String userHome = System.getenv("HOME"); + SkyKey userKey = null; + if (userHome != null) { + PathFragment userSettings = new PathFragment(userHome).getRelative(".m2/settings.xml"); + userKey = FileValue.key(RootedPath.toRootedPath(getWorkspace().getRelative(userSettings), + PathFragment.EMPTY_FRAGMENT)); + settingsFilesBuilder.add(userKey); + } + + ImmutableList settingsFiles = settingsFilesBuilder.build(); + if (settingsFiles.isEmpty()) { + return ImmutableMap.of(); + } + Map<SkyKey, SkyValue> values = env.getValues(settingsFilesBuilder.build()); + ImmutableMap.Builder<String, FileValue> settingsBuilder = ImmutableMap.builder(); + for (Map.Entry<SkyKey, SkyValue> entry : values.entrySet()) { + if (entry.getValue() == null) { + return null; + } + if (systemKey != null && systemKey.equals(entry.getKey())) { + settingsBuilder.put(SYSTEM_KEY, (FileValue) entry.getValue()); + } else if (userKey != null && userKey.equals(entry.getKey())) { + settingsBuilder.put(USER_KEY, (FileValue) entry.getValue()); + } + } + return settingsBuilder.build(); + } + @Override public SkyFunctionName getSkyFunctionName() { return NAME;
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/repository/MavenServerValue.java b/src/main/java/com/google/devtools/build/lib/bazel/repository/MavenServerValue.java index 78f2e26..22254b7 100644 --- a/src/main/java/com/google/devtools/build/lib/bazel/repository/MavenServerValue.java +++ b/src/main/java/com/google/devtools/build/lib/bazel/repository/MavenServerValue.java
@@ -35,6 +35,10 @@ return new SkyKey(MavenServerFunction.NAME, serverName); } + public static MavenServerValue createFromUrl(String url) { + return new MavenServerValue(DEFAULT_ID, url, new Server()); + } + public MavenServerValue() { id = DEFAULT_ID; url = MavenConnector.getMavenCentralRemote().getUrl();
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/rules/workspace/MavenServerRule.java b/src/main/java/com/google/devtools/build/lib/bazel/rules/workspace/MavenServerRule.java index e710f9b..f08ec76 100644 --- a/src/main/java/com/google/devtools/build/lib/bazel/rules/workspace/MavenServerRule.java +++ b/src/main/java/com/google/devtools/build/lib/bazel/rules/workspace/MavenServerRule.java
@@ -42,7 +42,9 @@ <!-- #END_BLAZE_RULE.ATTRIBUTE --> */ .add(attr("url", Type.STRING)) /* <!-- #BLAZE_RULE(maven_server).ATTRIBUTE(settings_file) --> - A path to a settings.xml file. + A path to a settings.xml file. Used for testing. If unspecified, this defaults to using + <code>$M2_HOME/conf/settings.xml</code> for the global settings and + <code>$HOME/.m2/settings.xml</code> for the user settings. ${SYNOPSIS} <!-- #END_BLAZE_RULE.ATTRIBUTE --> */ .add(attr("settings_file", Type.STRING)) @@ -71,4 +73,33 @@ <p>This is a combination of a <repository> definition from a pom.xml file and a <server< definition from a settings.xml file.</p> +<h4>Using <code>maven_server</code></h4> + +<p><code>maven_jar</code> rules can specify the name of a <code>maven_server</code> in their +<code>server</code> field. For example, suppose we have the following WORKSPACE file:</p> + +<pre> +maven_jar( + name = "junit", + artifact = "junit:junit-dep:4.10", + server = "my-server", +) + +maven_server( + name = "my-server", + url = "http://intranet.mycorp.net", +) +</pre> + +This specifies that junit should be downloaded from http://intranet.mycorp.net using the +authentication information found in ~/.m2/settings.xml (specifically, the settings +for the server with the id <code>my-server</code>). + +<h4>Specifying a default server</h4> + +<p>If you create a <code>maven_server</code> with the <code>name</code> "default" it will be +used for any <code>maven_jar</code> that does not specify a <code>server</code> nor +<code>repository</code>. If there is no <code>maven_server</code> named default, the +default will be fetching from Maven Central with no authentication enabled.</p> + <!-- #END_BLAZE_RULE -->*/
diff --git a/src/test/shell/bazel/BUILD b/src/test/shell/bazel/BUILD index 4df03b7..7ce57f56 100644 --- a/src/test/shell/bazel/BUILD +++ b/src/test/shell/bazel/BUILD
@@ -41,6 +41,7 @@ "remote_helpers.sh", "test-setup.sh", "testenv.sh", + "testing_server.py", ":langtools-copy", ":objc-deps", "//examples:srcs",
diff --git a/src/test/shell/bazel/maven_test.sh b/src/test/shell/bazel/maven_test.sh index 57f20d2..1490385 100755 --- a/src/test/shell/bazel/maven_test.sh +++ b/src/test/shell/bazel/maven_test.sh
@@ -52,22 +52,19 @@ function test_maven_jar() { setup_zoo - serve_jar + serve_artifact com.example.carnivore carnivore 1.23 cat > WORKSPACE <<EOF maven_jar( name = 'endangered', artifact = "com.example.carnivore:carnivore:1.23", - repository = 'http://localhost:$nc_port/', + repository = 'http://localhost:$fileserver_port/', sha1 = '$sha1', ) bind(name = 'mongoose', actual = '@endangered//jar') EOF - bazel fetch //zoo:ball-pit || fail "Fetch failed" bazel run //zoo:ball-pit >& $TEST_log || fail "Expected run to succeed" - kill_nc - assert_contains "GET /com/example/carnivore/carnivore/1.23/carnivore-1.23.jar" $nc_log expect_log "Tra-la!" } @@ -219,4 +216,57 @@ expect_log "no such package '@x//'" } +function test_auth() { + startup_auth_server + create_artifact thing amabop 1.9 + cat > WORKSPACE <<EOF +maven_server( + name = "x", + url = "http://localhost:$fileserver_port/", + settings_file = "settings.xml", +) +maven_jar( + name = "good-auth", + artifact = "thing:amabop:1.9", + server = "x", +) + +maven_server( + name = "y", + url = "http://localhost:$fileserver_port/", + settings_file = "settings.xml", +) +maven_jar( + name = "bad-auth", + artifact = "thing:amabop:1.9", + server = "y", +) +EOF + + cat > settings.xml <<EOF +<settings> + <servers> + <server> + <id>x</id> + <username>foo</username> + <password>bar</password> + </server> + <server> + <id>y</id> + <username>foo</username> + <password>baz</password> + </server> + </servers> +</settings> +EOF + + bazel build @good-auth//jar &> $TEST_log \ + || fail "Expected correct password to work" + expect_log "Target @good-auth//jar:jar up-to-date" + + bazel build @bad-auth//jar &> $TEST_log \ + && fail "Expected incorrect password to fail" + expect_log "Unauthorized (401)" +} + run_suite "maven tests"
diff --git a/src/test/shell/bazel/remote_helpers.sh b/src/test/shell/bazel/remote_helpers.sh index 28d4b14..091a9b7 100755 --- a/src/test/shell/bazel/remote_helpers.sh +++ b/src/test/shell/bazel/remote_helpers.sh
@@ -57,7 +57,7 @@ function make_test_jar() { pkg_dir=$TEST_TMPDIR/carnivore rm -fr $pkg_dir - mkdir $pkg_dir + mkdir -p $pkg_dir cat > $pkg_dir/Mongoose.java <<EOF package carnivore; public class Mongoose { @@ -108,28 +108,39 @@ } -function serve_artifact() { - startup_server $PWD +function create_artifact() { local group_id=$1 local artifact_id=$2 local version=$3 make_test_jar - maven_path=$fileserver_root/$(echo $group_id | sed 's/\./\//g')/$artifact_id/$version + maven_path=$PWD/$(echo $group_id | sed 's/\./\//g')/$artifact_id/$version mkdir -p $maven_path openssl sha1 $test_jar > $maven_path/$artifact_id-$version.jar.sha1 mv $test_jar $maven_path/$artifact_id-$version.jar } +function serve_artifact() { + startup_server $PWD + create_artifact $1 $2 $3 +} + function startup_server() { fileserver_root=$1 cd $fileserver_root fileserver_port=$(pick_random_unused_tcp_port) || exit 1 - python -m SimpleHTTPServer $fileserver_port & + python $python_server --port=$fileserver_port & fileserver_pid=$! wait_for_server_startup cd - } +function startup_auth_server() { + fileserver_port=$(pick_random_unused_tcp_port) || exit 1 + python $python_server --port=$fileserver_port --auth=basic & + fileserver_pid=$! + wait_for_server_startup +} + function shutdown_server() { # Try to kill nc, otherwise the test will time out if Bazel has a bug and # didn't make a request to it.
diff --git a/src/test/shell/bazel/testenv.sh b/src/test/shell/bazel/testenv.sh index 8a62fea..0bd4ac2 100755 --- a/src/test/shell/bazel/testenv.sh +++ b/src/test/shell/bazel/testenv.sh
@@ -66,6 +66,7 @@ # Test data testdata_path=${TEST_SRCDIR}/src/test/shell/bazel/testdata +python_server="${TEST_SRCDIR}/src/test/shell/bazel/testing_server.py" # Third-party PLATFORM="$(uname -s | tr 'A-Z' 'a-z')"
diff --git a/src/test/shell/bazel/testing_server.py b/src/test/shell/bazel/testing_server.py new file mode 100644 index 0000000..cd12657 --- /dev/null +++ b/src/test/shell/bazel/testing_server.py
@@ -0,0 +1,87 @@ +# Copyright 2015 Google Inc. All rights reserved. +# +# 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. + +"""An HTTP server to use for external repository integration tests.""" + +import base64 +from BaseHTTPServer import BaseHTTPRequestHandler +import getopt +import os +import SocketServer +import sys + +auth = None + + +class Handler(BaseHTTPRequestHandler): + """Handlers for testing HTTP server.""" + + def do_HEAD(self): # pylint: disable=invalid-name + self.send_response(200) + self.send_header('Content-type', 'text/html') + self.end_headers() + + def do_AUTHHEAD(self): # pylint: disable=invalid-name + self.send_response(401) + self.send_header('WWW-Authenticate', 'Basic realm=\"Bazel\"') + self.send_header('Content-type', 'text/html') + self.end_headers() + + def do_GET(self): # pylint: disable=invalid-name + if auth is None: + self.do_HEAD() + self.serve_file() + return + + foo_bar = base64.b64encode('foo:bar') + + if self.headers.getheader('Authorization') is None: + self.do_AUTHHEAD() + self.wfile.write('Login required.') + elif self.headers.getheader('Authorization') == 'Basic %s' % foo_bar: + self.do_HEAD() + try: + self.wfile.write(self.headers.getheader('Authorization')) + self.serve_file() + except IOError: + self.wfile.write('Authorized.') + else: + self.do_AUTHHEAD() + self.wfile.write(self.headers.getheader('Authorization')) + self.wfile.write('not authenticated') + + def serve_file(self): + file_to_serve = open(str(os.getcwd()) + self.path) + self.wfile.write(file_to_serve.read()) + + +if __name__ == '__main__': + try: + opts, args = getopt.getopt(sys.argv[1:], 'p:a:', ['port=', 'auth=']) + except getopt.GetoptError: + print 'Error parsing args' + sys.exit(1) + + port = 12345 + for o, a in opts: + if o in ('-p', '--port'): + port = int(a) + if o in ('-a', '--auth'): + auth = a + httpd = SocketServer.TCPServer(('', port), Handler) + try: + print 'Serving forever on %d.' % port + httpd.serve_forever() + except: # pylint: disable=bare-except + print 'Goodbye.'