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 }