Add cloud endpoints resource (#933)

OpenAPI & gRPC Endpoints on Compute Engine.

New Resource:
- Endpoints Service Create/Read/Delete
- Example terraform config
This commit is contained in:
Nathan McKinley 2018-01-24 13:03:57 -08:00 committed by GitHub
parent fec2f0bc02
commit 9d3e64cdaf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 680 additions and 7 deletions

View File

@ -0,0 +1,3 @@
terraform.tfstate
terraform.tfstate.backup
terraform.tfvars

View File

@ -0,0 +1,136 @@
provider "google" {
region = "${var.region}"
credentials = "${file("${var.credentials_file_path}")}"
}
provider "random" {}
resource "random_id" "project_name" {
byte_length = 8
}
resource "google_project" "endpoints_project" {
name = "Endpoints Project"
project_id = "tf-ep-${random_id.project_name.hex}"
org_id = "${var.org_id}"
billing_account = "${var.billing_account_id}"
}
resource "google_project_service" "endpoints_project" {
project = "${google_project.endpoints_project.project_id}"
service = "compute.googleapis.com"
}
resource "google_project_service" "endpoints_project_sm" {
project = "${google_project.endpoints_project.project_id}"
service = "servicemanagement.googleapis.com"
}
resource "google_endpoints_service" "endpoints_service" {
service_name = "echo-api.endpoints.${google_project.endpoints_project.project_id}.cloud.goog"
project = "${google_project.endpoints_project.project_id}"
config_text = <<EOF
swagger: "2.0"
info:
description: "A simple Google Cloud Endpoints API example."
title: "Endpoints Example"
version: "1.0.0"
host: "echo-api.endpoints.${google_project.endpoints_project.project_id}.cloud.goog"
basePath: "/"
consumes:
- "application/json"
produces:
- "application/json"
schemes:
- "https"
paths:
"/echo":
post:
description: "Echo back a given message."
operationId: "echo"
produces:
- "application/json"
responses:
200:
description: "Echo"
schema:
$ref: "#/definitions/echoMessage"
parameters:
- description: "Message to echo"
in: body
name: message
required: true
schema:
$ref: "#/definitions/echoMessage"
security:
- api_key: []
definitions:
echoMessage:
properties:
message:
type: "string"
EOF
depends_on = ["google_project_service.endpoints_project_sm"]
}
resource "google_compute_network" "network" {
name = "ep-network"
auto_create_subnetworks = "true"
project = "${google_project.endpoints_project.project_id}"
depends_on = ["google_project_service.endpoints_project"]
}
# Allow the hosted network to be hit over ICMP, SSH, and HTTP.
resource "google_compute_firewall" "network" {
name = "allow-ssh-and-icmp"
network = "${google_compute_network.network.self_link}"
project = "${google_compute_network.network.project}"
allow {
protocol = "icmp"
}
allow {
protocol = "tcp"
ports = ["22", "80"]
}
}
resource "google_compute_instance" "project_1_vm" {
name = "tf-ep-vm"
project = "${google_project.endpoints_project.project_id}"
machine_type = "f1-micro"
zone = "${var.region_zone}"
boot_disk {
initialize_params {
image = "projects/debian-cloud/global/images/family/debian-8"
}
}
metadata {
endpoints-service-name = "${google_endpoints_service.endpoints_service.service_name}"
endpoints-service-config-id = "${google_endpoints_service.endpoints_service.config_id}"
startup-script = "${file("scripts/install-vm.sh")}"
}
network_interface {
network = "${google_compute_firewall.network.network}"
access_config {
// Ephemeral IP
}
}
service_account {
scopes = ["https://www.googleapis.com/auth/cloud-platform"]
}
depends_on = ["google_project_service.endpoints_project_sm", "google_project_service.endpoints_project"]
}
output "ip" {
value = "${google_compute_instance.project_1_vm.network_interface.0.access_config.0.assigned_nat_ip}"
}

View File

@ -0,0 +1,14 @@
#!/bin/bash
sudo apt-get install git -y
curl https://storage.googleapis.com/golang/go1.7.3.linux-amd64.tar.gz | tar xzf -
wget https://raw.githubusercontent.com/GoogleCloudPlatform/golang-samples/master/endpoints/getting-started/app.go
GOPATH=$PWD GOROOT=$PWD/go go/bin/go get ./... 2> /tmp/goget_log.txt > /tmp/goget_outlog.txt
echo "deb http://packages.cloud.google.com/apt google-cloud-endpoints-jessie main" | sudo tee /etc/apt/sources.list.d/google-cloud-endpoints.list
curl --silent https://packages.cloud.google.com/apt/doc/apt-key.gpg | sudo apt-key add -
sudo apt-get update && sudo apt-get install google-cloud-sdk
sudo apt-get install endpoints-runtime
sudo echo "PORT=80" >> /etc/default/nginx
PORT=8081 GOPATH=$PWD GOROOT=$PWD/go go/bin/go run app.go > /tmp/gorun_outlog.txt 2> /tmp/gorun_log.txt &
sleep 10
sudo service nginx restart

View File

@ -0,0 +1,20 @@
variable "region" {
default = "us-central1"
}
variable "region_zone" {
default = "us-central1-f"
}
variable "org_id" {
description = "The ID of the Google Cloud Organization."
}
variable "billing_account_id" {
description = "The ID of the associated billing account (optional)."
}
variable "credentials_file_path" {
description = "Location of the credentials to use."
default = "~/.gcloud/Terraform.json"
}

View File

@ -139,6 +139,7 @@ func Provider() terraform.ResourceProvider {
"google_dataproc_job": resourceDataprocJob(),
"google_dns_managed_zone": resourceDnsManagedZone(),
"google_dns_record_set": resourceDnsRecordSet(),
"google_endpoints_service": resourceEndpointsService(),
"google_folder": resourceGoogleFolder(),
"google_folder_iam_policy": ResourceIamPolicyWithImport(IamFolderSchema, NewFolderIamUpdater, FolderIdParseFunc),
"google_folder_organization_policy": resourceGoogleFolderOrganizationPolicy(),

View File

@ -0,0 +1,302 @@
package google
import (
"encoding/base64"
"encoding/json"
"errors"
"github.com/hashicorp/terraform/helper/schema"
"google.golang.org/api/servicemanagement/v1"
)
func resourceEndpointsService() *schema.Resource {
return &schema.Resource{
Create: resourceEndpointsServiceCreate,
Read: resourceEndpointsServiceRead,
Delete: resourceEndpointsServiceDelete,
Update: resourceEndpointsServiceUpdate,
Schema: map[string]*schema.Schema{
"service_name": &schema.Schema{
Type: schema.TypeString,
Required: true,
ForceNew: true,
},
"openapi_config": &schema.Schema{
Type: schema.TypeString,
Optional: true,
ConflictsWith: []string{"grpc_config", "protoc_output"},
},
"grpc_config": &schema.Schema{
Type: schema.TypeString,
Optional: true,
},
"protoc_output": &schema.Schema{
Type: schema.TypeString,
Optional: true,
},
"project": &schema.Schema{
Type: schema.TypeString,
Optional: true,
Computed: true,
ForceNew: true,
},
"config_id": &schema.Schema{
Type: schema.TypeString,
Computed: true,
},
"apis": &schema.Schema{
Type: schema.TypeList,
Computed: true,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"name": &schema.Schema{
Type: schema.TypeString,
Computed: true,
},
"syntax": &schema.Schema{
Type: schema.TypeString,
Computed: true,
},
"version": &schema.Schema{
Type: schema.TypeString,
Computed: true,
},
"methods": &schema.Schema{
Type: schema.TypeList,
Computed: true,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"name": &schema.Schema{
Type: schema.TypeString,
Computed: true,
},
"syntax": &schema.Schema{
Type: schema.TypeString,
Computed: true,
},
"request_type": &schema.Schema{
Type: schema.TypeString,
Computed: true,
},
"response_type": &schema.Schema{
Type: schema.TypeString,
Computed: true,
},
},
},
},
},
},
},
"dns_address": &schema.Schema{
Type: schema.TypeString,
Computed: true,
},
"endpoints": &schema.Schema{
Type: schema.TypeList,
Computed: true,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"name": &schema.Schema{
Type: schema.TypeString,
Computed: true,
},
"address": &schema.Schema{
Type: schema.TypeString,
Computed: true,
},
},
},
},
},
}
}
func getOpenAPIConfigSource(configText string) servicemanagement.ConfigSource {
// We need to provide a ConfigSource object to the API whenever submitting a
// new config. A ConfigSource contains a ConfigFile which contains the b64
// encoded contents of the file. OpenAPI requires only one file.
configfile := servicemanagement.ConfigFile{
FileContents: base64.StdEncoding.EncodeToString([]byte(configText)),
FileType: "OPEN_API_YAML",
FilePath: "heredoc.yaml",
}
return servicemanagement.ConfigSource{
Files: []*servicemanagement.ConfigFile{&configfile},
}
}
func getGRPCConfigSource(serviceConfig, protoConfig string) servicemanagement.ConfigSource {
// gRPC requires both the file specifying the service and the compiled protobuf,
// but they can be in any order.
ymlConfigfile := servicemanagement.ConfigFile{
FileContents: base64.StdEncoding.EncodeToString([]byte(serviceConfig)),
FileType: "SERVICE_CONFIG_YAML",
FilePath: "heredoc.yaml",
}
protoConfigfile := servicemanagement.ConfigFile{
FileContents: base64.StdEncoding.EncodeToString([]byte(protoConfig)),
FileType: "FILE_DESCRIPTOR_SET_PROTO",
FilePath: "api_def.pb",
}
return servicemanagement.ConfigSource{
Files: []*servicemanagement.ConfigFile{&ymlConfigfile, &protoConfigfile},
}
}
func resourceEndpointsServiceCreate(d *schema.ResourceData, meta interface{}) error {
config := meta.(*Config)
project, err := getProject(d, config)
if err != nil {
return err
}
// If the service doesn't exist, we'll need to create it, but if it does, it
// will be reused. This is unusual for Terraform, but it causes the behavior
// that users will want and accept. Users of Endpoints are not thinking in
// terms of services, configs, and rollouts - they just want the setup declared
// in their config to happen. The fact that a service may need to be created
// is not interesting to them. Consequently, we create this service if necessary
// so that we can perform the rollout without further disruption, which is the
// action that a user running `terraform apply` is going to want.
serviceName := d.Get("service_name").(string)
servicesService := servicemanagement.NewServicesService(config.clientServiceMan)
_, err = servicesService.Get(serviceName).Do()
if err != nil {
_, err = servicesService.Create(&servicemanagement.ManagedService{ProducerProjectId: project, ServiceName: serviceName}).Do()
if err != nil {
return err
}
}
// Do a rollout using the update mechanism.
err = resourceEndpointsServiceUpdate(d, meta)
if err != nil {
return err
}
d.SetId(serviceName)
return resourceEndpointsServiceRead(d, meta)
}
func resourceEndpointsServiceUpdate(d *schema.ResourceData, meta interface{}) error {
// This update is not quite standard for a terraform resource. Instead of
// using the go client library to send an HTTP request to update something
// serverside, we have to push a new configuration, wait for it to be
// parsed and loaded, then create and push a rollout and wait for that
// rollout to be completed.
// There's a lot of moving parts there, and all of them have knobs that can
// be tweaked if the user is using gcloud. In the interest of simplicity,
// we currently only support full rollouts - anyone trying to do incremental
// rollouts or A/B testing is going to need a more precise tool than this resource.
config := meta.(*Config)
serviceName := d.Get("service_name").(string)
var source servicemanagement.ConfigSource
if openapiConfig, ok := d.GetOk("openapi_config"); ok {
source = getOpenAPIConfigSource(openapiConfig.(string))
} else {
grpcConfig, gok := d.GetOk("grpc_config")
protocOutput, pok := d.GetOk("protoc_output")
if gok && pok {
source = getGRPCConfigSource(grpcConfig.(string), protocOutput.(string))
} else {
return errors.New("Could not decypher config - please either set openapi_config or set both grpc_config and protoc_output.")
}
}
configService := servicemanagement.NewServicesConfigsService(config.clientServiceMan)
// The difference between "submit" and "create" is that submit parses the config
// you provide, where "create" requires the config in a pre-parsed format.
// "submit" will be a lot more flexible for users and will always be up-to-date
// with any new features that arise - this is why you provide a YAML config
// instead of providing the config in HCL.
op, err := configService.Submit(serviceName, &servicemanagement.SubmitConfigSourceRequest{ConfigSource: &source}).Do()
if err != nil {
return err
}
s, err := serviceManagementOperationWait(config, op, "Submitting service config.")
if err != nil {
return err
}
var serviceConfig servicemanagement.SubmitConfigSourceResponse
json.Unmarshal(s, &serviceConfig)
// Next, we create a new rollout with the new config value, and wait for it to complete.
rolloutService := servicemanagement.NewServicesRolloutsService(config.clientServiceMan)
rollout := servicemanagement.Rollout{
ServiceName: serviceName,
TrafficPercentStrategy: &servicemanagement.TrafficPercentStrategy{
Percentages: map[string]float64{serviceConfig.ServiceConfig.Id: 100.0},
},
}
op, err = rolloutService.Create(serviceName, &rollout).Do()
if err != nil {
return err
}
_, err = serviceManagementOperationWait(config, op, "Performing service rollout.")
if err != nil {
return err
}
return resourceEndpointsServiceRead(d, meta)
}
func resourceEndpointsServiceDelete(d *schema.ResourceData, meta interface{}) error {
config := meta.(*Config)
servicesService := servicemanagement.NewServicesService(config.clientServiceMan)
op, err := servicesService.Delete(d.Get("service_name").(string)).Do()
if err != nil {
return err
}
_, err = serviceManagementOperationWait(config, op, "Deleting service.")
d.SetId("")
return err
}
func resourceEndpointsServiceRead(d *schema.ResourceData, meta interface{}) error {
config := meta.(*Config)
servicesService := servicemanagement.NewServicesService(config.clientServiceMan)
service, err := servicesService.GetConfig(d.Get("service_name").(string)).Do()
if err != nil {
return err
}
d.Set("config_id", service.Id)
d.Set("dns_address", service.Name)
d.Set("apis", flattenServiceManagementAPIs(service.Apis))
d.Set("endpoints", flattenServiceManagementEndpoints(service.Endpoints))
return nil
}
func flattenServiceManagementAPIs(apis []*servicemanagement.Api) []map[string]interface{} {
flattened := make([]map[string]interface{}, len(apis))
for i, a := range apis {
flattened[i] = map[string]interface{}{
"name": a.Name,
"version": a.Version,
"syntax": a.Syntax,
"methods": flattenServiceManagementMethods(a.Methods),
}
}
return flattened
}
func flattenServiceManagementMethods(methods []*servicemanagement.Method) []map[string]interface{} {
flattened := make([]map[string]interface{}, len(methods))
for i, m := range methods {
flattened[i] = map[string]interface{}{
"name": m.Name,
"syntax": m.Syntax,
"request_type": m.RequestTypeUrl,
"response_type": m.ResponseTypeUrl,
}
}
return flattened
}
func flattenServiceManagementEndpoints(endpoints []*servicemanagement.Endpoint) []map[string]interface{} {
flattened := make([]map[string]interface{}, len(endpoints))
for i, e := range endpoints {
flattened[i] = map[string]interface{}{
"name": e.Name,
"address": e.Target,
}
}
return flattened
}

