github.com/Jeffail/benthos/v3@v3.65.0/lib/output/writer/amqp.go (about) 1 package writer 2 3 import ( 4 "context" 5 "crypto/tls" 6 "errors" 7 "fmt" 8 "net/url" 9 "strconv" 10 "strings" 11 "sync" 12 "time" 13 14 "github.com/Jeffail/benthos/v3/internal/bloblang/field" 15 "github.com/Jeffail/benthos/v3/internal/interop" 16 "github.com/Jeffail/benthos/v3/internal/metadata" 17 "github.com/Jeffail/benthos/v3/lib/log" 18 "github.com/Jeffail/benthos/v3/lib/metrics" 19 "github.com/Jeffail/benthos/v3/lib/types" 20 btls "github.com/Jeffail/benthos/v3/lib/util/tls" 21 amqp "github.com/rabbitmq/amqp091-go" 22 ) 23 24 const defaultDeprecatedAMQP09URL = "amqp://guest:guest@localhost:5672/" 25 26 var errAMQP09Connect = errors.New("AMQP 0.9 Connect") 27 28 //------------------------------------------------------------------------------ 29 30 // AMQPExchangeDeclareConfig contains fields indicating whether the target AMQP 31 // exchange needs to be declared, as well as any fields specifying how to 32 // accomplish that. 33 type AMQPExchangeDeclareConfig struct { 34 Enabled bool `json:"enabled" yaml:"enabled"` 35 Type string `json:"type" yaml:"type"` 36 Durable bool `json:"durable" yaml:"durable"` 37 } 38 39 // AMQPConfig contains configuration fields for the AMQP output type. 40 type AMQPConfig struct { 41 URL string `json:"url" yaml:"url"` 42 URLs []string `json:"urls" yaml:"urls"` 43 MaxInFlight int `json:"max_in_flight" yaml:"max_in_flight"` 44 Exchange string `json:"exchange" yaml:"exchange"` 45 ExchangeDeclare AMQPExchangeDeclareConfig `json:"exchange_declare" yaml:"exchange_declare"` 46 BindingKey string `json:"key" yaml:"key"` 47 Type string `json:"type" yaml:"type"` 48 ContentType string `json:"content_type" yaml:"content_type"` 49 ContentEncoding string `json:"content_encoding" yaml:"content_encoding"` 50 Metadata metadata.ExcludeFilterConfig `json:"metadata" yaml:"metadata"` 51 Priority string `json:"priority" yaml:"priority"` 52 Persistent bool `json:"persistent" yaml:"persistent"` 53 Mandatory bool `json:"mandatory" yaml:"mandatory"` 54 Immediate bool `json:"immediate" yaml:"immediate"` 55 TLS btls.Config `json:"tls" yaml:"tls"` 56 } 57 58 // NewAMQPConfig creates a new AMQPConfig with default values. 59 func NewAMQPConfig() AMQPConfig { 60 return AMQPConfig{ 61 URL: defaultDeprecatedAMQP09URL, 62 URLs: []string{}, 63 MaxInFlight: 1, 64 Exchange: "benthos-exchange", 65 ExchangeDeclare: AMQPExchangeDeclareConfig{ 66 Enabled: false, 67 Type: "direct", 68 Durable: true, 69 }, 70 BindingKey: "benthos-key", 71 Type: "", 72 ContentType: "application/octet-stream", 73 ContentEncoding: "", 74 Metadata: metadata.NewExcludeFilterConfig(), 75 Priority: "", 76 Persistent: false, 77 Mandatory: false, 78 Immediate: false, 79 TLS: btls.NewConfig(), 80 } 81 } 82 83 //------------------------------------------------------------------------------ 84 85 // AMQP is an output type that serves AMQP messages. 86 type AMQP struct { 87 key *field.Expression 88 msgType *field.Expression 89 contentType *field.Expression 90 contentEncoding *field.Expression 91 priority *field.Expression 92 metaFilter *metadata.ExcludeFilter 93 94 log log.Modular 95 stats metrics.Type 96 97 conf AMQPConfig 98 urls []string 99 tlsConf *tls.Config 100 101 conn *amqp.Connection 102 amqpChan *amqp.Channel 103 confirmChan <-chan amqp.Confirmation 104 returnChan <-chan amqp.Return 105 106 deliveryMode uint8 107 108 connLock sync.RWMutex 109 } 110 111 // NewAMQP creates a new AMQP writer type. 112 // 113 // Deprecated: use the V2 API instead. 114 func NewAMQP(conf AMQPConfig, log log.Modular, stats metrics.Type) (*AMQP, error) { 115 return NewAMQPV2(types.NoopMgr(), conf, log, stats) 116 } 117 118 // NewAMQPV2 creates a new AMQP writer type. 119 func NewAMQPV2(mgr types.Manager, conf AMQPConfig, log log.Modular, stats metrics.Type) (*AMQP, error) { 120 a := AMQP{ 121 log: log, 122 stats: stats, 123 conf: conf, 124 deliveryMode: amqp.Transient, 125 } 126 var err error 127 if a.metaFilter, err = conf.Metadata.Filter(); err != nil { 128 return nil, fmt.Errorf("failed to construct metadata filter: %w", err) 129 } 130 if a.key, err = interop.NewBloblangField(mgr, conf.BindingKey); err != nil { 131 return nil, fmt.Errorf("failed to parse binding key expression: %v", err) 132 } 133 if a.msgType, err = interop.NewBloblangField(mgr, conf.Type); err != nil { 134 return nil, fmt.Errorf("failed to parse type property expression: %v", err) 135 } 136 if a.contentType, err = interop.NewBloblangField(mgr, conf.ContentType); err != nil { 137 return nil, fmt.Errorf("failed to parse content_type property expression: %v", err) 138 } 139 if a.contentEncoding, err = interop.NewBloblangField(mgr, conf.ContentEncoding); err != nil { 140 return nil, fmt.Errorf("failed to parse content_encoding property expression: %v", err) 141 } 142 if a.priority, err = interop.NewBloblangField(mgr, conf.Priority); err != nil { 143 return nil, fmt.Errorf("failed to parse priority property expression: %w", err) 144 } 145 if conf.Persistent { 146 a.deliveryMode = amqp.Persistent 147 } 148 149 if conf.URL != defaultDeprecatedAMQP09URL && len(conf.URLs) > 0 { 150 return nil, errors.New("cannot mix both the field `url` and `urls`") 151 } 152 153 if len(conf.URLs) == 0 { 154 a.urls = append(a.urls, conf.URL) 155 } 156 157 for _, u := range conf.URLs { 158 for _, splitURL := range strings.Split(u, ",") { 159 if trimmed := strings.TrimSpace(splitURL); len(trimmed) > 0 { 160 a.urls = append(a.urls, trimmed) 161 } 162 } 163 } 164 165 if conf.TLS.Enabled { 166 if a.tlsConf, err = conf.TLS.Get(); err != nil { 167 return nil, err 168 } 169 } 170 return &a, nil 171 } 172 173 //------------------------------------------------------------------------------ 174 175 // ConnectWithContext establishes a connection to an AMQP server. 176 func (a *AMQP) ConnectWithContext(ctx context.Context) error { 177 return a.Connect() 178 } 179 180 // Connect establishes a connection to an AMQP server. 181 func (a *AMQP) Connect() error { 182 a.connLock.Lock() 183 defer a.connLock.Unlock() 184 185 conn, err := a.reDial(a.urls) 186 if err != nil { 187 return err 188 } 189 190 var amqpChan *amqp.Channel 191 if amqpChan, err = conn.Channel(); err != nil { 192 conn.Close() 193 return fmt.Errorf("amqp failed to create channel: %v", err) 194 } 195 196 if a.conf.ExchangeDeclare.Enabled { 197 if err = amqpChan.ExchangeDeclare( 198 a.conf.Exchange, // name of the exchange 199 a.conf.ExchangeDeclare.Type, // type 200 a.conf.ExchangeDeclare.Durable, // durable 201 false, // delete when complete 202 false, // internal 203 false, // noWait 204 nil, // arguments 205 ); err != nil { 206 conn.Close() 207 return fmt.Errorf("amqp failed to declare exchange: %v", err) 208 } 209 } 210 211 if err = amqpChan.Confirm(false); err != nil { 212 conn.Close() 213 return fmt.Errorf("amqp channel could not be put into confirm mode: %v", err) 214 } 215 216 a.conn = conn 217 a.amqpChan = amqpChan 218 a.confirmChan = amqpChan.NotifyPublish(make(chan amqp.Confirmation, a.conf.MaxInFlight)) 219 if a.conf.Mandatory || a.conf.Immediate { 220 a.returnChan = amqpChan.NotifyReturn(make(chan amqp.Return, 1)) 221 } 222 223 a.log.Infof("Sending AMQP messages to exchange: %v\n", a.conf.Exchange) 224 return nil 225 } 226 227 // disconnect safely closes a connection to an AMQP server. 228 func (a *AMQP) disconnect() error { 229 a.connLock.Lock() 230 defer a.connLock.Unlock() 231 232 if a.amqpChan != nil { 233 a.amqpChan = nil 234 } 235 if a.conn != nil { 236 if err := a.conn.Close(); err != nil { 237 a.log.Errorf("Failed to close connection cleanly: %v\n", err) 238 } 239 a.conn = nil 240 } 241 return nil 242 } 243 244 //------------------------------------------------------------------------------ 245 246 // WriteWithContext will attempt to write a message over AMQP, wait for 247 // acknowledgement, and returns an error if applicable. 248 func (a *AMQP) WriteWithContext(ctx context.Context, msg types.Message) error { 249 return a.Write(msg) 250 } 251 252 // Write will attempt to write a message over AMQP, wait for acknowledgement, 253 // and returns an error if applicable. 254 func (a *AMQP) Write(msg types.Message) error { 255 a.connLock.RLock() 256 conn := a.conn 257 amqpChan := a.amqpChan 258 confirmChan := a.confirmChan 259 returnChan := a.returnChan 260 a.connLock.RUnlock() 261 262 if conn == nil { 263 return types.ErrNotConnected 264 } 265 266 return IterateBatchedSend(msg, func(i int, p types.Part) error { 267 bindingKey := strings.ReplaceAll(a.key.String(i, msg), "/", ".") 268 msgType := strings.ReplaceAll(a.msgType.String(i, msg), "/", ".") 269 contentType := a.contentType.String(i, msg) 270 contentEncoding := a.contentEncoding.String(i, msg) 271 272 var priority uint8 273 if priorityString := a.priority.String(i, msg); priorityString != "" { 274 priorityInt, err := strconv.Atoi(priorityString) 275 if err != nil { 276 return fmt.Errorf("failed to parse valid integer from priority expression: %w", err) 277 } 278 if priorityInt > 9 || priorityInt < 0 { 279 return fmt.Errorf("invalid priority parsed from expression, must be <= 9 and >= 0, got %v", priorityInt) 280 } 281 priority = uint8(priorityInt) 282 } 283 284 headers := amqp.Table{} 285 a.metaFilter.Iter(p.Metadata(), func(k, v string) error { 286 headers[strings.ReplaceAll(k, "_", "-")] = v 287 return nil 288 }) 289 290 err := amqpChan.Publish( 291 a.conf.Exchange, // publish to an exchange 292 bindingKey, // routing to 0 or more queues 293 a.conf.Mandatory, // mandatory 294 a.conf.Immediate, // immediate 295 amqp.Publishing{ 296 Headers: headers, 297 ContentType: contentType, 298 ContentEncoding: contentEncoding, 299 Body: p.Get(), 300 DeliveryMode: a.deliveryMode, // 1=non-persistent, 2=persistent 301 Priority: priority, // 0-9 302 Type: msgType, 303 // a bunch of application/implementation-specific fields 304 }, 305 ) 306 if err != nil { 307 a.disconnect() 308 a.log.Errorf("Failed to send message: %v\n", err) 309 return types.ErrNotConnected 310 } 311 select { 312 case confirm, open := <-confirmChan: 313 if !open { 314 a.log.Errorln("Failed to send message, ensure your target exchange exists.") 315 return types.ErrNotConnected 316 } 317 if !confirm.Ack { 318 a.log.Errorln("Failed to acknowledge message.") 319 return types.ErrNoAck 320 } 321 case _, open := <-returnChan: 322 if !open { 323 return fmt.Errorf("acknowledgement not supported, ensure server supports immediate and mandatory flags") 324 } 325 return types.ErrNoAck 326 } 327 return nil 328 }) 329 } 330 331 // CloseAsync shuts down the AMQP output and stops processing messages. 332 func (a *AMQP) CloseAsync() { 333 a.disconnect() 334 } 335 336 // WaitForClose blocks until the AMQP output has closed down. 337 func (a *AMQP) WaitForClose(timeout time.Duration) error { 338 return nil 339 } 340 341 //------------------------------------------------------------------------------ 342 343 // reDial connection to amqp with one or more fallback URLs 344 func (a *AMQP) reDial(urls []string) (conn *amqp.Connection, err error) { 345 for _, u := range urls { 346 conn, err = a.dial(u) 347 if err != nil { 348 if errors.Is(err, errAMQP09Connect) { 349 continue 350 } 351 break 352 } 353 return conn, nil 354 } 355 return nil, err 356 } 357 358 // dial attempts to connect to amqp URL 359 func (a *AMQP) dial(amqpURL string) (conn *amqp.Connection, err error) { 360 u, err := url.Parse(amqpURL) 361 if err != nil { 362 return nil, fmt.Errorf("invalid AMQP URL: %w", err) 363 } 364 365 if a.conf.TLS.Enabled { 366 if u.User != nil { 367 conn, err = amqp.DialTLS(amqpURL, a.tlsConf) 368 if err != nil { 369 return nil, fmt.Errorf("%w: %s", errAMQP09Connect, err) 370 } 371 } else { 372 conn, err = amqp.DialTLS_ExternalAuth(amqpURL, a.tlsConf) 373 if err != nil { 374 return nil, fmt.Errorf("%w: %s", errAMQP09Connect, err) 375 } 376 } 377 } else { 378 conn, err = amqp.Dial(amqpURL) 379 if err != nil { 380 return nil, fmt.Errorf("%w: %s", errAMQP09Connect, err) 381 } 382 } 383 384 return conn, nil 385 }