-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathmain.go
More file actions
335 lines (294 loc) · 9.22 KB
/
Copy pathmain.go
File metadata and controls
335 lines (294 loc) · 9.22 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
package main
import (
"bufio"
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/aws/aws-lambda-go/events"
"github.com/aws/aws-lambda-go/lambda"
"github.com/aws/aws-lambda-go/lambdacontext"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/dynamodb"
"github.com/aws/aws-sdk-go/service/sns"
"github.com/go-sql-driver/mysql"
"github.com/google/go-github/v33/github"
"github.com/pkg/errors"
)
// SQS consumer for the from-github queue.
//
// The request path is API Gateway HTTP API → SQS direct integration, so this
// Lambda is only ever triggered by SQS records. Each record's body is the raw
// GitHub PushEvent JSON forwarded by API GW. Failures bubble up to the Lambda
// runtime; SQS retries up to maxReceiveCount (5) before parking the message
// in the DLQ. GitHub never sees a 5xx — API Gateway acks each delivery the
// moment SQS accepts the message.
//
// A direct-invocation fallback (PushEvent JSON) is kept for local runs and
// `aws lambda invoke` testing.
type App struct {
DynamoDB *dynamodb.DynamoDB
CursorTable string
SQLDriver *sql.DB
SNSClient *sns.SNS
Region string
AwsAccountId string
}
type Category struct {
GithubFile string
SQLTable string
SNSTopic string
DynamoCursor string
IDField string
NameField string
}
const region = "eu-west-1"
var categories = map[string]Category{
"youtube-channels.csv": {
"youtube-channels.csv",
"yt_channels",
"arn:aws:sns:%s:%s:mirrorfm_incoming_youtube_channel",
"from_github_last_successful_channel",
"channel_id",
"channel_name",
},
"discogs-labels.csv": {
"discogs-labels.csv",
"dg_labels",
"arn:aws:sns:%s:%s:mirrorfm_incoming_discogs_label",
"from_github_last_successful_label",
"label_id",
"label_name",
},
}
func dispatch(ctx context.Context, raw json.RawMessage) error {
if isSQSEvent(raw) {
var evt events.SQSEvent
if err := json.Unmarshal(raw, &evt); err != nil {
return errors.Wrap(err, "decode SQSEvent")
}
for _, record := range evt.Records {
var push github.PushEvent
if err := json.Unmarshal([]byte(record.Body), &push); err != nil {
return errors.Wrap(err, "decode PushEvent from SQS body")
}
if err := ProcessPushEvent(ctx, push); err != nil {
return err
}
}
return nil
}
var push github.PushEvent
if err := json.Unmarshal(raw, &push); err != nil {
return errors.Wrap(err, "unrecognized event shape")
}
return ProcessPushEvent(ctx, push)
}
func isSQSEvent(raw json.RawMessage) bool {
var probe struct {
Records []struct {
EventSource string `json:"eventSource"`
} `json:"Records"`
}
if err := json.Unmarshal(raw, &probe); err != nil {
return false
}
return len(probe.Records) > 0 && probe.Records[0].EventSource == "aws:sqs"
}
// dataRepo is hardcoded so the webhook body cannot redirect the fetch at a
// foreign repo. The webhook URL is publicly discoverable and HMAC is not
// verified at ingress; without this, an attacker could POST arbitrary
// repository.full_name and have us ingest channel IDs from any public repo.
const dataRepo = "mirrorfm/data"
func ProcessPushEvent(ctx context.Context, evt github.PushEvent) error {
fmt.Printf("%+v\n", evt)
if evt.HeadCommit == nil || evt.HeadCommit.Modified == nil {
fmt.Println("ignored incorrect event: some fields missing")
return nil
}
app, err := getApp(ctx)
if err != nil {
return errors.Wrap(err, "could not set up app")
}
for _, file := range evt.HeadCommit.Modified {
if _, ok := categories[file]; !ok {
continue // ignore changes to other files
}
current, err := app.ProcessFile(dataRepo, file)
if err != nil {
return errors.Wrap(err, fmt.Sprintf("could not process file %s", file))
}
if err := app.SaveCursor(categories[file].DynamoCursor, current); err != nil {
return errors.Wrap(err, fmt.Sprintf("could not save cursor %d for file %s", current, categories[file].DynamoCursor))
}
}
return nil
}
func getApp(ctx context.Context) (App, error) {
dbHost := os.Getenv("DB_HOST")
dbUser := os.Getenv("DB_USERNAME")
dbPass := os.Getenv("DB_PASSWORD")
dbName := os.Getenv("DB_NAME")
sqlDriver, err := sql.Open("mysql", dbUser+":"+dbPass+"@tcp("+dbHost+")/"+dbName+"?parseTime=true")
if err != nil {
return App{}, errors.Wrap(err, "failed to set up DB client")
}
sess := session.Must(session.NewSessionWithOptions(session.Options{
SharedConfigState: session.SharedConfigEnable,
}))
dynamoClient := dynamodb.New(sess, &aws.Config{Region: aws.String(region)})
snsClient := sns.New(sess, &aws.Config{Region: aws.String(region)})
awsAccountId, ok := os.LookupEnv("AWS_ACCOUNT_ID")
if !ok {
lc, ok := lambdacontext.FromContext(ctx)
if !ok {
return App{}, errors.New("missing environment variable AWS_ACCOUNT_ID")
}
awsAccountId = strings.Split(lc.InvokedFunctionArn, ":")[4]
}
return App{
DynamoDB: dynamoClient,
CursorTable: "mirrorfm_cursors",
SQLDriver: sqlDriver,
SNSClient: snsClient,
Region: region,
AwsAccountId: awsAccountId,
}, nil
}
func (client *App) ProcessFile(repo, file string) (int, error) {
url := strings.Join([]string{"https://raw.githubusercontent.com", repo, "master", file}, "/")
resp, err := http.Get(url)
if err != nil {
return 0, errors.Wrap(err, fmt.Sprintf("failed to get %s", url))
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("status %d for %s", resp.StatusCode, url)
}
var lines []string
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
if len(lines) == 0 {
return 0, errors.New("nothing in file")
}
cat := categories[file]
cat.SNSTopic = fmt.Sprintf(cat.SNSTopic, client.Region, client.AwsAccountId)
current, err := client.GetCursor(cat.DynamoCursor)
if err != nil {
return 0, errors.Wrap(err, "could not get cursor")
}
current, err = client.processLines(lines, current, cat)
if err != nil {
return 0, errors.Wrap(err, "failed to process lines")
}
return current, nil
}
func (client *App) processLines(lines []string, current int, cat Category) (int, error) {
total := len(lines) - 1
for current < total {
current += 1
currentLine := lines[current]
parts := strings.Split(currentLine, ",")
id := parts[0]
name := parts[1]
if id == "" {
fmt.Printf("line %s is empty", id)
break
}
err := client.InsertIntoTable(id, name, cat)
if err != nil {
if isDuplicateKey(err) {
fmt.Printf("skip duplicate #%d: %s\n", current, id)
continue
}
// Surface non-duplicate errors so the Lambda fails and SQS retries.
// Historically these were silently swallowed as "skip duplicate",
// which advanced the cursor past genuine failures and dropped rows
// (e.g. overstand87, _epler_).
return current - 1, errors.Wrap(err, fmt.Sprintf("insert #%d (%s) failed", current, id))
}
_, err = client.SNSClient.Publish(&sns.PublishInput{
TopicArn: aws.String(cat.SNSTopic),
Message: aws.String(id),
})
if err != nil {
return current, errors.Wrap(err, fmt.Sprintf("failed to publish %s on %s\n", id, cat.SNSTopic))
}
fmt.Printf("published %s on %s\n", id, cat.SNSTopic)
}
return current, nil
}
// isDuplicateKey returns true if err is the MySQL "Duplicate entry" error
// (code 1062). Any other error must NOT be silently treated as a skip — that
// historically lost rows when the cursor advanced past genuine failures.
func isDuplicateKey(err error) bool {
var mysqlErr *mysql.MySQLError
cause := errors.Cause(err)
if me, ok := cause.(*mysql.MySQLError); ok {
mysqlErr = me
}
return mysqlErr != nil && mysqlErr.Number == 1062
}
func (client *App) InsertIntoTable(id, name string, cat Category) error {
_, err := client.SQLDriver.Exec(fmt.Sprintf(`
INSERT INTO %s (%s, %s, added_datetime)
VALUES (?, ?, ?)
`, cat.SQLTable, cat.IDField, cat.NameField), id, strings.TrimSpace(name), time.Now())
if err != nil {
return errors.Wrap(err, fmt.Sprintf("failed to insert into %s", cat.SQLTable))
}
return nil
}
func (client *App) GetCursor(cursor string) (int, error) {
resp, err := client.DynamoDB.GetItem(&dynamodb.GetItemInput{
TableName: &client.CursorTable,
Key: map[string]*dynamodb.AttributeValue{
"name": {S: aws.String(cursor)},
},
AttributesToGet: []*string{aws.String("value")},
})
if err != nil {
return 0, err
}
val, ok := resp.Item["value"]
if !ok {
return 0, nil
}
return strconv.Atoi(*val.N)
}
func (client *App) SaveCursor(cursor string, value int) error {
if _, err := client.DynamoDB.PutItem(&dynamodb.PutItemInput{
TableName: &client.CursorTable,
Item: map[string]*dynamodb.AttributeValue{
"name": {S: aws.String(cursor)},
"value": {N: aws.String(strconv.Itoa(value))},
},
}); err != nil {
return errors.Wrap(err, fmt.Sprintf("failed to save %s cursor", cursor))
}
fmt.Printf("successfully set cursor to %d\n", value)
return nil
}
func main() {
if os.Getenv("AWS_LAMBDA_FUNCTION_NAME") != "" {
lambda.Start(dispatch)
return
}
name := "mirrorfm/data"
if err := ProcessPushEvent(context.TODO(), github.PushEvent{
Repo: &github.PushEventRepository{FullName: &name},
HeadCommit: &github.HeadCommit{
Modified: []string{"youtube-channels.csv", "discogs-labels.csv"},
},
}); err != nil {
fmt.Println(err.Error())
}
}