View File

@ -0,0 +1,124 @@
package google
import (
"testing"
"fmt"
"github.com/hashicorp/terraform/helper/acctest"
"github.com/hashicorp/terraform/helper/resource"
"github.com/hashicorp/terraform/terraform"
"google.golang.org/api/servicemanagement/v1"
)
func TestAccEndpointsService_basic(t *testing.T) {
t.Parallel()
random_name := "t-" + acctest.RandString(10)
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
Providers: testAccProviders,
Steps: []resource.TestStep{
resource.TestStep{
Config: testAccEndpointsService_basic(random_name),
Check: testAccCheckEndpointExistsByName(random_name),
},
},
})
}
func TestAccEndpointsService_grpc(t *testing.T) {
t.Parallel()
random_name := "t-" + acctest.RandString(10)
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
Providers: testAccProviders,
Steps: []resource.TestStep{
resource.TestStep{
Config: testAccEndpointsService_grpc(random_name),
Check: testAccCheckEndpointExistsByName(random_name),
},
},
})
}
func testAccEndpointsService_basic(random_name string) string {
return fmt.Sprintf(`resource "google_endpoints_service" "endpoints_service" {
service_name = "%s.endpoints.%s.cloud.goog"
project = "%s"
openapi_config = <<EOF
swagger: "2.0"
info:
description: "A simple Google Cloud Endpoints API example."
title: "Endpoints Example"
version: "1.0.0"
host: "%s.endpoints.%s.cloud.goog"
basePath: "/"
consumes:
- "application/json"
produces:
- "application/json"
schemes:
- "https"
paths:
"/echo":
post:
description: "Echo back a given message."
operationId: "echo"
produces:
- "application/json"
responses:
200:
description: "Echo"
schema:
$ref: "#/definitions/echoMessage"
parameters:
- description: "Message to echo"
in: body
name: message
required: true
schema:
$ref: "#/definitions/echoMessage"
security:
- api_key: []
definitions:
echoMessage:
properties:
message:
type: "string"
EOF
}`, random_name, getTestProjectFromEnv(), getTestProjectFromEnv(), random_name, getTestProjectFromEnv())
}
func testAccEndpointsService_grpc(random_name string) string {
return fmt.Sprintf(`resource "google_endpoints_service" "endpoints_service" {
service_name = "%s.endpoints.%s.cloud.goog"
project = "%s"
grpc_config = <<EOF
type: google.api.Service
config_version: 3
name: %s.endpoints.%s.cloud.goog
usage:
rules:
- selector: endpoints.examples.bookstore.Bookstore.ListShelves
allow_unregistered_calls: true
EOF
protoc_output = "${file("test-fixtures/test_api_descriptor.pb")}"
}`, random_name, getTestProjectFromEnv(), getTestProjectFromEnv(), random_name, getTestProjectFromEnv())
}
func testAccCheckEndpointExistsByName(random_name string) resource.TestCheckFunc {
return func(s *terraform.State) error {
config := testAccProvider.Meta().(*Config)
servicesService := servicemanagement.NewServicesService(config.clientServiceMan)
service, err := servicesService.GetConfig(fmt.Sprintf("%s.endpoints.%s.cloud.goog", random_name, config.Project)).Do()
if err != nil {
return err
}
if service != nil {
return nil
} else {
return fmt.Errorf("Service %s.endpoints.%s.cloud.goog does not seem to exist.", random_name, config.Project)
}
}
}

