Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ozone cdn invalidation #2087

Merged
merged 9 commits into from
Feb 29, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/dev-env/src/ozone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,9 @@ export class TestOzone {
const secrets = ozone.envToSecrets(env)

// api server
const server = await ozone.OzoneService.create(cfg, secrets)
const server = await ozone.OzoneService.create(cfg, secrets, {
imgInvalidator: config.imgInvalidator,
})
await server.start()

const daemon = await ozone.OzoneDaemon.create(cfg, secrets)
Expand Down
1 change: 1 addition & 0 deletions packages/dev-env/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export type OzoneConfig = Partial<ozone.OzoneEnvironment> & {
dbPostgresUrl: string
migration?: string
signingKey?: ExportableKeypair
imgInvalidator?: ImageInvalidator
}

export type TestServerParams = {
Expand Down
10 changes: 10 additions & 0 deletions packages/ozone/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ export const envToCfg = (env: OzoneEnvironment): OzoneConfig => {
did: env.pdsDid,
}

const cdnCfg: OzoneConfig['cdn'] = {
paths: env.cdnPaths,
}

assert(env.didPlcUrl)
const identityCfg: OzoneConfig['identity'] = {
plcUrl: env.didPlcUrl,
Expand All @@ -45,6 +49,7 @@ export const envToCfg = (env: OzoneEnvironment): OzoneConfig => {
db: dbCfg,
appview: appviewCfg,
pds: pdsCfg,
cdn: cdnCfg,
identity: identityCfg,
}
}
Expand All @@ -54,6 +59,7 @@ export type OzoneConfig = {
db: DatabaseConfig
appview: AppviewConfig
pds: PdsConfig | null
cdn: CdnConfig
identity: IdentityConfig
}

Expand Down Expand Up @@ -82,3 +88,7 @@ export type PdsConfig = {
export type IdentityConfig = {
plcUrl: string
}

export type CdnConfig = {
paths?: string[]
}
4 changes: 3 additions & 1 deletion packages/ozone/src/config/env.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { envInt, envStr } from '@atproto/common'
import { envInt, envList, envStr } from '@atproto/common'

export const readEnv = (): OzoneEnvironment => {
return {
Expand All @@ -14,6 +14,7 @@ export const readEnv = (): OzoneEnvironment => {
dbPostgresUrl: envStr('OZONE_DB_POSTGRES_URL'),
dbPostgresSchema: envStr('OZONE_DB_POSTGRES_SCHEMA'),
didPlcUrl: envStr('OZONE_DID_PLC_URL'),
cdnPaths: envList('OZONE_CDN_PATHS'),
adminPassword: envStr('OZONE_ADMIN_PASSWORD'),
moderatorPassword: envStr('OZONE_MODERATOR_PASSWORD'),
triagePassword: envStr('OZONE_TRIAGE_PASSWORD'),
Expand All @@ -34,6 +35,7 @@ export type OzoneEnvironment = {
dbPostgresUrl?: string
dbPostgresSchema?: string
didPlcUrl?: string
cdnPaths?: string[]
adminPassword?: string
moderatorPassword?: string
triagePassword?: string
Expand Down
4 changes: 4 additions & 0 deletions packages/ozone/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
CommunicationTemplateService,
CommunicationTemplateServiceCreator,
} from './communication-service/template'
import { ImageInvalidator } from './image-invalidator'

export type AppContextOptions = {
db: Database
Expand All @@ -24,6 +25,7 @@ export type AppContextOptions = {
pdsAgent: AtpAgent | undefined
signingKey: Keypair
idResolver: IdResolver
imgInvalidator?: ImageInvalidator
backgroundQueue: BackgroundQueue
}

Expand Down Expand Up @@ -65,6 +67,8 @@ export class AppContext {
eventPusher,
appviewAgent,
appviewAuth,
overrides?.imgInvalidator,
cfg.cdn.paths,
)

const communicationTemplateService = CommunicationTemplateService.creator()
Expand Down
7 changes: 7 additions & 0 deletions packages/ozone/src/image-invalidator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Invalidation is a general interface for propagating an image blob
// takedown through any caches where a representation of it may be stored.
// @NOTE this does not remove the blob from storage: just invalidates it from caches.
// @NOTE keep in sync with same interface in aws/src/cloudfront.ts
export interface ImageInvalidator {
invalidate(subject: string, paths: string[]): Promise<void>
}
38 changes: 35 additions & 3 deletions packages/ozone/src/mod-service/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ import {
import { BlobPushEvent } from '../db/schema/blob_push_event'
import { BackgroundQueue } from '../background'
import { EventPusher } from '../daemon'
import { ImageInvalidator } from '../image-invalidator'
import { httpLogger as log } from '../logger'

export type ModerationServiceCreator = (db: Database) => ModerationService

Expand All @@ -49,13 +51,17 @@ export class ModerationService {
public eventPusher: EventPusher,
public appviewAgent: AtpAgent,
private appviewAuth: AppviewAuth,
public imgInvalidator?: ImageInvalidator,
public cdnPaths?: string[],
) {}

static creator(
backgroundQueue: BackgroundQueue,
eventPusher: EventPusher,
appviewAgent: AtpAgent,
appviewAuth: AppviewAuth,
imgInvalidator?: ImageInvalidator,
cdnPaths?: string[],
) {
return (db: Database) =>
new ModerationService(
Expand All @@ -64,6 +70,8 @@ export class ModerationService {
eventPusher,
appviewAgent,
appviewAuth,
imgInvalidator,
cdnPaths,
)
}

Expand Down Expand Up @@ -463,14 +471,38 @@ export class ModerationService {
lastAttempted: null,
}),
)
.returning('id')
.returning(['id', 'subjectDid', 'subjectBlobCid', 'eventType'])
.execute()

this.db.onCommit(() => {
this.backgroundQueue.add(async () => {
await Promise.all(
blobEvts.map((evt) => this.eventPusher.attemptBlobEvent(evt.id)),
await Promise.allSettled(
blobEvts.map((evt) =>
this.eventPusher
.attemptBlobEvent(evt.id)
.catch((err) =>
log.error({ err, ...evt }, 'failed to push blob event'),
),
),
)

if (this.imgInvalidator) {
await Promise.allSettled(
(subject.blobCids ?? []).map((cid) => {
const paths = (this.cdnPaths ?? []).map((path) =>
path.replace('%s', subject.did),
)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not noticing a place where the cid is also inserted into the path. Pretty sure the invalidators themselves don't do this, so I think we'd want it around here.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm yup you're totally right i thought the img invalidator took care of that tbh 😅

return this.imgInvalidator
?.invalidate(cid, paths)
.catch((err) =>
log.error(
{ err, paths, cid },
'failed to invalidate blob on cdn',
),
)
}),
)
}
})
})
}
Expand Down
28 changes: 28 additions & 0 deletions packages/ozone/tests/moderation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
} from '../src/lexicon/types/com/atproto/admin/defs'
import { EventReverser } from '../src'
import { TestOzone } from '@atproto/dev-env/src/ozone'
import { ImageInvalidator } from '../src/image-invalidator'

type BaseCreateReportParams =
| { account: string }
Expand All @@ -39,6 +40,7 @@ type TakedownParams = BaseCreateReportParams &
describe('moderation', () => {
let network: TestNetwork
let ozone: TestOzone
let mockInvalidator: MockInvalidator
let agent: AtpAgent
let pdsAgent: AtpAgent
let sc: SeedClient
Expand Down Expand Up @@ -140,8 +142,13 @@ describe('moderation', () => {
}

beforeAll(async () => {
mockInvalidator = new MockInvalidator()
network = await TestNetwork.create({
dbPostgresSchema: 'ozone_moderation',
ozone: {
imgInvalidator: mockInvalidator,
cdnPaths: ['/path1/%s/', '/path2/%s/'],
},
})
ozone = network.ozone
agent = network.ozone.getClient()
Expand Down Expand Up @@ -913,6 +920,19 @@ describe('moderation', () => {
expect(await fetchImage.json()).toEqual({ message: 'Image not found' })
})

it('invalidates the image in the cdn', async () => {
expect(mockInvalidator.invalidated.length).toBe(1)
expect(mockInvalidator.invalidated.at(0)?.subject).toBe(
blob.image.ref.toString(),
)
expect(mockInvalidator.invalidated.at(0)?.paths.at(0)).toEqual(
`/path1/${sc.dids.carol}/`,
)
expect(mockInvalidator.invalidated.at(0)?.paths.at(1)).toEqual(
`/path2/${sc.dids.carol}/`,
)
})

it('fans takedown out to pds', async () => {
const res = await pdsAgent.api.com.atproto.admin.getSubjectStatus(
{
Expand Down Expand Up @@ -959,3 +979,11 @@ describe('moderation', () => {
})
})
})

class MockInvalidator implements ImageInvalidator {
invalidated: { subject: string; paths: string[] }[] = []

async invalidate(subject: string, paths: string[]) {
this.invalidated.push({ subject, paths })
}
}
38 changes: 37 additions & 1 deletion services/ozone/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ require('dd-trace') // Only works with commonjs

// Tracer code above must come before anything else
const path = require('path')
const {
BunnyInvalidator,
CloudfrontInvalidator,
MultiImageInvalidator,
} = require('@atproto/aws')
Comment on lines +15 to +19
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We may need to adjust the service's package.json and dockerfile to include @atproto/aws.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Easiest thing to check/catch this is to make a build and run it, even if it's unconfigured.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yup yup good point 👍

const {
OzoneService,
envToCfg,
Expand All @@ -24,7 +29,38 @@ const main = async () => {
const env = readEnv()
const cfg = envToCfg(env)
const secrets = envToSecrets(env)
const ozone = await OzoneService.create(cfg, secrets)

// configure zero, one, or more image invalidators
const imgUriEndpoint = process.env.OZONE_IMG_URI_ENDPOINT
const bunnyAccessKey = process.env.OZONE_BUNNY_ACCESS_KEY
const cfDistributionId = process.env.OZONE_CF_DISTRIBUTION_ID

const imgInvalidators = []

if (bunnyAccessKey) {
imgInvalidators.push(
new BunnyInvalidator({
accessKey: bunnyAccessKey,
urlPrefix: imgUriEndpoint,
}),
)
}

if (cfDistributionId) {
imgInvalidators.push(
new CloudfrontInvalidator({
distributionId: cfDistributionId,
pathPrefix: imgUriEndpoint && new URL(imgUriEndpoint).pathname,
}),
)
}

const imgInvalidator =
imgInvalidators.length > 1
? new MultiImageInvalidator(imgInvalidators)
: imgInvalidators[0]

const ozone = await OzoneService.create(cfg, secrets, { imgInvalidator })

await ozone.start()

Expand Down
Loading