diff --git a/gcp/lib/gcp.dart b/gcp/lib/gcp.dart index 2cf95e87..19bed3e8 100644 --- a/gcp/lib/gcp.dart +++ b/gcp/lib/gcp.dart @@ -29,5 +29,6 @@ export 'src/logging.dart' cloudLoggingMiddleware, createLoggingMiddleware, currentLogger; +export 'src/metadata.dart' show MetadataValue; export 'src/serve.dart' show listenPort, serveHandler; export 'src/terminate.dart' show waitForTerminate; diff --git a/gcp/lib/src/gcp_project.dart b/gcp/lib/src/gcp_project.dart index ef92a9cc..edb6f944 100644 --- a/gcp/lib/src/gcp_project.dart +++ b/gcp/lib/src/gcp_project.dart @@ -12,26 +12,16 @@ // See the License for the specific language governing permissions and // limitations under the License. -import 'dart:io'; - -import 'package:http/http.dart' as http; - import 'bad_configuration_exception.dart'; +import 'metadata.dart'; /// A convenience wrapper that first tries [projectIdFromEnvironment] /// then (if the value is `null`) tries [projectIdFromMetadataServer] /// /// Like [projectIdFromMetadataServer], if no value is found, a /// [BadConfigurationException] is thrown. -Future computeProjectId() async { - final localValue = projectIdFromEnvironment(); - if (localValue != null) { - return localValue; - } - final result = await projectIdFromMetadataServer(); - - return result; -} +Future computeProjectId() => + MetadataValue.project.fromEnvironmentOrMetadata(); /// Returns the /// [Project ID](https://cloud.google.com/resource-manager/docs/creating-managing-projects#identifying_projects) @@ -41,14 +31,7 @@ Future computeProjectId() async { /// The list is checked in order. This is useful for local development. /// /// If no matching variable is found, `null` is returned. -String? projectIdFromEnvironment() { - for (var envKey in gcpProjectIdEnvironmentVariables) { - final value = Platform.environment[envKey]; - if (value != null) return value; - } - - return null; -} +String? projectIdFromEnvironment() => MetadataValue.project.fromEnvironment(); /// Returns a [Future] that completes with the /// [Project ID](https://cloud.google.com/resource-manager/docs/creating-managing-projects#identifying_projects) @@ -57,36 +40,8 @@ String? projectIdFromEnvironment() { /// /// If the metadata server cannot be contacted, a [BadConfigurationException] is /// thrown. -Future projectIdFromMetadataServer() async { - const host = 'http://metadata.google.internal/'; - final url = Uri.parse('$host/computeMetadata/v1/project/project-id'); - - try { - final response = await http.get( - url, - headers: {'Metadata-Flavor': 'Google'}, - ); - - if (response.statusCode != 200) { - throw HttpException( - '${response.body} (${response.statusCode})', - uri: url, - ); - } - - return response.body; - } on SocketException catch (e) { - throw BadConfigurationException( - ''' -Could not connect to $host. -If not running on Google Cloud, one of these environment variables must be set -to the target Google Project ID: -${gcpProjectIdEnvironmentVariables.join('\n')} -''', - details: e.toString(), - ); - } -} +Future projectIdFromMetadataServer() => + MetadataValue.project.fromMetadataServer(); /// A set of typical environment variables that are likely to represent the /// current Google Cloud project ID. @@ -98,9 +53,5 @@ ${gcpProjectIdEnvironmentVariables.join('\n')} /// /// Note: these are ordered starting from the most current/canonical to least. /// (At least as could be determined at the time of writing.) -const gcpProjectIdEnvironmentVariables = { - 'GCP_PROJECT', - 'GCLOUD_PROJECT', - 'CLOUDSDK_CORE_PROJECT', - 'GOOGLE_CLOUD_PROJECT', -}; +Set get gcpProjectIdEnvironmentVariables => + MetadataValue.project.environmentValues; diff --git a/gcp/lib/src/metadata.dart b/gcp/lib/src/metadata.dart new file mode 100644 index 00000000..bd0fd2bd --- /dev/null +++ b/gcp/lib/src/metadata.dart @@ -0,0 +1,138 @@ +// Copyright 2022 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 +// +// 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 'dart:io'; + +import 'package:http/http.dart' as http; + +import 'bad_configuration_exception.dart'; + +enum MetadataValue { + /// A set of typical environment variables that are likely to represent the + /// current Google Cloud instance region. + /// + /// For context, see: + /// * https://cloud.google.com/functions/docs/env-var + /// * https://cloud.google.com/compute/docs/gcloud-compute#default_project + /// * https://github.com/GoogleContainerTools/gcp-auth-webhook/blob/08136ca171fe5713cc70ef822c911fbd3a1707f5/server.go#L38-L44 + /// + /// Note: these are ordered starting from the most current/canonical to least. + /// (At least as could be determined at the time of writing.) + project( + path: 'project/project-id', + environmentValues: { + 'GCP_PROJECT', + 'GCLOUD_PROJECT', + 'CLOUDSDK_CORE_PROJECT', + 'GOOGLE_CLOUD_PROJECT', + }, + ), + + /// A set of typical environment variables that are likely to represent the + /// current Google Cloud instance region. + /// + /// For context, see: + /// * https://cloud.google.com/functions/docs/env-var + /// * https://cloud.google.com/compute/docs/gcloud-compute#default_project + /// * https://github.com/GoogleContainerTools/gcp-auth-webhook/blob/08136ca171fe5713cc70ef822c911fbd3a1707f5/server.go#L38-L44 + /// + /// Note: these are ordered starting from the most current/canonical to least. + /// (At least as could be determined at the time of writing.) + region( + path: 'instance/region', + environmentValues: { + 'FUNCTION_REGION', + 'CLOUDSDK_COMPUTE_REGION', + }, + ); + + const MetadataValue({ + required this.path, + required this.environmentValues, + }); + + final String path; + + final Set environmentValues; + + /// A convenience wrapper that first tries [fromEnvironment] + /// then (if the value is `null`) tries [fromMetadataServer] + /// + /// Like [fromMetadataServer], if no value is found, a + /// [BadConfigurationException] is thrown. + Future fromEnvironmentOrMetadata() async { + final localValue = fromEnvironment(); + if (localValue != null) { + return localValue; + } + final result = await fromMetadataServer(); + + return result; + } + + /// Returns the + /// [Region](https://cloud.google.com/compute/docs/regions-zones#identifying_a_region_or_zone) + /// for the current instance by checking the environment variables in + /// [environmentValues]. + /// + /// The list is checked in order. This is useful for local development. + /// + /// If no matching variable is found, `null` is returned. + String? fromEnvironment() { + for (var envKey in environmentValues) { + final value = Platform.environment[envKey]; + if (value != null) return value; + } + + return null; + } + + /// Returns a [Future] that completes with the + /// [Region](https://cloud.google.com/compute/docs/regions-zones#identifying_a_region_or_zone) + /// for the current instance by checking + /// [instance metadata](https://cloud.google.com/compute/docs/metadata/default-metadata-values#vm_instance_metadata). + /// + /// If the metadata server cannot be contacted, a [BadConfigurationException] + /// is thrown. + Future fromMetadataServer() async { + const host = 'http://metadata.google.internal/'; + final url = Uri.parse('$host/computeMetadata/v1/$path'); + + try { + final response = await http.get( + url, + headers: {'Metadata-Flavor': 'Google'}, + ); + + if (response.statusCode != 200) { + throw HttpException( + '${response.body} (${response.statusCode})', + uri: url, + ); + } + + return response.body; + } on SocketException catch (e) { + throw BadConfigurationException( + ''' +Could not connect to $host. +If not running on Google Cloud, one of these environment variables must be set +to the target region: +${environmentValues.join('\n')} +''', + details: e.toString(), + ); + } + } +} diff --git a/gcp/test/gcp_test.dart b/gcp/test/gcp_test.dart index f8ac2ed9..5b4e4ed0 100644 --- a/gcp/test/gcp_test.dart +++ b/gcp/test/gcp_test.dart @@ -131,6 +131,44 @@ void main() { }); }, ); + + group('currentRegion', () { + const regionPrint = 'test/src/region_print.dart'; + + test( + 'not environment', + onPlatform: const {'windows': Skip('Broken on windows')}, + () async { + final proc = await _run(regionPrint); + + final errorOut = await proc.stderrStream().toList(); + + await expectLater( + errorOut, + containsAll(MetadataValue.region.environmentValues), + ); + await expectLater(proc.stdout, emitsDone); + + await proc.shouldExit(255); + }, + ); + + test('environment set', () async { + final proc = await _run( + regionPrint, + environment: { + MetadataValue.region.environmentValues.first: 'us-central1', + }, + ); + + await expectLater(proc.stdout, emits('us-central1')); + await expectLater(proc.stderr, emitsDone); + + await proc.shouldExit(0); + }); + + // TODO: worth emulating the metadata server? + }); } Future _run( diff --git a/gcp/test/src/region_print.dart b/gcp/test/src/region_print.dart new file mode 100644 index 00000000..582eb828 --- /dev/null +++ b/gcp/test/src/region_print.dart @@ -0,0 +1,18 @@ +// Copyright 2022 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 +// +// 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 'package:gcp/gcp.dart'; + +Future main() async { + print(await MetadataValue.region.fromEnvironmentOrMetadata()); +}