View File

@ -191,7 +191,7 @@ func enableService(s, pid string, config *Config) error {
if err != nil {
return err
}
waitErr := serviceManagementOperationWait(config, sop, "api to enable")
_, waitErr := serviceManagementOperationWait(config, sop, "api to enable")
if waitErr != nil {
return waitErr
}
@ -211,7 +211,7 @@ func disableService(s, pid string, config *Config) error {
return err
}
// Wait for the operation to complete
waitErr := serviceManagementOperationWait(config, sop, "api to disable")
_, waitErr := serviceManagementOperationWait(config, sop, "api to disable")
if waitErr != nil {
return waitErr
}

View File

@ -6,6 +6,7 @@ import (
"time"
"github.com/hashicorp/terraform/helper/resource"
"google.golang.org/api/googleapi"
"google.golang.org/api/servicemanagement/v1"
)
@ -39,11 +40,11 @@ func (w *ServiceManagementOperationWaiter) Conf() *resource.StateChangeConf {
}
}
func serviceManagementOperationWait(config *Config, op *servicemanagement.Operation, activity string) error {
func serviceManagementOperationWait(config *Config, op *servicemanagement.Operation, activity string) (googleapi.RawMessage, error) {
return serviceManagementOperationWaitTime(config, op, activity, 10)
}
func serviceManagementOperationWaitTime(config *Config, op *servicemanagement.Operation, activity string, timeoutMin int) error {
func serviceManagementOperationWaitTime(config *Config, op *servicemanagement.Operation, activity string, timeoutMin int) (googleapi.RawMessage, error) {
w := &ServiceManagementOperationWaiter{
Service: config.clientServiceMan,
Op: op,
@ -55,13 +56,13 @@ func serviceManagementOperationWaitTime(config *Config, op *servicemanagement.Op
state.MinTimeout = 2 * time.Second
opRaw, err := state.WaitForState()
if err != nil {
return fmt.Errorf("Error waiting for %s: %s", activity, err)
return nil, fmt.Errorf("Error waiting for %s: %s", activity, err)
}
op = opRaw.(*servicemanagement.Operation)
if op.Error != nil {
return fmt.Errorf("Error code %v, message: %s", op.Error.Code, op.Error.Message)
return nil, fmt.Errorf("Error code %v, message: %s", op.Error.Code, op.Error.Message)
}
return nil
return op.Response, nil
}

Binary file not shown.

View File

@ -0,0 +1,63 @@
---
layout: "google"
page_title: "Google: google_endpoints_service"
sidebar_current: "docs-google-endpoints-service"
description: |-
Creates and rolls out a Google Endpoints service.
---
# google_endpoints_service
This resource creates and rolls out a Cloud Endpoints service using OpenAPI or gRPC. View the relevant docs for [OpenAPI](https://cloud.google.com/endpoints/docs/openapi/) and [gRPC](https://cloud.google.com/endpoints/docs/grpc/).
## Example Usage
```hcl
resource "google_endpoints_service" "openapi_service" {
service_name = "api-name.endpoints.project-id.cloud.goog"
project = "project-id"
openapi_config = "${file("openapi_spec.yml")}"
}
resource "google_endpoints_service" "grpc_service" {
service_name = "api-name.endpoints.project-id.cloud.goog"
project = "project-id"
grpc_config = "${file("service_spec.yml")}"
protoc_output = "${file("compiled_descriptor_file.pb")}"
}
```
The example in `examples/endpoints_on_compute_engine` shows the API from the quickstart running on a Compute Engine VM and reachable through Cloud Endpoints, which may also be useful.
## Argument Reference
The following arguments are supported:
* `service_name`: (Required) The name of the service. Usually of the form `$apiname.endpoints.$projectid.cloud.goog`.
* `openapi_config`: (Optional) The full text of the OpenAPI YAML configuration as described [here](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md). Either this, or *both* of `grpc_config` and `protoc_output` must be specified.
* `grpc_config`: (Optional) The full text of the Service Config YAML file (Example located [here](https://github.com/GoogleCloudPlatform/python-docs-samples/blob/master/endpoints/bookstore-grpc/api_config.yaml)). If provided, must also provide `protoc_output`. `open_api` config must *not* be provided.
* `protoc_output`: (Optional) The full contents of the Service Descriptor File generated by protoc. This should be a compiled .pb file.
* `project`: (Optional) The project ID that the service belongs to. If not provided, provider project is used.
## Attributes Reference
In addition to the arguments, the following attributes are available:
* `config_id`: The autogenerated ID for the configuration that is rolled out as part of the creation of this resource. Must be provided to compute engine instances as a tag.
* `dns_address`: The address at which the service can be found - usually the same as the service name.
* `apis`: A list of API objects; structure is documented below.
* `endpoints`: A list of Endpoint objects; structure is documented below.
- - -
### API Object Structure
* `name`: The FQDN of the API as described in the provided config.
* `syntax`: `SYNTAX_PROTO2` or `SYNTAX_PROTO3`.
* `version`: A version string for this api. If specified, will have the form major-version.minor-version, e.g. `1.10`.
* `methods`: A list of Method objects; structure is documented below.
### Method Object Structure
* `name`: The simple name of this method as described in the provided config.
* `syntax`: `SYNTAX_PROTO2` or `SYNTAX_PROTO3`.
* `request_type`: The type URL for the request to this API.
* `response_type`: The type URL for the response from this API.
### Endpoint Object Structure
* `name`: The simple name of the endpoint as described in the config.
* `address`: The FQDN of the endpoint as described in the config.

View File

@ -396,6 +396,15 @@
</ul>
</li>
<li<%= sidebar_current("docs-google-endpoints") %>>
<a href="#">Google Endpoints Resources</a>
<ul class="nav nav-visible">
<li<%= sidebar_current("docs-google-endpoints-service") %>>
<a href="/docs/providers/google/r/endpoints_service.html">google_endpoints_service</a>
</li>
</ul>
</li>
<li<%= sidebar_current("docs-google-pubsub") %>>
<a href="#">Google PubSub Resources</a>
<ul class="nav nav-visible">