Repository: google/exposure-notifications-server
Pull request #1071 (2020-10-12 18:33:54)

E2E test: add cleanup scenario
This had been tested locally

google-oss-robot: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/google/exposure-notifications-server/pull/1071#" title="Author self-approved">chaodaiG</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=google%2Fexposure-notifications-server).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/google/exposure-notifications-server/blob/main/OWNERS)~~ [chaodaiG]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->

Patch:
diff --git a/cmd/mirror/main.go b/cmd/mirror/main.go
new file mode 100644
index 0000000..4643ccb
--- /dev/null
+++ b/cmd/mirror/main.go
@@ -0,0 +1,72 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// This binary provides a server that can import export files from a trusted partner.
+package main
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"strconv"
+
+	"github.com/google/exposure-notifications-server/internal/buildinfo"
+	"github.com/google/exposure-notifications-server/internal/mirror"
+	"github.com/google/exposure-notifications-server/internal/setup"
+	"github.com/google/exposure-notifications-server/pkg/logging"
+	"github.com/google/exposure-notifications-server/pkg/server"
+	"github.com/sethvargo/go-signalcontext"
+)
+
+func main() {
+	ctx, done := signalcontext.OnInterrupt()
+
+	debug, _ := strconv.ParseBool(os.Getenv("LOG_DEBUG"))
+	logger := logging.NewLogger(debug)
+	logger = logger.With("build_id", buildinfo.BuildID)
+	logger = logger.With("build_tag", buildinfo.BuildTag)
+
+	ctx = logging.WithLogger(ctx, logger)
+
+	err := realMain(ctx)
+	done()
+
+	if err != nil {
+		logger.Fatal(err)
+	}
+}
+
+func realMain(ctx context.Context) error {
+	logger := logging.FromContext(ctx)
+
+	var config mirror.Config
+	env, err := setup.Setup(ctx, &config)
+	if err != nil {
+		return fmt.Errorf("setup.Setup: %w", err)
+	}
+	defer env.Close(ctx)
+
+	rotationServer, err := mirror.NewServer(&config, env)
+	if err != nil {
+		return fmt.Errorf("mirror.NewServer: %w", err)
+	}
+
+	srv, err := server.New(config.Port)
+	if err != nil {
+		return fmt.Errorf("server.New: %w", err)
+	}
+	logger.Info("listening on: ", config.Port)
+
+	return srv.ServeHTTPHandler(ctx, rotationServer.Routes(ctx))
+}
diff --git a/internal/admin/exports/form.go b/internal/admin/exports/form.go
index 672bff3..65c439b 100644
--- a/internal/admin/exports/form.go
+++ b/internal/admin/exports/form.go
@@ -17,6 +17,7 @@ package exports
 
 import (
 	"fmt"
+	"sort"
 	"strings"
 	"time"
 
@@ -28,6 +29,7 @@ type formData struct {
 	OutputRegion     string        `form:"OutputRegion"`
 	InputRegions     string        `form:"InputRegions"`
 	IncludeTravelers bool          `form:"IncludeTravelers"`
+	ExcludeRegions   string        `form:"ExcludeRegions"`
 	BucketName       string        `form:"BucketName"`
 	FilenameRoot     string        `form:"FilenameRoot"`
 	Period           time.Duration `form:"Period"`
@@ -38,6 +40,20 @@ type formData struct {
 	SigInfoIDs       []int64       `form:"siginfo"`
 }
 
+// splitRegions turns a string of regions (generally separated by newlines), and
+// breaks them up into an alphabetically sorted slice of strings.
+func splitRegions(regions string) []string {
+	ret := make([]string, 0, 20)
+	for _, s := range strings.Split(regions, "\n") {
+		s := strings.TrimSpace(s)
+		if s != "" {
+			ret = append(ret, s)
+		}
+	}
+	sort.Strings(ret)
+	return ret
+}
+
 func (f *formData) PopulateExportConfig(ec *model.ExportConfig) error {
 	from, err := admin.CombineDateAndTime(f.FromDate, f.FromTime)
 	if err != nil {
@@ -52,14 +68,9 @@ func (f *formData) PopulateExportConfig(ec *model.ExportConfig) error {
 	ec.FilenameRoot = strings.TrimSpace(f.FilenameRoot)
 	ec.Period = f.Period
 	ec.OutputRegion = strings.TrimSpace(f.OutputRegion)
-	ec.InputRegions = make([]string, 0)
-	for _, s := range strings.Split(f.InputRegions, "\n") {
-		s := strings.TrimSpace(s)
-		if s != "" {
-			ec.InputRegions = append(ec.InputRegions, strings.TrimSpace(s))
-		}
-	}
+	ec.InputRegions = splitRegions(f.InputRegions)
 	ec.IncludeTravelers = f.IncludeTravelers
+	ec.ExcludeRegions = splitRegions(f.ExcludeRegions)
 	ec.From = from
 	ec.Thru = thru
 	ec.SignatureInfoIDs = f.SigInfoIDs
diff --git a/internal/export/database/export.go b/internal/export/database/export.go
index 0b73af7..fd9c897 100644
--- a/internal/export/database/export.go
+++ b/internal/export/database/export.go
@@ -52,12 +52,13 @@ func (db *ExportDB) AddExportConfig(ctx context.Context, ec *model.ExportConfig)
 		row := tx.QueryRow(ctx, `
 			INSERT INTO
 				ExportConfig
-				(bucket_name, filename_root, period_seconds, output_region, from_timestamp, thru_timestamp, signature_info_ids, input_regions, include_travelers)
+				(bucket_name, filename_root, period_seconds, output_region, from_timestamp, thru_timestamp, signature_info_ids, input_regions, include_travelers, exclude_regions)
 			VALUES
-				($1, $2, $3, $4, $5, $6, $7, $8, $9)
+				($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
 			RETURNING config_id
 		`, ec.BucketName, ec.FilenameRoot, int(ec.Period.Seconds()), ec.OutputRegion,
-			ec.From, thru, ec.SignatureInfoIDs, ec.InputRegions, ec.IncludeTravelers)
+			ec.From, thru, ec.SignatureInfoIDs, ec.InputRegions, ec.IncludeTravelers,
+			ec.ExcludeRegions)
 
 		if err := row.Scan(&ec.ConfigID); err != nil {
 			return fmt.Errorf("fetching config_id: %w", err)
@@ -78,10 +79,11 @@ func (db *ExportDB) UpdateExportConfig(ctx context.Context, ec *model.ExportConf
 			UPDATE
 				ExportConfig
 			SET
-				bucket_name = $1, filename_root = $2, period_seconds = $3, output_region = $4, from_timestamp = $5, thru_timestamp = $6, signature_info_ids = $7, input_regions = $8, include_travelers = $9
-			WHERE config_id = $10
+				bucket_name = $1, filename_root = $2, period_seconds = $3, output_region = $4, from_timestamp = $5, thru_timestamp = $6, signature_info_ids = $7, input_regions = $8, include_travelers = $9, exclude_regions = $10
+			WHERE config_id = $11
 		`, ec.BucketName, ec.FilenameRoot, int(ec.Period.Seconds()), ec.OutputRegion,
-			ec.From, thru, ec.SignatureInfoIDs, ec.InputRegions, ec.IncludeTravelers, ec.ConfigID)
+			ec.From, thru, ec.SignatureInfoIDs, ec.InputRegions, ec.IncludeTravelers,
+			ec.ExcludeRegions, ec.ConfigID)
 		if err != nil {
 			return fmt.Errorf("updating signatureinfo: %w", err)
 		}
@@ -97,7 +99,7 @@ func (db *ExportDB) GetExportConfig(ctx context.Context, id int64) (*model.Expor
 	if err := db.db.InTx(ctx, pgx.ReadCommitted, func(tx pgx.Tx) error {
 		row := tx.QueryRow(ctx, `
 			SELECT
-				config_id, bucket_name, filename_root, period_seconds, output_region, from_timestamp, thru_timestamp, signature_info_ids, input_regions, include_travelers
+				config_id, bucket_name, filename_root, period_seconds, output_region, from_timestamp, thru_timestamp, signature_info_ids, input_regions, include_travelers, exclude_regions
 			FROM
 				ExportConfig
 			WHERE
@@ -123,7 +125,7 @@ func (db *ExportDB) GetAllExportConfigs(ctx context.Context) ([]*model.ExportCon
 	if err := db.db.InTx(ctx, pgx.ReadCommitted, func(tx pgx.Tx) error {
 		rows, err := tx.Query(ctx, `
 			SELECT
-				config_id, bucket_name, filename_root, period_seconds, output_region, from_timestamp, thru_timestamp, signature_info_ids, input_regions, include_travelers
+				config_id, bucket_name, filename_root, period_seconds, output_region, from_timestamp, thru_timestamp, signature_info_ids, input_regions, include_travelers, exclude_regions
 			FROM
 				ExportConfig
 			ORDER BY config_id
@@ -160,7 +162,7 @@ func (db *ExportDB) IterateExportConfigs(ctx context.Context, t time.Time, f fun
 	if err := db.db.InTx(ctx, pgx.ReadCommitted, func(tx pgx.Tx) error {
 		rows, err := tx.Query(ctx, `
 			SELECT
-				config_id, bucket_name, filename_root, period_seconds, output_region, from_timestamp, thru_timestamp, signature_info_ids, input_regions, include_travelers
+				config_id, bucket_name, filename_root, period_seconds, output_region, from_timestamp, thru_timestamp, signature_info_ids, input_regions, include_travelers, exclude_regions
 			FROM
 				ExportConfig
 			WHERE
@@ -202,7 +204,7 @@ func scanOneExportConfig(row pgx.Row) (*model.ExportConfig, error) {
 		periodSeconds int
 		thru          *time.Time
 	)
-	if err := row.Scan(&m.ConfigID, &m.BucketName, &m.FilenameRoot, &periodSeconds, &outputRegion, &m.From, &thru, &m.SignatureInfoIDs, &m.InputRegions, &m.IncludeTravelers); err != nil {
+	if err := row.Scan(&m.ConfigID, &m.BucketName, &m.FilenameRoot, &periodSeconds, &outputRegion, &m.From, &thru, &m.SignatureInfoIDs, &m.InputRegions, &m.IncludeTravelers, &m.ExcludeRegions); err != nil {
 		return nil, err
 	}
 
@@ -468,9 +470,9 @@ func (db *ExportDB) AddExportBatches(ctx context.Context, batches []*model.Expor
 		_, err := tx.Prepare(ctx, stmtName, `
 			INSERT INTO
 				ExportBatch
-				(config_id, bucket_name, filename_root, start_timestamp, end_timestamp, output_region, status, signature_info_ids, input_regions, include_travelers)
+				(config_id, bucket_name, filename_root, start_timestamp, end_timestamp, output_region, status, signature_info_ids, input_regions, include_travelers, exclude_regions)
 			VALUES
-				($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
+				($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
 		`)
 		if err != nil {
 			return err
@@ -478,7 +480,7 @@ func (db *ExportDB) AddExportBatches(ctx context.Context, batches []*model.Expor
 
 		for _, eb := range batches {
 			if _, err := tx.Exec(ctx, stmtName,
-				eb.ConfigID, eb.BucketName, eb.FilenameRoot, eb.StartTimestamp, eb.EndTimestamp, eb.OutputRegion, eb.Status, eb.SignatureInfoIDs, eb.InputRegions, eb.IncludeTravelers); err != nil {
+				eb.ConfigID, eb.BucketName, eb.FilenameRoot, eb.StartTimestamp, eb.EndTimestamp, eb.OutputRegion, eb.Status, eb.SignatureInfoIDs, eb.InputRegions, eb.IncludeTravelers, eb.ExcludeRegions); err != nil {
 				return err
 			}
 		}
@@ -607,7 +609,7 @@ type queryRowFn func(ctx context.Context, query string, args ...interface{}) pgx
 func lookupExportBatch(ctx context.Context, batchID int64, queryRow queryRowFn) (*model.ExportBatch, error) {
 	row := queryRow(ctx, `
 		SELECT
-			batch_id, config_id, bucket_name, filename_root, start_timestamp, end_timestamp, output_region, status, lease_expires, signature_info_ids, input_regions, include_travelers
+			batch_id, config_id, bucket_name, filename_root, start_timestamp, end_timestamp, output_region, status, lease_expires, signature_info_ids, input_regions, include_travelers, exclude_regions
 		FROM
 			ExportBatch
 		WHERE
@@ -617,7 +619,7 @@ func lookupExportBatch(ctx context.Context, batchID int64, queryRow queryRowFn)
 
 	var expires *time.Time
 	eb := model.ExportBatch{}
-	if err := row.Scan(&eb.BatchID, &eb.ConfigID, &eb.BucketName, &eb.FilenameRoot, &eb.StartTimestamp, &eb.EndTimestamp, &eb.OutputRegion, &eb.Status, &expires, &eb.SignatureInfoIDs, &eb.InputRegions, &eb.IncludeTravelers); err != nil {
+	if err := row.Scan(&eb.BatchID, &eb.ConfigID, &eb.BucketName, &eb.FilenameRoot, &eb.StartTimestamp, &eb.EndTimestamp, &eb.OutputRegion, &eb.Status, &expires, &eb.SignatureInfoIDs, &eb.InputRegions, &eb.IncludeTravelers, &eb.ExcludeRegions); err != nil {
 		if err == pgx.ErrNoRows {
 			return nil, database.ErrNotFound
 		}
@@ -641,6 +643,7 @@ func (db *ExportDB) FinalizeBatch(ctx context.Context, eb *model.ExportBatch, fi
 				OutputRegion:     eb.OutputRegion,
 				InputRegions:     eb.InputRegions,
 				IncludeTravelers: eb.IncludeTravelers,
+				ExcludeRegions:   eb.ExcludeRegions,
 				BatchNum:         i + 1,
 				BatchSize:        batchSize,
 				Status:           model.ExportBatchComplete,
@@ -729,7 +732,7 @@ func (db *ExportDB) LookupExportFile(ctx context.Context, filename string) (*mod
 	if err := db.db.InTx(ctx, pgx.ReadCommitted, func(tx pgx.Tx) error {
 		row := tx.QueryRow(ctx, `
 			SELECT
-				bucket_name, filename, batch_id, output_region, batch_num, batch_size, status, input_regions, include_travelers
+				bucket_name, filename, batch_id, output_region, batch_num, batch_size, status, input_regions, include_travelers, exclude_regions
 			FROM
 				ExportFile
 			WHERE
@@ -737,7 +740,7 @@ func (db *ExportDB) LookupExportFile(ctx context.Context, filename string) (*mod
 			LIMIT 1
 		`, filename)
 
-		if err := row.Scan(&file.BucketName, &file.Filename, &file.BatchID, &file.OutputRegion, &file.BatchNum, &file.BatchSize, &file.Status, &file.InputRegions, &file.IncludeTravelers); err != nil {
+		if err := row.Scan(&file.BucketName, &file.Filename, &file.BatchID, &file.OutputRegion, &file.BatchNum, &file.BatchSize, &file.Status, &file.InputRegions, &file.IncludeTravelers, &file.ExcludeRegions); err != nil {
 			if errors.Is(err, pgx.ErrNoRows) {
 				return database.ErrNotFound
 			}
@@ -842,11 +845,11 @@ func addExportFile(ctx context.Context, tx pgx.Tx, ef *model.ExportFile) error {
 	tag, err := tx.Exec(ctx, `
 		INSERT INTO
 			ExportFile
-			(bucket_name, filename, batch_id, output_region, batch_num, batch_size, status, input_regions, include_travelers)
+			(bucket_name, filename, batch_id, output_region, batch_num, batch_size, status, input_regions, include_travelers, exclude_regions)
 		VALUES
-			($1, $2, $3, $4, $5, $6, $7, $8, $9)
+			($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
 		ON CONFLICT (filename) DO NOTHING
-		`, ef.BucketName, ef.Filename, ef.BatchID, ef.OutputRegion, ef.BatchNum, ef.BatchSize, ef.Status, ef.InputRegions, ef.IncludeTravelers)
+		`, ef.BucketName, ef.Filename, ef.BatchID, ef.OutputRegion, ef.BatchNum, ef.BatchSize, ef.Status, ef.InputRegions, ef.IncludeTravelers, ef.ExcludeRegions)
 	if err != nil {
 		return fmt.Errorf("inserting to ExportFile: %w", err)
 	}
diff --git a/internal/export/model/export_model.go b/internal/export/model/export_model.go
index 565da8c..cbde0cf 100644
--- a/internal/export/model/export_model.go
+++ b/internal/export/model/export_model.go
@@ -39,6 +39,7 @@ type ExportConfig struct {
 	Period           time.Duration
 	OutputRegion     string
 	InputRegions     []string
+	ExcludeRegions   []string
 	IncludeTravelers bool
 	From             time.Time
 	Thru             time.Time
@@ -55,6 +56,10 @@ func (ec *ExportConfig) InputRegionsOnePerLine() string {
 	return strings.Join(ec.InputRegions, "\n")
 }
 
+func (ec *ExportConfig) ExcludeRegionsOnePerLine() string {
+	return strings.Join(ec.ExcludeRegions, "\n")
+}
+
 func (ec *ExportConfig) Validate() error {
 	if ec.Period > oneDay {
 		return errors.New("maximum period is 24h")
@@ -105,6 +110,7 @@ type ExportBatch struct {
 	OutputRegion     string
 	InputRegions     []string
 	IncludeTravelers bool
+	ExcludeRegions   []string
 	Status           string
 	LeaseExpires     time.Time
 	SignatureInfoIDs []int64
@@ -123,6 +129,7 @@ type ExportFile struct {
 	OutputRegion     string
 	InputRegions     []string
 	IncludeTravelers bool
+	ExcludeRegions   []string
 	BatchNum         int
 	BatchSize        int
 	Status           string
diff --git a/internal/export/worker.go b/internal/export/worker.go
index 67bf8f3..99eae73 100644
--- a/internal/export/worker.go
+++ b/internal/export/worker.go
@@ -120,7 +120,8 @@ func (s *Server) exportBatch(ctx context.Context, eb *model.ExportBatch, emitInd
 		UntilTimestamp:      eb.EndTimestamp,
 		IncludeRegions:      eb.EffectiveInputRegions(),
 		IncludeTravelers:    eb.IncludeTravelers, // Travelers are included from "any" region.
-		OnlyLocalProvenance: false,               // include federated ids
+		ExcludeRegions:      eb.ExcludeRegions,
+		OnlyLocalProvenance: false, // include federated ids
 		OnlyRevisedKeys:     false,
 	}
 
diff --git a/internal/mirror/config.go b/internal/mirror/config.go
new file mode 100644
index 0000000..b90715a
--- /dev/null
+++ b/internal/mirror/config.go
@@ -0,0 +1,65 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package mirror
+
+import (
+	"time"
+
+	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/setup"
+	"github.com/google/exposure-notifications-server/internal/storage"
+	"github.com/google/exposure-notifications-server/pkg/observability"
+	"github.com/google/exposure-notifications-server/pkg/secrets"
+)
+
+var _ setup.DatabaseConfigProvider = (*Config)(nil)
+var _ setup.ObservabilityExporterConfigProvider = (*Config)(nil)
+var _ setup.BlobstoreConfigProvider = (*Config)(nil)
+var _ setup.SecretManagerConfigProvider = (*Config)(nil)
+
+type Config struct {
+	Database              database.Config
+	ObservabilityExporter observability.Config
+	SecretManager         secrets.Config
+	Storage               storage.Config
+
+	Port string `env:"PORT, default=8080"`
+
+	// Max file sizes for download. 1mb for index files, 20mb for zip files.
+	MaxIndexBytes int64 `env:"MAX_INDEX_BYTES, default=1048576"`
+	MaxZipBytes   int64 `env:"MAX_ZIP_BYTES, default=20971520"`
+
+	IndexFileDownloadTimeout  time.Duration `env:"INDEX_FILE_DOWNLOAD_TIMEOUT, default=30s"`
+	ExportFileDownloadTimeout time.Duration `env:"EXPORT_FILE_DOWNLOAD_TIMEOUT, default=2m"`
+
+	MaxRuntime         time.Duration `env:"MAX_RUNTIME, default=14m"`
+	MirrorLockDuration time.Duration `env:"MIRROR_LOCK_DURATION, default=15m"`
+}
+
+func (c *Config) BlobstoreConfig() *storage.Config {
+	return &c.Storage
+}
+
+func (c *Config) DatabaseConfig() *database.Config {
+	return &c.Database
+}
+
+func (c *Config) ObservabilityExporterConfig() *observability.Config {
+	return &c.ObservabilityExporter
+}
+
+func (c *Config) SecretManagerConfig() *secrets.Config {
+	return &c.SecretManager
+}
diff --git a/internal/mirror/database/mirror.go b/internal/mirror/database/mirror.go
new file mode 100644
index 0000000..45d4a23
--- /dev/null
+++ b/internal/mirror/database/mirror.go
@@ -0,0 +1,232 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package database is a database interface for mirror settings.
+package database
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/mirror/model"
+	"github.com/jackc/pgx/v4"
+)
+
+type MirrorDB struct {
+	db *database.DB
+}
+
+func New(db *database.DB) *MirrorDB {
+	return &MirrorDB{
+		db: db,
+	}
+}
+
+func (db *MirrorDB) AddMirror(ctx context.Context, m *model.Mirror) error {
+	return db.db.InTx(ctx, pgx.Serializable, func(tx pgx.Tx) error {
+		row := tx.QueryRow(ctx, `
+			INSERT INTO
+				Mirror (index_file, export_root, cloud_storage_bucket, filename_root)
+			VALUES
+				($1, $2, $3, $4)
+			RETURNING id
+		`, m.IndexFile, m.ExportRoot, m.CloudStorageBucket, m.FilenameRoot)
+
+		if err := row.Scan(&m.ID); err != nil {
+			return fmt.Errorf("fetching mirror.ID: %w", err)
+		}
+		return nil
+	})
+}
+
+func (db *MirrorDB) DeleteMirror(ctx context.Context, m *model.Mirror) error {
+	return db.db.InTx(ctx, pgx.Serializable, func(tx pgx.Tx) error {
+		_, err := tx.Exec(ctx, `
+			DELETE FROM
+				MirrorFile
+			WHERE
+				mirror_id = $1
+			`, m.ID)
+		if err != nil {
+			return fmt.Errorf("failed to delete mirror files: %w", err)
+		}
+
+		_, err = tx.Exec(ctx, `
+			DELETE FROM
+				Mirror
+			WHERE
+				id = $1
+			`, m.ID)
+		if err != nil {
+			return fmt.Errorf("failed to delete mirror config: %w", err)
+		}
+
+		return nil
+	})
+}
+
+func (db *MirrorDB) Mirrors(ctx context.Context) ([]*model.Mirror, error) {
+	var mirrors []*model.Mirror
+
+	if err := db.db.InTx(ctx, pgx.ReadCommitted, func(tx pgx.Tx) error {
+		rows, err := tx.Query(ctx, `
+			SELECT
+				id, index_file, export_root, cloud_storage_bucket, filename_root
+			FROM
+				mirror
+			ORDER BY id
+		`)
+		if err != nil {
+			return fmt.Errorf("failed to list: %w", err)
+		}
+		defer rows.Close()
+
+		for rows.Next() {
+			if err := rows.Err(); err != nil {
+				return fmt.Errorf("faield to iterate: %w", err)
+			}
+
+			var m model.Mirror
+			if err := rows.Scan(&m.ID, &m.IndexFile, &m.ExportRoot, &m.CloudStorageBucket, &m.FilenameRoot); err != nil {
+				return fmt.Errorf("reading row: %w", err)
+			}
+			mirrors = append(mirrors, &m)
+		}
+		return nil
+	}); err != nil {
+		return nil, fmt.Errorf("listing mirror configs: %w", err)
+	}
+
+	return mirrors, nil
+}
+
+// SaveFiles makes the list of filenames passed in the only files that are saved on that mirrorID.
+func (db *MirrorDB) SaveFiles(ctx context.Context, mirrorID int64, filenames []string) error {
+	const deleteName = "delete mirror file"
+	const insertName = "insert mirror file"
+
+	wantFiles := make(map[string]struct{}, len(filenames))
+	for _, fname := range filenames {
+		wantFiles[fname] = struct{}{}
+	}
+
+	return db.db.InTx(ctx, pgx.ReadCommitted, func(tx pgx.Tx) error {
+		// Read files selects all of the existing known files FOR UPDATE.
+		knownFiles, err := readFiles(ctx, tx, mirrorID)
+		if err != nil {
+			return err
+		}
+
+		toDelete := make([]*model.MirrorFile, 0)
+		// if any filenames were read that aren't in the 'filenames' list, add them to the toDelete
+		for _, mirrorFile := range knownFiles {
+			if _, ok := wantFiles[mirrorFile.Filename]; ok {
+				delete(wantFiles, mirrorFile.Filename)
+			} else {
+				toDelete = append(toDelete, mirrorFile)
+			}
+		}
+
+		// if any filenames need removing, delete them.
+		// toDelete contains items in 'knownFiles' that weren't in 'filenames'
+		if len(toDelete) > 0 {
+			if _, err := tx.Prepare(ctx, deleteName, `
+				DELETE FROM
+						MirrorFile
+				WHERE
+					mirror_id = $1 AND filename = $2
+				`); err != nil {
+				return fmt.Errorf("failed to prepare delete DB statement: %w", err)
+			}
+
+			for _, mf := range toDelete {
+				if result, err := tx.Exec(ctx, deleteName, mf.MirrorID, mf.Filename); err != nil {
+					return fmt.Errorf("failed to delete mirrofile: %w", err)
+				} else if result.RowsAffected() != 1 {
+					return fmt.Errorf("delete of locked row failed")
+				}
+			}
+		}
+
+		// Create files if they still need to be created.
+		// wantFiles contains items from 'filenames' that weren't in 'knownFiles'
+		if len(wantFiles) > 0 {
+			if _, err := tx.Prepare(ctx, insertName, `
+				INSERT INTO
+					MirrorFile (mirror_id, filename)
+				VALUES
+					($1, $2)
+				ON CONFLICT (mirror_id, filename) DO NOTHING
+			`); err != nil {
+				return fmt.Errorf("failed to prepare insert statement: %w", err)
+			}
+
+			for fName := range wantFiles {
+				if _, err := tx.Exec(ctx, insertName, mirrorID, fName); err != nil {
+					return fmt.Errorf("failed to insert mirrorfile: %w", err)
+				}
+			}
+		}
+
+		return nil
+	})
+}
+
+func (db *MirrorDB) ListFiles(ctx context.Context, mirrorID int64) ([]*model.MirrorFile, error) {
+	var mirrorFiles []*model.MirrorFile
+
+	if err := db.db.InTx(ctx, pgx.ReadCommitted, func(tx pgx.Tx) error {
+		var err error
+		mirrorFiles, err = readFiles(ctx, tx, mirrorID)
+		if err != nil {
+			return err
+		}
+		return nil
+	}); err != nil {
+		return nil, err
+	}
+	return mirrorFiles, nil
+}
+
+func readFiles(ctx context.Context, tx pgx.Tx, mirrorID int64) ([]*model.MirrorFile, error) {
+	var mirrorFiles []*model.MirrorFile
+	rows, err := tx.Query(ctx, `
+			SELECT
+				mirror_id, filename
+			FROM
+				MirrorFile
+			WHERE
+				mirror_id = $1
+			FOR UPDATE
+		`, mirrorID)
+	if err != nil {
+		return nil, fmt.Errorf("failed to list: %w", err)
+	}
+	defer rows.Close()
+
+	for rows.Next() {
+		if err := rows.Err(); err != nil {
+			return nil, fmt.Errorf("failed to iterate: %w", err)
+		}
+
+		var f model.MirrorFile
+		if err := rows.Scan(&f.MirrorID, &f.Filename); err != nil {
+			return nil, fmt.Errorf("reading row: %w", err)
+		}
+		mirrorFiles = append(mirrorFiles, &f)
+	}
+
+	return mirrorFiles, nil
+}
diff --git a/internal/mirror/mirror.go b/internal/mirror/mirror.go
new file mode 100644
index 0000000..53c404e
--- /dev/null
+++ b/internal/mirror/mirror.go
@@ -0,0 +1,289 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package mirror
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"sort"
+	"strings"
+	"time"
+
+	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/mirror/model"
+	"github.com/google/exposure-notifications-server/internal/storage"
+	"github.com/google/exposure-notifications-server/pkg/logging"
+	"go.opencensus.io/trace"
+)
+
+const mirrorLockPrefix = "mirror-lock"
+
+type FileStatus struct {
+	Order        int
+	MirrorFile   *model.MirrorFile
+	DownloadPath string
+	Filename     string
+	Failed       bool
+	Saved        bool
+}
+
+func (f *FileStatus) needsDelete() bool {
+	return f.DownloadPath == ""
+}
+
+func (f *FileStatus) needsDownload() bool {
+	return f.MirrorFile == nil
+}
+
+func (s *Server) downloadIndex(w http.ResponseWriter, mirror *model.Mirror) ([]string, error) {
+	client := &http.Client{
+		Timeout: s.config.IndexFileDownloadTimeout,
+	}
+	resp, err := client.Get(mirror.IndexFile)
+	if err != nil {
+		return nil, fmt.Errorf("error downloading index file: %w", err)
+	}
+	defer resp.Body.Close()
+	resp.Body = http.MaxBytesReader(w, resp.Body, s.config.MaxIndexBytes)
+	bytes, err := ioutil.ReadAll(resp.Body)
+	if err != nil {
+		return nil, fmt.Errorf("error reading index file: %w", err)
+	}
+	zipNames := strings.Split(string(bytes), "\n")
+	currentFiles := make([]string, 0, len(zipNames))
+	for _, zipFile := range zipNames {
+		fullZipFile := fmt.Sprintf("%s%s", mirror.ExportRoot, strings.TrimSpace(zipFile))
+		currentFiles = append(currentFiles, fullZipFile)
+	}
+
+	return currentFiles, nil
+}
+
+func (s *Server) handleMirror(ctx context.Context) http.HandlerFunc {
+	logger := logging.FromContext(ctx).Named("mirror.handleMirror")
+
+	return func(w http.ResponseWriter, r *http.Request) {
+		ctx := logging.WithLogger(r.Context(), logger)
+		_, span := trace.StartSpan(ctx, "(*mirror.handleMirror).ServeHTTP")
+		defer span.End()
+
+		ctx, cancelFn := context.WithTimeout(ctx, s.config.MaxRuntime)
+		defer cancelFn()
+
+		// Chop of 30 seconds to save state at the end.
+		runtime := s.config.MaxRuntime
+		if runtime > time.Minute {
+			runtime = runtime - (30 * time.Second)
+		}
+		deadline := time.Now().Add(runtime)
+		logger.Infow("mirrow will run until", "deadline", deadline)
+
+		// get all possible mirror candidates
+		mirrors, err := s.mirrorDB.Mirrors(ctx)
+		if err != nil {
+			logger.Errorw("unable to list mirrors", "error", err)
+			w.WriteHeader(http.StatusInternalServerError)
+			return
+		}
+
+		for _, mirror := range mirrors {
+			if deadlinePassed(deadline) {
+				logger.Warnw("mirror timed out before processing all configurations")
+				break
+			}
+
+			if err := s.processMirror(ctx, w, deadline, mirror); err != nil {
+				logger.Errorw("unable to process mirror", "error", err)
+				w.WriteHeader(http.StatusInternalServerError)
+				return
+			}
+		}
+
+		status := http.StatusOK
+		w.WriteHeader(status)
+		w.Write([]byte(http.StatusText(status)))
+	}
+}
+
+func (s *Server) processMirror(ctx context.Context, w http.ResponseWriter, deadline time.Time, mirror *model.Mirror) error {
+	logger := logging.FromContext(ctx)
+
+	lockID := fmt.Sprintf("%s-%d", mirrorLockPrefix, mirror.ID)
+	unlock, err := s.db.Lock(ctx, lockID, s.config.MaxRuntime)
+	if err != nil {
+		if errors.Is(err, database.ErrAlreadyLocked) {
+			logger.Infow("mirror already locked, skipping", "mirrorID", mirror.ID)
+			return nil
+		}
+		return fmt.Errorf("unable to lock mirror: %w", err)
+	}
+	defer func() {
+		if err := unlock(); err != nil {
+			logger.Errorw("failed to unlock: %v", err)
+		}
+	}()
+
+	logger.Info("starting mirror", "mirrorID", mirror.ID, "index", mirror.IndexFile)
+
+	// have the lock
+	knownFiles, err := s.mirrorDB.ListFiles(ctx, mirror.ID)
+	if err != nil {
+		logger.Errorw("unable to list mirror files", "error", err)
+		return nil
+	}
+
+	// download the index
+	// curFiles contains the fullly qualified download link for the files.
+	curFiles, err := s.downloadIndex(w, mirror)
+	if err != nil {
+		logger.Errorw("unable to download index.txt - skipping", "mirrorID", mirror.ID, "error", err)
+		return nil
+	}
+
+	// figure out the actions
+	actions := make(map[string]*FileStatus, len(curFiles))
+	for _, kf := range knownFiles {
+		actions[kf.Filename] = &FileStatus{
+			MirrorFile: kf,
+		}
+	}
+	for i, curFile := range curFiles {
+		lastIdx := strings.LastIndex(curFile, "/")
+		fileName := curFile[lastIdx+1:]
+		if cur, ok := actions[fileName]; ok {
+			cur.DownloadPath = curFile
+			cur.Filename = fileName
+			cur.Order = i
+		} else {
+			actions[fileName] = &FileStatus{
+				Order:        i,
+				Filename:     fileName,
+				DownloadPath: curFile,
+			}
+		}
+	}
+
+	// save for convenience.
+	blobstore := s.env.Blobstore()
+
+	// delete any files we no longer have
+	for filename, status := range actions {
+		if status.needsDelete() {
+			logger.Infow("deleting stale export file", "file", filename)
+
+			bsCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
+			defer cancel()
+			// If there's a race and the file was already deleted, no error is returned.
+			if err := blobstore.DeleteObject(bsCtx, mirror.CloudStorageBucket, filename); err != nil {
+				logger.Errorw("unable to delete file", "bucket", mirror.CloudStorageBucket, "file", filename, "error", err)
+				return nil
+			}
+			// Remove from map, so will be deleted from db.
+			delete(actions, filename)
+		}
+	}
+
+	// download any files we don't have
+	indexObjects := make([]*FileStatus, 0, len(actions))
+	// quickly bypass anything that doesn't need to be downloaded.
+	// This ensures that if we timeout, files we previously knew about remain saved.
+	for _, status := range actions {
+		if !status.needsDownload() {
+			status.Saved = true
+			indexObjects = append(indexObjects, status)
+		}
+	}
+	logger.Infow("mirror state", "mirrorID", mirror.ID, "total", len(actions), "existing", len(indexObjects), "remaining", len(actions)-len(indexObjects))
+
+	// go through again and process downloads.
+	for filename, status := range actions {
+		if deadlinePassed(deadline) {
+			logger.Warnw("mirror didn't complete processing before timeout", "mirrorID", mirror.ID)
+			break
+		}
+
+		if status.needsDownload() {
+			logger.Infow("mirroring export file", "mirrorID", mirror.ID, "file", filename)
+
+			client := &http.Client{
+				Timeout: s.config.ExportFileDownloadTimeout,
+			}
+			resp, err := client.Get(status.DownloadPath)
+			if err != nil {
+				logger.Errorw("unable to download file", "filename", filename, "error", err)
+				status.Failed = true
+				return nil
+			}
+
+			defer resp.Body.Close()
+			resp.Body = http.MaxBytesReader(w, resp.Body, s.config.MaxZipBytes)
+			bytes, err := ioutil.ReadAll(resp.Body)
+			if err != nil {
+				logger.Errorw("unable to read file", "filename", filename, "error", err)
+				status.Failed = true
+				return nil
+			}
+
+			// Write this file to the blob store.
+			bsCtx, cancel := context.WithTimeout(ctx, time.Minute)
+			defer cancel()
+			objName := fmt.Sprintf("%s/%s", mirror.FilenameRoot, status.Filename)
+			if err := blobstore.CreateObject(bsCtx, mirror.CloudStorageBucket, objName, bytes, true, storage.ContentTypeZip); err != nil {
+				logger.Errorw("unable to write file to blobstore", "filename", filename, "error", err)
+				status.Failed = true
+				return nil
+			}
+
+			status.Saved = true
+			indexObjects = append(indexObjects, status)
+		}
+	}
+
+	// Write new index file
+	sort.Slice(indexObjects, func(i, j int) bool {
+		return indexObjects[i].Order < indexObjects[j].Order
+	})
+	shortFilenames := make([]string, 0, len(indexObjects))
+	filenames := make([]string, 0, len(indexObjects))
+	for _, obj := range indexObjects {
+		// Only persist state of the files we got to.
+		if obj.Saved {
+			shortFilenames = append(shortFilenames, obj.Filename)
+			filenames = append(filenames, fmt.Sprintf("%s/%s", mirror.FilenameRoot, obj.Filename))
+		}
+	}
+	data := []byte(strings.Join(filenames, "\n"))
+	indexName := fmt.Sprintf("%s/index.txt", mirror.FilenameRoot)
+	bsCtx, cancel := context.WithTimeout(ctx, time.Minute)
+	defer cancel()
+	if err := blobstore.CreateObject(bsCtx, mirror.CloudStorageBucket, indexName, data, true, storage.ContentTypeTextPlain); err != nil {
+		logger.Errorw("unable to write index to blobstore", "filename", "index.txt", "error", err)
+		return nil
+	}
+
+	// sync state to DB.
+	if err := s.mirrorDB.SaveFiles(ctx, mirror.ID, shortFilenames); err != nil {
+		logger.Errorw("error saving index state to database", "mirrorID", mirror.ID, "error", err)
+	}
+	logger.Infow("finished mirror", "mirrorID", mirror.ID, "index", mirror.IndexFile)
+	return nil
+}
+
+func deadlinePassed(deadline time.Time) bool {
+	return time.Now().After(deadline)
+}
diff --git a/internal/mirror/model/mirror.go b/internal/mirror/model/mirror.go
new file mode 100644
index 0000000..ca0699a
--- /dev/null
+++ b/internal/mirror/model/mirror.go
@@ -0,0 +1,31 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package model is a model abstraction of mirror data structures.
+package model
+
+type Mirror struct {
+	ID int64
+	// Read from
+	IndexFile  string
+	ExportRoot string
+	// Write to
+	CloudStorageBucket string
+	FilenameRoot       string
+}
+
+type MirrorFile struct {
+	MirrorID int64
+	Filename string
+}
diff --git a/internal/mirror/server.go b/internal/mirror/server.go
new file mode 100644
index 0000000..f843f1d
--- /dev/null
+++ b/internal/mirror/server.go
@@ -0,0 +1,63 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package mirror implements the export file mirroring job.
+package mirror
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+
+	"github.com/google/exposure-notifications-server/internal/database"
+	mirrordb "github.com/google/exposure-notifications-server/internal/mirror/database"
+	"github.com/google/exposure-notifications-server/internal/serverenv"
+	"github.com/google/exposure-notifications-server/pkg/server"
+)
+
+// Server hosts end points to manage key rotation
+type Server struct {
+	config   *Config
+	env      *serverenv.ServerEnv
+	db       *database.DB
+	mirrorDB *mirrordb.MirrorDB
+}
+
+// NewServer creates a Server that manages deletion of
+// old export files that are no longer needed by clients for download.
+func NewServer(config *Config, env *serverenv.ServerEnv) (*Server, error) {
+	if env.Database() == nil {
+		return nil, fmt.Errorf("missing database in server environment")
+	}
+
+	db := env.Database()
+	mdb := mirrordb.New(db)
+
+	return &Server{
+		config:   config,
+		env:      env,
+		db:       db,
+		mirrorDB: mdb,
+	}, nil
+}
+
+// Routes defines and returns the routes for this server.
+func (s *Server) Routes(ctx context.Context) *http.ServeMux {
+	mux := http.NewServeMux()
+
+	mux.HandleFunc("/", s.handleMirror(ctx))
+	mux.Handle("/health", server.HandleHealthz(ctx))
+
+	return mux
+}
diff --git a/migrations/000043_ReAddExcludeRegions.down.sql b/migrations/000043_ReAddExcludeRegions.down.sql
new file mode 100644
index 0000000..c821157
--- /dev/null
+++ b/migrations/000043_ReAddExcludeRegions.down.sql
@@ -0,0 +1,26 @@
+-- Copyright 2020 Google LLC
+--
+-- Licensed under the Apache License, Version 2.0 (the "License");
+-- you may not use this file except in compliance with the License.
+-- You may obtain a copy of the License at
+--
+--      http://www.apache.org/licenses/LICENSE-2.0
+--
+-- Unless required by applicable law or agreed to in writing, software
+-- distributed under the License is distributed on an "AS IS" BASIS,
+-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+-- See the License for the specific language governing permissions and
+-- limitations under the License.
+
+BEGIN;
+
+ALTER TABLE exportconfig
+    DROP COLUMN exclude_regions;
+
+ALTER TABLE exportbatch
+    DROP COLUMN exclude_regions;
+
+ALTER TABLE exportfile
+    DROP COLUMN exclude_regions;
+
+END;
diff --git a/migrations/000043_ReAddExcludeRegions.up.sql b/migrations/000043_ReAddExcludeRegions.up.sql
new file mode 100644
index 0000000..4c39106
--- /dev/null
+++ b/migrations/000043_ReAddExcludeRegions.up.sql
@@ -0,0 +1,26 @@
+-- Copyright 2020 Google LLC
+--
+-- Licensed under the Apache License, Version 2.0 (the "License");
+-- you may not use this file except in compliance with the License.
+-- You may obtain a copy of the License at
+--
+--      http://www.apache.org/licenses/LICENSE-2.0
+--
+-- Unless required by applicable law or agreed to in writing, software
+-- distributed under the License is distributed on an "AS IS" BASIS,
+-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+-- See the License for the specific language governing permissions and
+-- limitations under the License.
+
+BEGIN;
+
+ALTER TABLE exportconfig
+    ADD COLUMN exclude_regions VARCHAR(5) [];
+
+ALTER TABLE exportbatch
+    ADD COLUMN exclude_regions VARCHAR(5) [];
+
+ALTER TABLE exportfile
+    ADD COLUMN exclude_regions VARCHAR(5) [];
+
+END;
diff --git a/migrations/000044_AddMirrorSettings.down.sql b/migrations/000044_AddMirrorSettings.down.sql
new file mode 100644
index 0000000..e80de1a
--- /dev/null
+++ b/migrations/000044_AddMirrorSettings.down.sql
@@ -0,0 +1,21 @@
+-- Copyright 2020 Google LLC
+--
+-- Licensed under the Apache License, Version 2.0 (the "License");
+-- you may not use this file except in compliance with the License.
+-- You may obtain a copy of the License at
+--
+--      http://www.apache.org/licenses/LICENSE-2.0
+--
+-- Unless required by applicable law or agreed to in writing, software
+-- distributed under the License is distributed on an "AS IS" BASIS,
+-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+-- See the License for the specific language governing permissions and
+-- limitations under the License.
+
+BEGIN;
+
+DROP TABLE MirrorFile;
+
+DROP TABLE Mirror; 
+
+END;
diff --git a/migrations/000044_AddMirrorSettings.up.sql b/migrations/000044_AddMirrorSettings.up.sql
new file mode 100644
index 0000000..8300f45
--- /dev/null
+++ b/migrations/000044_AddMirrorSettings.up.sql
@@ -0,0 +1,31 @@
+-- Copyright 2020 Google LLC
+--
+-- Licensed under the Apache License, Version 2.0 (the "License");
+-- you may not use this file except in compliance with the License.
+-- You may obtain a copy of the License at
+--
+--      http://www.apache.org/licenses/LICENSE-2.0
+--
+-- Unless required by applicable law or agreed to in writing, software
+-- distributed under the License is distributed on an "AS IS" BASIS,
+-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+-- See the License for the specific language governing permissions and
+-- limitations under the License.
+
+BEGIN;
+
+CREATE TABLE Mirror(
+    id SERIAL PRIMARY KEY,
+    index_file VARCHAR(500) NOT NULL,
+    export_root VARCHAR(500) NOT NULL,
+    cloud_storage_bucket VARCHAR(200) NOT NULL,
+    filename_root VARCHAR(500) NOT NULL
+);
+
+CREATE TABLE MirrorFile(
+    mirror_id INT REFERENCES Mirror(id),
+    filename VARCHAR(200) NOT NULL,
+    PRIMARY KEY(mirror_id, filename)
+);
+
+END;
diff --git a/terraform/service_export.tf b/terraform/service_export.tf
index 936cc63..60e30ee 100644
--- a/terraform/service_export.tf
+++ b/terraform/service_export.tf
@@ -106,6 +106,8 @@ resource "google_cloud_run_service" "export" {
       }
 
       container_concurrency = 10
+      // 30 seconds less than cloud scheduler maximum.
+      timeout_seconds = 570
     }
 
     metadata {
diff --git a/terraform/service_export_importer.tf b/terraform/service_export_importer.tf
index 275f629..fcfebc5 100644
--- a/terraform/service_export_importer.tf
+++ b/terraform/service_export_importer.tf
@@ -93,7 +93,9 @@ resource "google_cloud_run_service" "export-importer" {
         }
       }
 
-      container_concurrency = 10
+      container_concurrency = 5
+      // 30 seconds less than cloud scheduler maximum.
+      timeout_seconds = 570
     }
 
     metadata {
@@ -131,9 +133,9 @@ resource "google_service_account" "export-importer-invoker" {
 }
 
 resource "google_cloud_run_service_iam_member" "export-importer-invoker" {
-  project  = google_cloud_run_service.export.project
-  location = google_cloud_run_service.export.location
-  service  = google_cloud_run_service.export.name
+  project  = google_cloud_run_service.export-importer.project
+  location = google_cloud_run_service.export-importer.location
+  service  = google_cloud_run_service.export-importer.name
   role     = "roles/run.invoker"
   member   = "serviceAccount:${google_service_account.export-importer-invoker.email}"
 }
diff --git a/terraform/service_mirror.tf b/terraform/service_mirror.tf
new file mode 100644
index 0000000..ff960cf
--- /dev/null
+++ b/terraform/service_mirror.tf
@@ -0,0 +1,169 @@
+# Copyright 2020 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+#
+# Create and deploy the service
+#
+
+resource "google_service_account" "mirror" {
+  project      = data.google_project.project.project_id
+  account_id   = "en-mirror-sa"
+  display_name = "Exposure Notification Mirror"
+}
+
+resource "google_service_account_iam_member" "cloudbuild-deploy-mirror" {
+  service_account_id = google_service_account.mirror.id
+  role               = "roles/iam.serviceAccountUser"
+  member             = "serviceAccount:${data.google_project.project.number}@cloudbuild.gserviceaccount.com"
+
+  depends_on = [
+    google_project_service.services["cloudbuild.googleapis.com"],
+  ]
+}
+
+resource "google_secret_manager_secret_iam_member" "mirror-db" {
+  for_each = toset([
+    "sslcert",
+    "sslkey",
+    "sslrootcert",
+    "password",
+  ])
+
+  secret_id = google_secret_manager_secret.db-secret[each.key].id
+  role      = "roles/secretmanager.secretAccessor"
+  member    = "serviceAccount:${google_service_account.mirror.email}"
+}
+
+resource "google_project_iam_member" "mirror-observability" {
+  for_each = toset([
+    "roles/cloudtrace.agent",
+    "roles/logging.logWriter",
+    "roles/monitoring.metricWriter",
+    "roles/stackdriver.resourceMetadata.writer",
+  ])
+
+  project = var.project
+  role    = each.key
+  member  = "serviceAccount:${google_service_account.mirror.email}"
+}
+
+resource "google_cloud_run_service" "mirror" {
+  name     = "mirror"
+  location = var.cloudrun_location
+
+  autogenerate_revision_name = true
+
+  template {
+    spec {
+      service_account_name = google_service_account.mirror.email
+
+      containers {
+        image = "gcr.io/${data.google_project.project.project_id}/github.com/google/exposure-notifications-server/mirror:initial"
+
+        resources {
+          limits = {
+            cpu    = "2000m"
+            memory = "1G"
+          }
+        }
+
+        dynamic "env" {
+          for_each = merge(
+            local.common_cloudrun_env_vars,
+
+            // This MUST come last to allow overrides!
+            lookup(var.service_environment, "mirror", {}),
+          )
+
+          content {
+            name  = env.key
+            value = env.value
+          }
+        }
+      }
+
+      container_concurrency = 5
+      // 30 seconds less than cloud scheduler maximum.
+      timeout_seconds = 570
+    }
+
+    metadata {
+      annotations = {
+        "autoscaling.knative.dev/maxScale" : "10",
+        "run.googleapis.com/vpc-access-connector" : google_vpc_access_connector.connector.id
+      }
+    }
+  }
+
+  depends_on = [
+    google_project_service.services["run.googleapis.com"],
+    google_secret_manager_secret_iam_member.mirror-db,
+    null_resource.build,
+    null_resource.migrate,
+  ]
+
+  lifecycle {
+    ignore_changes = [
+      template[0].metadata[0].annotations,
+      template[0].spec[0].containers[0].image,
+    ]
+  }
+}
+
+
+#
+# Create scheduler job to invoke the service on a fixed interval.
+#
+
+resource "google_service_account" "mirror-invoker" {
+  project      = data.google_project.project.project_id
+  account_id   = "en-mirror-invoker-sa"
+  display_name = "Exposure Notification Mirror Invoker"
+}
+
+resource "google_cloud_run_service_iam_member" "mirror-invoker" {
+  project  = google_cloud_run_service.mirror.project
+  location = google_cloud_run_service.mirror.location
+  service  = google_cloud_run_service.mirror.name
+  role     = "roles/run.invoker"
+  member   = "serviceAccount:${google_service_account.mirror-invoker.email}"
+}
+
+resource "google_cloud_scheduler_job" "mirror-invoke" {
+  name             = "mirror-invoke"
+  region           = var.cloudscheduler_location
+  schedule         = "*/5 * * * *"
+  time_zone        = "America/Los_Angeles"
+  attempt_deadline = "600s"
+
+  retry_config {
+    retry_count = 1
+  }
+
+  http_target {
+    http_method = "GET"
+    uri         = "${google_cloud_run_service.mirror.status.0.url}/"
+    oidc_token {
+      audience              = google_cloud_run_service.mirror.status.0.url
+      service_account_email = google_service_account.mirror-invoker.email
+    }
+  }
+
+  depends_on = [
+    google_app_engine_application.app,
+    google_cloud_run_service.mirror,
+    google_cloud_run_service_iam_member.mirror-invoker,
+    google_project_service.services["cloudscheduler.googleapis.com"],
+  ]
+}
diff --git a/tools/admin-console/templates/export.html b/tools/admin-console/templates/export.html
index 5ad798f..ed1c26a 100644
--- a/tools/admin-console/templates/export.html
+++ b/tools/admin-console/templates/export.html
@@ -58,6 +58,16 @@
         </small>
       </div>
 
+      <div class="form-label-group">
+        <textarea name="ExcludeRegions" id="exclude-regions" rows="3"
+          placeholder="Exclude regions" class="form-control">{{.export.ExcludeRegionsOnePerLine}}</textarea>
+        <label for="exclude-regions">Exclude regions</label>
+        <small class="form-text text-muted">
+          One per line, leave blank for only using the output region (above).
+		  Allows you to exclude travelers from certain regions.
+        </small>
+      </div>
+
       <div class="form-label-group">
         <input type="text" name="BucketName" id="bucket-name" value="{{.export.BucketName}}"
           placeholder="Cloud storage bucket" class="form-control">


Test patch:
diff --git a/internal/admin/exports/form_test.go b/internal/admin/exports/form_test.go
new file mode 100644
index 0000000..a31d478
--- /dev/null
+++ b/internal/admin/exports/form_test.go
@@ -0,0 +1,26 @@
+package exports
+
+import (
+	"reflect"
+	"testing"
+)
+
+func TestSplitRegions(t *testing.T) {
+	tests := []struct {
+		r string
+		e []string
+	}{
+		{"", []string{}},
+		{"  test  ", []string{"test"}},
+		{"test\n\rfoo", []string{"foo", "test"}},
+		{"test\n\rfoo bar\n\r", []string{"foo bar", "test"}},
+		{"test\n\rfoo bar\n\r  ", []string{"foo bar", "test"}},
+		{"test\nfoo\n", []string{"foo", "test"}},
+	}
+
+	for i, test := range tests {
+		if res := splitRegions(test.r); !reflect.DeepEqual(res, test.e) {
+			t.Errorf("[%d] splitRegions(%v) = %v, expected %v", i, test.r, res, test.e)
+		}
+	}
+}
diff --git a/internal/e2e/e2e_test.go b/internal/e2e/e2e_test.go
index 00f44e5..e87b80c 100644
--- a/internal/e2e/e2e_test.go
+++ b/internal/e2e/e2e_test.go
@@ -30,11 +30,14 @@ import (
 
 	"github.com/google/exposure-notifications-server/internal/database"
 	exportapi "github.com/google/exposure-notifications-server/internal/export"
+	exportdb "github.com/google/exposure-notifications-server/internal/export/database"
 	"github.com/google/exposure-notifications-server/internal/integration"
 	publishdb "github.com/google/exposure-notifications-server/internal/publish/database"
 	"github.com/google/exposure-notifications-server/internal/storage"
 	"github.com/google/exposure-notifications-server/pkg/secrets"
 	"github.com/google/exposure-notifications-server/pkg/util"
+	"github.com/google/go-cmp/cmp"
+	pgx "github.com/jackc/pgx/v4"
 	"github.com/sethvargo/go-envconfig"
 	"github.com/sethvargo/go-retry"
 
@@ -109,7 +112,7 @@ func TestPublishEndpoint(t *testing.T) {
 		t.Skip()
 	}
 	// Increase this so that the db connection won't be canceled while polling for exported files
-	tc.DBConfig.PoolMaxConnIdle = 5 * time.Minute
+	tc.DBConfig.PoolMaxConnIdle = 10 * time.Minute
 
 	db, err := database.NewFromEnv(ctx, tc.DBConfig)
 	if err != nil {
@@ -125,9 +128,9 @@ func TestPublishEndpoint(t *testing.T) {
 		HealthAuthorityID: appName,
 	}
 
-	var wantExport []string
+	wantExport := make(map[string]bool)
 	for _, key := range keys {
-		wantExport = append(wantExport, key.Key)
+		wantExport[key.Key] = true
 	}
 
 	jwtCfg.ExposureKeys = keys
@@ -165,6 +168,7 @@ func TestPublishEndpoint(t *testing.T) {
 		t.Fatal(err)
 	}
 	gotExported := make(map[string]bool)
+	var expectedFile string
 	integration.Eventually(t, 30, 10*time.Second, func() error {
 		// Attempt to get the index
 		index, err := blobStore.GetObject(ctx, bucketName, integration.IndexFilePath(filenameRoot))
@@ -202,20 +206,83 @@ func TestPublishEndpoint(t *testing.T) {
 		}
 
 		for _, k := range key.Keys {
-			gotExported[base64.StdEncoding.EncodeToString(k.KeyData)] = true
-		}
-		allExported := true
-		for _, want := range wantExport {
-			if _, ok := gotExported[want]; !ok {
-				allExported = false
+			sk := base64.StdEncoding.EncodeToString(k.KeyData)
+			if _, ok := wantExport[sk]; ok {
+				expectedFile = latest
+				gotExported[sk] = true
 			}
 		}
-		if !allExported {
+		if diff := cmp.Diff(wantExport, gotExported); diff != "" {
 			return retry.RetryableError(errors.New("Not all keys are exported yet, keep waiting"))
 		}
 
 		return nil
 	})
+
+	// Mark the export in the past to force a cleanup
+	t.Logf("Exposure was exported: %q. Modify the date to 14 days earlier", expectedFile)
+	exportFile, err := exportdb.New(db).LookupExportFile(ctx, expectedFile)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := db.InTx(ctx, pgx.Serializable, func(tx pgx.Tx) error {
+		result, err := tx.Exec(ctx, `
+		UPDATE
+			ExportBatch
+		SET
+			start_timestamp = $1,
+			end_timestamp = $2
+		WHERE
+			batch_id = $3
+	`,
+			time.Now().Add(-30*24*time.Hour),
+			time.Now().Add(-29*24*time.Hour),
+			exportFile.BatchID,
+		)
+		if err != nil {
+			return err
+		}
+		if got, want := result.RowsAffected(), int64(1); got != want {
+			return fmt.Errorf("expected %v to be %v", got, want)
+		}
+		return nil
+	}); err != nil {
+		t.Fatal(err)
+	}
+
+	// Ensure the export was deleted
+	// Cloud scheduler runs every minute, waiting 2.5 minutes should be sufficient
+	t.Logf("Wait until export %q is cleaned up", expectedFile)
+	integration.Eventually(t, 30, 10*time.Second, func() error {
+		// Attempt to get the index
+		index, err := blobStore.GetObject(ctx, bucketName, integration.IndexFilePath(filenameRoot))
+		if err != nil {
+			if errors.Is(err, storage.ErrNotFound) {
+				return retry.RetryableError(err)
+			}
+			return err
+		}
+
+		// Ensure the new export is created
+		batchFiles := strings.Split(string(index), "\n")
+		for _, f := range batchFiles {
+			if f != exportFile.Filename {
+				continue
+			}
+
+			// Lookup the file, hope it's gone
+			if _, err := blobStore.GetObject(ctx, bucketName, f); err != nil {
+				if errors.Is(err, storage.ErrNotFound) {
+					return nil // expected
+				}
+				return err
+			}
+
+			return retry.RetryableError(fmt.Errorf("export file still exists"))
+		}
+
+		return nil
+	})
 }
 
 // getExposures finds the exposures that match the given criteria.
diff --git a/internal/export/database/export_test.go b/internal/export/database/export_test.go
index 372037a..cccdcac 100644
--- a/internal/export/database/export_test.go
+++ b/internal/export/database/export_test.go
@@ -17,6 +17,7 @@ package database
 import (
 	"context"
 	"errors"
+	"reflect"
 	"sort"
 	"testing"
 	"time"
@@ -142,6 +143,7 @@ func TestAddGetUpdateExportConfig(t *testing.T) {
 		OutputRegion:     "i1",
 		InputRegions:     []string{"US"},
 		IncludeTravelers: true,
+		ExcludeRegions:   []string{"EU"},
 		From:             fromTime,
 		Thru:             thruTime,
 		SignatureInfoIDs: []int64{42, 84},
@@ -541,6 +543,130 @@ func TestTravelerKeys(t *testing.T) {
 	}
 }
 
+// TestExcludeRegions ensures excluded regions are excluded.
+func TestExcludeRegions(t *testing.T) {
+	inclRegion, exclRegion := "US", "EU"
+	now := time.Now()
+	startTimestamp := now.Truncate(time.Hour).Add(-2 * time.Hour)
+	endTimestamp := startTimestamp.Add(time.Hour)
+
+	// Create person in main region.
+	mainUser := &publishmodel.Exposure{
+		ExposureKey: []byte("aaa"),
+		Regions:     []string{inclRegion},
+		CreatedAt:   startTimestamp,
+	}
+
+	// Create traveler&nonTraveler key out of the main region.
+	extTravUser := &publishmodel.Exposure{
+		ExposureKey: []byte("bbb"),
+		Regions:     []string{exclRegion},
+		CreatedAt:   startTimestamp,
+		Traveler:    true,
+	}
+	extUser := &publishmodel.Exposure{
+		ExposureKey: []byte("ccc"),
+		Regions:     []string{exclRegion},
+		CreatedAt:   startTimestamp,
+	}
+
+	tests := []struct {
+		name  string
+		users []*publishmodel.Exposure
+		want  []string
+	}{
+		{"1main,1ext", []*publishmodel.Exposure{mainUser, extUser}, []string{"aaa"}},
+		{"1main,2ext", []*publishmodel.Exposure{mainUser, extUser, extTravUser}, []string{"aaa"}},
+	}
+
+	for _, test := range tests {
+		test := test
+		t.Run(test.name, func(t *testing.T) {
+			t.Parallel()
+
+			testDB := database.NewTestDatabase(t)
+			ctx := context.Background()
+
+			// Add a config.
+			ec := &model.ExportConfig{
+				BucketName:     "bucket-name",
+				FilenameRoot:   "filename-root",
+				Period:         3600 * time.Second,
+				OutputRegion:   inclRegion,
+				From:           now.Add(-24 * time.Hour),
+				ExcludeRegions: []string{exclRegion},
+			}
+			if err := New(testDB).AddExportConfig(ctx, ec); err != nil {
+				t.Fatal(err)
+			}
+
+			// Create a batch for two hours ago to one hour ago.
+			eb := &model.ExportBatch{
+				ConfigID:       ec.ConfigID,
+				BucketName:     ec.BucketName,
+				FilenameRoot:   ec.FilenameRoot,
+				StartTimestamp: startTimestamp,
+				EndTimestamp:   endTimestamp,
+				OutputRegion:   ec.OutputRegion,
+				Status:         model.ExportBatchOpen,
+				ExcludeRegions: ec.ExcludeRegions,
+			}
+			if err := New(testDB).AddExportBatches(ctx, []*model.ExportBatch{eb}); err != nil {
+				t.Fatal(err)
+			}
+
+			// Add the keys to the database.
+			if _, err := publishdb.New(testDB).InsertAndReviseExposures(ctx, &publishdb.InsertAndReviseExposuresRequest{
+				Incoming:     []*publishmodel.Exposure{mainUser, extUser, extTravUser},
+				RequireToken: true,
+			}); err != nil {
+				t.Fatal(err)
+			}
+
+			// Re-fetch the ExposureBatch by leasing it; this is important to this test which is trying
+			// to ensure our dates are going in-and-out of the database correctly.
+			leased, err := New(testDB).LeaseBatch(ctx, time.Hour, now)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			// Check that the batch times from the database are *exactly* what we started with.
+			if eb.StartTimestamp.UnixNano() != leased.StartTimestamp.UnixNano() {
+				t.Errorf("Start timestamps did not align original: %d, leased: %d", eb.StartTimestamp.UnixNano(), leased.StartTimestamp.UnixNano())
+			}
+			if eb.EndTimestamp.UnixNano() != leased.EndTimestamp.UnixNano() {
+				t.Errorf("End timestamps did not align original: %d, leased: %d", eb.EndTimestamp.UnixNano(), leased.EndTimestamp.UnixNano())
+			}
+
+			// Lookup the keys; they must be only the key created_at the startTimestamp
+			// (because start is inclusive, end is exclusive).
+			criteria := publishdb.IterateExposuresCriteria{
+				IncludeRegions: []string{inclRegion},
+				SinceTimestamp: leased.StartTimestamp,
+				UntilTimestamp: leased.EndTimestamp,
+				ExcludeRegions: []string{exclRegion},
+			}
+
+			var got []*publishmodel.Exposure
+			_, err = publishdb.New(testDB).IterateExposures(ctx, criteria, func(exp *publishmodel.Exposure) error {
+				got = append(got, exp)
+				return nil
+			})
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			keys := []string{}
+			for _, exp := range got {
+				keys = append(keys, string(exp.ExposureKey))
+			}
+			if !reflect.DeepEqual(test.want, keys) {
+				t.Fatalf("%v got = %v, want %v", test.name, keys, test.want)
+			}
+		})
+	}
+}
+
 // TestKeysInBatch ensures that keys are fetched in the correct batch when they fall on boundary conditions.
 func TestKeysInBatch(t *testing.T) {
 	t.Parallel()
diff --git a/internal/mirror/database/mirror_test.go b/internal/mirror/database/mirror_test.go
new file mode 100644
index 0000000..f995500
--- /dev/null
+++ b/internal/mirror/database/mirror_test.go
@@ -0,0 +1,146 @@
+// Copyright 2020 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package database
+
+import (
+	"context"
+	"testing"
+
+	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/mirror/model"
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+)
+
+func TestAddListDeleteMirror(t *testing.T) {
+	t.Parallel()
+
+	testDB := database.NewTestDatabase(t)
+	ctx := context.Background()
+	mirrorDB := New(testDB)
+
+	want := []*model.Mirror{
+		{
+			IndexFile:          "https://mysever/exports/index.txt",
+			ExportRoot:         "https://myserver/",
+			CloudStorageBucket: "b1",
+			FilenameRoot:       "/storage/is/awesome/",
+		},
+		{
+			IndexFile:          "https://mysever2/exports/index.txt",
+			ExportRoot:         "https://myserver2/",
+			CloudStorageBucket: "b2",
+			FilenameRoot:       "/storage/is/awesome2/",
+		},
+	}
+	for _, w := range want {
+		if err := mirrorDB.AddMirror(ctx, w); err != nil {
+			t.Fatal(err)
+		}
+	}
+
+	got, err := mirrorDB.Mirrors(ctx)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if diff := cmp.Diff(want, got); diff != "" {
+		t.Errorf("mismatch (-want, +got):\n%s", diff)
+	}
+
+	for _, w := range want {
+		if err := mirrorDB.DeleteMirror(ctx, w); err != nil {
+			t.Fatal(err)
+		}
+	}
+
+	got, err = mirrorDB.Mirrors(ctx)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if len(got) != 0 {
+		t.Fatalf("got back deleted mirrors")
+	}
+}
+
+func TestFileLifecycle(t *testing.T) {
+	t.Parallel()
+
+	testDB := database.NewTestDatabase(t)
+	ctx := context.Background()
+	mirrorDB := New(testDB)
+
+	mirror := model.Mirror{
+		IndexFile:          "https://mysever/exports/index.txt",
+		ExportRoot:         "https://myserver/",
+		CloudStorageBucket: "b1",
+		FilenameRoot:       "/storage/is/awesome/",
+	}
+
+	if err := mirrorDB.AddMirror(ctx, &mirror); err != nil {
+		t.Fatal(err)
+	}
+
+	filenames := []string{"a", "b", "c"}
+	if err := mirrorDB.SaveFiles(ctx, mirror.ID, filenames); err != nil {
+		t.Fatal(err)
+	}
+
+	want := make([]*model.MirrorFile, len(filenames))
+	for i, fname := range filenames {
+		want[i] = &model.MirrorFile{
+			MirrorID: mirror.ID,
+			Filename: fname,
+		}
+	}
+
+	got, err := mirrorDB.ListFiles(ctx, mirror.ID)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	sorter := cmpopts.SortSlices(
+		func(a, b *model.MirrorFile) bool {
+			return a.Filename < b.Filename
+		})
+	if diff := cmp.Diff(want, got, sorter); diff != "" {
+		t.Errorf("mismatch (-want, +got):\n%s", diff)
+	}
+
+	// Change the files
+	filenames = []string{"c", "d", "e"}
+
+	if err := mirrorDB.SaveFiles(ctx, mirror.ID, filenames); err != nil {
+		t.Fatal(err)
+	}
+
+	want = make([]*model.MirrorFile, len(filenames))
+	for i, fname := range filenames {
+		want[i] = &model.MirrorFile{
+			MirrorID: mirror.ID,
+			Filename: fname,
+		}
+	}
+
+	got, err = mirrorDB.ListFiles(ctx, mirror.ID)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if diff := cmp.Diff(want, got, sorter); diff != "" {
+		t.Errorf("mismatch (-want, +got):\n%s", diff)
+	}
+}