go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/scheduler/appengine/engine/pubsub.go (about)

     1  // Copyright 2015 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package engine
    16  
    17  import (
    18  	"context"
    19  	"net/http"
    20  	"sort"
    21  	"strings"
    22  
    23  	"google.golang.org/api/googleapi"
    24  	"google.golang.org/api/pubsub/v1"
    25  
    26  	"go.chromium.org/luci/common/data/stringset"
    27  	"go.chromium.org/luci/common/errors"
    28  	"go.chromium.org/luci/common/logging"
    29  	"go.chromium.org/luci/common/retry/transient"
    30  	"go.chromium.org/luci/server/auth"
    31  )
    32  
    33  // createPubSubService returns configured instance of pubsub.Service.
    34  func createPubSubService(c context.Context, pubSubURL string) (*pubsub.Service, error) {
    35  	// In real mode (not a unit test), use authenticated transport.
    36  	var transport http.RoundTripper
    37  	if pubSubURL == "" {
    38  		var err error
    39  		transport, err = auth.GetRPCTransport(c, auth.AsSelf, auth.WithScopes(pubsub.PubsubScope))
    40  		if err != nil {
    41  			return nil, err
    42  		}
    43  	} else {
    44  		transport = http.DefaultTransport
    45  	}
    46  	service, err := pubsub.New(&http.Client{Transport: transport})
    47  	if err != nil {
    48  		return nil, err
    49  	}
    50  	if pubSubURL != "" {
    51  		service.BasePath = pubSubURL
    52  	}
    53  	return service, nil
    54  }
    55  
    56  // configureTopic creates PubSub topic and subscription, allowing given
    57  // publisher to send messages to the topic.
    58  //
    59  // Both topic and subscription names are fully qualified PubSub resource IDs,
    60  // e.g. "projects/<id>/topics/<id>".
    61  //
    62  // Idempotent.
    63  func configureTopic(c context.Context, topic, sub, pushURL, publisher, pubSubURL string) error {
    64  	service, err := createPubSubService(c, pubSubURL)
    65  	if err != nil {
    66  		return err
    67  	}
    68  
    69  	// Create the topic. Ignore HTTP 409 (it means the topic already exists).
    70  	logging.Infof(c, "Ensuring topic %q exists", topic)
    71  	_, err = service.Projects.Topics.Create(topic, &pubsub.Topic{}).Context(c).Do()
    72  	if err != nil && !isHTTP409(err) {
    73  		logging.Errorf(c, "Failed - %s", err)
    74  		return transient.Tag.Apply(err)
    75  	}
    76  
    77  	// Create the subscription to this topic. Ignore HTTP 409.
    78  	logging.Infof(c, "Ensuring subscription %q exists", sub)
    79  	_, err = service.Projects.Subscriptions.Create(sub, &pubsub.Subscription{
    80  		Topic:              topic,
    81  		AckDeadlineSeconds: 70, // GAE request timeout plus some spare time
    82  		PushConfig: &pubsub.PushConfig{
    83  			PushEndpoint: pushURL, // if "", the subscription will be pull based
    84  		},
    85  	}).Context(c).Do()
    86  	if err != nil && !isHTTP409(err) {
    87  		logging.Errorf(c, "Failed - %s", err)
    88  		return transient.Tag.Apply(err)
    89  	}
    90  
    91  	// Modify topic's IAM policy to allow publisher to publish.
    92  	if strings.HasSuffix(publisher, ".gserviceaccount.com") {
    93  		publisher = "serviceAccount:" + publisher
    94  	} else {
    95  		publisher = "user:" + publisher
    96  	}
    97  	logging.Infof(c, "Ensuring %q can publish to the topic", publisher)
    98  
    99  	// Do two attempts, to account for possible race condition. Two attempts
   100  	// should be enough to handle concurrent calls to 'configureTopic': second
   101  	// attempt will read already correct IAM policy and will just end right away.
   102  	for attempt := 0; attempt < 2; attempt++ {
   103  		err = modifyTopicIAMPolicy(c, service, topic, func(policy iamPolicy) error {
   104  			policy.grantRole("roles/pubsub.publisher", publisher)
   105  			return nil
   106  		})
   107  		if err == nil {
   108  			return nil
   109  		}
   110  		logging.Errorf(c, "Failed - %s", err)
   111  	}
   112  	return transient.Tag.Apply(err)
   113  }
   114  
   115  // pullSubcription pulls one message from PubSub subscription.
   116  //
   117  // Used on dev server only. Returns the message and callback to call to
   118  // acknowledge the message.
   119  func pullSubcription(c context.Context, subscription, pubSubURL string) (*pubsub.PubsubMessage, func(), error) {
   120  	service, err := createPubSubService(c, pubSubURL)
   121  	if err != nil {
   122  		return nil, nil, err
   123  	}
   124  
   125  	resp, err := service.Projects.Subscriptions.Pull(subscription, &pubsub.PullRequest{
   126  		ReturnImmediately: true,
   127  		MaxMessages:       1,
   128  	}).Context(c).Do()
   129  	if err != nil {
   130  		return nil, nil, err
   131  	}
   132  
   133  	switch len(resp.ReceivedMessages) {
   134  	case 0:
   135  		return nil, nil, nil
   136  	case 1:
   137  		ackID := resp.ReceivedMessages[0].AckId
   138  		ackCb := func() {
   139  			_, err := service.Projects.Subscriptions.Acknowledge(subscription, &pubsub.AcknowledgeRequest{
   140  				AckIds: []string{ackID},
   141  			}).Context(c).Do()
   142  			if err != nil {
   143  				logging.Errorf(c, "Failed to acknowledge the message - %s", err)
   144  			}
   145  		}
   146  		return resp.ReceivedMessages[0].Message, ackCb, nil
   147  	default:
   148  		panic(errors.New("received more than one message from PubSub while asking only one"))
   149  	}
   150  }
   151  
   152  func isHTTP409(err error) bool {
   153  	apiErr, _ := err.(*googleapi.Error)
   154  	return apiErr != nil && apiErr.Code == 409
   155  }
   156  
   157  // modifyTopicIAMPolicy reads IAM policy, calls callback to modify it, and then
   158  // puts it back (if callback really changed it).
   159  func modifyTopicIAMPolicy(c context.Context, service *pubsub.Service, topic string, cb func(iamPolicy) error) error {
   160  	policy, err := service.Projects.Topics.GetIamPolicy(topic).Context(c).Do()
   161  	if err != nil {
   162  		return err
   163  	}
   164  
   165  	// Convert the policy to a map. Make a copy to be mutated by the callback.
   166  	// Need to store the original to detect changes done by the callback.
   167  	roles := iamPolicyFromBindings(policy.Bindings)
   168  	clone := roles.clone()
   169  	if err = cb(clone); err != nil {
   170  		return err
   171  	}
   172  
   173  	// Skip storing if no changes are made.
   174  	if clone.isEqual(roles) {
   175  		return nil
   176  	}
   177  
   178  	// Convert back to IamPolicy struct.
   179  	logging.Infof(c, "Updating IAM policy of %q", topic)
   180  	request := &pubsub.SetIamPolicyRequest{
   181  		Policy: &pubsub.Policy{
   182  			Bindings: clone.toBindings(),
   183  			Etag:     policy.Etag,
   184  		},
   185  	}
   186  	_, err = service.Projects.Topics.SetIamPolicy(topic, request).Context(c).Do()
   187  	return err
   188  }
   189  
   190  // iamPolicy is the IAM policy doc: map {role -> set of members}.
   191  type iamPolicy map[string]stringset.Set
   192  
   193  func iamPolicyFromBindings(bindings []*pubsub.Binding) iamPolicy {
   194  	roles := make(iamPolicy, len(bindings))
   195  	for _, b := range bindings {
   196  		roles[b.Role] = stringset.NewFromSlice(b.Members...)
   197  	}
   198  	return roles
   199  }
   200  
   201  func (p iamPolicy) toBindings() []*pubsub.Binding {
   202  	// Sort by role name.
   203  	roles := make([]string, 0, len(p))
   204  	for role := range p {
   205  		roles = append(roles, role)
   206  	}
   207  	sort.Strings(roles)
   208  
   209  	// Sort members list too.
   210  	bindings := make([]*pubsub.Binding, 0, len(p))
   211  	for _, role := range roles {
   212  		members := p[role].ToSlice()
   213  		sort.Strings(members)
   214  		bindings = append(bindings, &pubsub.Binding{
   215  			Role:    role,
   216  			Members: members,
   217  		})
   218  	}
   219  	return bindings
   220  }
   221  
   222  func (p iamPolicy) clone() iamPolicy {
   223  	clone := make(iamPolicy, len(p))
   224  	for k, v := range p {
   225  		clone[k] = v.Dup()
   226  	}
   227  	return clone
   228  }
   229  
   230  func (p iamPolicy) isEqual(another iamPolicy) bool {
   231  	if len(p) != len(another) {
   232  		return false
   233  	}
   234  	for k, right := range another {
   235  		left := p[k]
   236  		if left.Len() != right.Len() {
   237  			return false
   238  		}
   239  		equal := true
   240  		left.Iter(func(item string) bool {
   241  			if !right.Has(item) {
   242  				equal = false
   243  				return false
   244  			}
   245  			return true
   246  		})
   247  		if !equal {
   248  			return false
   249  		}
   250  	}
   251  	return true
   252  }
   253  
   254  func (p iamPolicy) grantRole(role, principal string) {
   255  	switch existing := p[role]; {
   256  	case existing != nil && existing.Has(principal): // already there
   257  		return
   258  	case existing != nil: // the role is there, but not the principal
   259  		existing.Add(principal)
   260  	default:
   261  		p[role] = stringset.NewFromSlice(principal)
   262  	}
   263  }