Repository: google/exposure-notifications-server
Pull request #468 (2020-05-27 21:21:07)

Add cleanup for Container Registry
As we build more containers, GCR gets littered with a lot of images. This runs a job (disabled by default) that cleans up old images, keeping only the most recent 3.

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

This pull-request has been approved by: *<a href="https://github.com/google/exposure-notifications-server/pull/468#" title="Author self-approved">sethvargo</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:

- ~~[terraform/OWNERS](https://github.com/google/exposure-notifications-server/blob/master/terraform/OWNERS)~~ [sethvargo]

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":[]} -->
sethvargo: /assign @mikehelmick 

Patch:
diff --git a/internal/admin/authorizedapps/form.go b/internal/admin/authorizedapps/form.go
index b01abab..d6108a2 100644
--- a/internal/admin/authorizedapps/form.go
+++ b/internal/admin/authorizedapps/form.go
@@ -60,11 +60,14 @@ func (f *formData) PopulateAuthorizedApp(a *model.AuthorizedApp) error {
 	a.Platform = f.Platform
 	a.AllowedRegions = make(map[string]struct{})
 	for _, region := range strings.Split(f.AllowedRegions, "\n") {
-		a.AllowedRegions[region] = struct{}{}
+		a.AllowedRegions[strings.TrimSpace(region)] = struct{}{}
 	}
 	// SafetyNet
 	a.SafetyNetDisabled = f.SafetyNetDisabled
 	a.SafetyNetApkDigestSHA256 = strings.Split(f.SafetyNetApkDigestSHA256, "\n")
+	for i, s := range a.SafetyNetApkDigestSHA256 {
+		a.SafetyNetApkDigestSHA256[i] = strings.TrimSpace(s)
+	}
 	a.SafetyNetBasicIntegrity = f.SafetyNetBasicIntegrity
 	a.SafetyNetCTSProfileMatch = f.SafetyNetCTSProfileMatch
 	var err error
diff --git a/internal/admin/exports/form.go b/internal/admin/exports/form.go
index a32b639..8368922 100644
--- a/internal/admin/exports/form.go
+++ b/internal/admin/exports/form.go
@@ -15,6 +15,7 @@
 package exports
 
 import (
+	"strings"
 	"time"
 
 	"github.com/google/exposure-notifications-server/internal/admin"
@@ -22,7 +23,8 @@ import (
 )
 
 type formData struct {
-	Region       string        `form:"Region"`
+	OutputRegion string        `form:"OutputRegion"`
+	InputRegions string        `form:"InputRegions"`
 	BucketName   string        `form:"BucketName"`
 	FilenameRoot string        `form:"FilenameRoot"`
 	Period       time.Duration `form:"Period"`
@@ -46,7 +48,11 @@ func (f *formData) PopulateExportConfig(ec *model.ExportConfig) error {
 	ec.BucketName = f.BucketName
 	ec.FilenameRoot = f.FilenameRoot
 	ec.Period = f.Period
-	ec.Region = f.Region
+	ec.OutputRegion = f.OutputRegion
+	ec.InputRegions = strings.Split(f.InputRegions, "\n")
+	for i, s := range ec.InputRegions {
+		ec.InputRegions[i] = strings.TrimSpace(s)
+	}
 	ec.From = from
 	ec.Thru = thru
 	ec.SignatureInfoIDs = f.SigInfoIDs
diff --git a/internal/export/batcher.go b/internal/export/batcher.go
index 00d77e6..8cf3588 100644
--- a/internal/export/batcher.go
+++ b/internal/export/batcher.go
@@ -118,7 +118,8 @@ func (s *Server) maybeCreateBatches(ctx context.Context, ec *model.ExportConfig,
 			FilenameRoot:     ec.FilenameRoot,
 			StartTimestamp:   br.start,
 			EndTimestamp:     br.end,
-			Region:           ec.Region,
+			OutputRegion:     ec.OutputRegion,
+			InputRegions:     ec.InputRegions,
 			Status:           model.ExportBatchOpen,
 			SignatureInfoIDs: infoIds,
 		})
diff --git a/internal/export/database/export.go b/internal/export/database/export.go
index cf700d0..d068e7f 100644
--- a/internal/export/database/export.go
+++ b/internal/export/database/export.go
@@ -56,12 +56,12 @@ func (db *ExportDB) AddExportConfig(ctx context.Context, ec *model.ExportConfig)
 		row := tx.QueryRow(ctx, `
 			INSERT INTO
 				ExportConfig
-				(bucket_name, filename_root, period_seconds, region, from_timestamp, thru_timestamp, signature_info_ids)
+				(bucket_name, filename_root, period_seconds, output_region, from_timestamp, thru_timestamp, signature_info_ids, input_regions)
 			VALUES
-				($1, $2, $3, $4, $5, $6, $7)
+				($1, $2, $3, $4, $5, $6, $7, $8)
 			RETURNING config_id
-		`, ec.BucketName, ec.FilenameRoot, int(ec.Period.Seconds()), ec.Region,
-			ec.From, thru, ec.SignatureInfoIDs)
+		`, ec.BucketName, ec.FilenameRoot, int(ec.Period.Seconds()), ec.OutputRegion,
+			ec.From, thru, ec.SignatureInfoIDs, ec.InputRegions)
 
 		if err := row.Scan(&ec.ConfigID); err != nil {
 			return fmt.Errorf("fetching config_id: %w", err)
@@ -85,10 +85,10 @@ func (db *ExportDB) UpdateExportConfig(ctx context.Context, ec *model.ExportConf
 			UPDATE
 				ExportConfig
 			SET
-				bucket_name = $1, filename_root = $2, period_seconds = $3, region = $4, from_timestamp = $5, thru_timestamp = $6, signature_info_ids = $7
-			WHERE config_id = $8
-		`, ec.BucketName, ec.FilenameRoot, int(ec.Period.Seconds()), ec.Region,
-			ec.From, thru, ec.SignatureInfoIDs, ec.ConfigID)
+				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
+			WHERE config_id = $9
+		`, ec.BucketName, ec.FilenameRoot, int(ec.Period.Seconds()), ec.OutputRegion,
+			ec.From, thru, ec.SignatureInfoIDs, ec.InputRegions, ec.ConfigID)
 		if err != nil {
 			return fmt.Errorf("updating signatureinfo: %w", err)
 		}
@@ -108,7 +108,7 @@ func (db *ExportDB) GetExportConfig(ctx context.Context, id int64) (*model.Expor
 
 	row := conn.QueryRow(ctx, `
 		SELECT
-			config_id, bucket_name, filename_root, period_seconds, region, from_timestamp, thru_timestamp, signature_info_ids
+			config_id, bucket_name, filename_root, period_seconds, output_region, from_timestamp, thru_timestamp, signature_info_ids, input_regions
 		FROM
 			ExportConfig
 		WHERE
@@ -126,7 +126,7 @@ func (db *ExportDB) GetAllExportConfigs(ctx context.Context) ([]*model.ExportCon
 
 	rows, err := conn.Query(ctx, `
 		SELECT
-			config_id, bucket_name, filename_root, period_seconds, region, from_timestamp, thru_timestamp, signature_info_ids
+			config_id, bucket_name, filename_root, period_seconds, output_region, from_timestamp, thru_timestamp, signature_info_ids, input_regions
 		FROM
 			ExportConfig`)
 	if err != nil {
@@ -164,7 +164,7 @@ func (db *ExportDB) IterateExportConfigs(ctx context.Context, t time.Time, f fun
 
 	rows, err := conn.Query(ctx, `
 		SELECT
-			config_id, bucket_name, filename_root, period_seconds, region, from_timestamp, thru_timestamp, signature_info_ids
+			config_id, bucket_name, filename_root, period_seconds, output_region, from_timestamp, thru_timestamp, signature_info_ids, input_regions
 		FROM
 			ExportConfig
 		WHERE
@@ -194,7 +194,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, &m.Region, &m.From, &thru, &m.SignatureInfoIDs); err != nil {
+	if err := row.Scan(&m.ConfigID, &m.BucketName, &m.FilenameRoot, &periodSeconds, &m.OutputRegion, &m.From, &thru, &m.SignatureInfoIDs, &m.InputRegions); err != nil {
 		return nil, err
 	}
 	m.Period = time.Duration(periodSeconds) * time.Second
@@ -393,9 +393,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, region, status, signature_info_ids)
+				(config_id, bucket_name, filename_root, start_timestamp, end_timestamp, output_region, status, signature_info_ids, input_regions)
 			VALUES
-				($1, $2, $3, $4, $5, $6, $7, $8)
+				($1, $2, $3, $4, $5, $6, $7, $8, $9)
 		`)
 		if err != nil {
 			return err
@@ -403,7 +403,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.Region, eb.Status, eb.SignatureInfoIDs); err != nil {
+				eb.ConfigID, eb.BucketName, eb.FilenameRoot, eb.StartTimestamp, eb.EndTimestamp, eb.OutputRegion, eb.Status, eb.SignatureInfoIDs, eb.InputRegions); err != nil {
 				return err
 			}
 		}
@@ -535,7 +535,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, region, status, lease_expires, signature_info_ids
+			batch_id, config_id, bucket_name, filename_root, start_timestamp, end_timestamp, output_region, status, lease_expires, signature_info_ids, input_regions
 		FROM
 			ExportBatch
 		WHERE
@@ -545,7 +545,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.Region, &eb.Status, &expires, &eb.SignatureInfoIDs); 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); err != nil {
 		if err == pgx.ErrNoRows {
 			return nil, database.ErrNotFound
 		}
@@ -563,13 +563,14 @@ func (db *ExportDB) FinalizeBatch(ctx context.Context, eb *model.ExportBatch, fi
 		// Update ExportFile for the files created.
 		for i, file := range files {
 			ef := model.ExportFile{
-				BucketName: eb.BucketName,
-				Filename:   file,
-				BatchID:    eb.BatchID,
-				Region:     eb.Region,
-				BatchNum:   i + 1,
-				BatchSize:  batchSize,
-				Status:     model.ExportBatchComplete,
+				BucketName:   eb.BucketName,
+				Filename:     file,
+				BatchID:      eb.BatchID,
+				OutputRegion: eb.OutputRegion,
+				InputRegions: eb.InputRegions,
+				BatchNum:     i + 1,
+				BatchSize:    batchSize,
+				Status:       model.ExportBatchComplete,
 			}
 			if err := addExportFile(ctx, tx, &ef); err != nil {
 				if err == database.ErrKeyConflict {
@@ -648,7 +649,7 @@ func (db *ExportDB) LookupExportFile(ctx context.Context, filename string) (*mod
 
 	row := conn.QueryRow(ctx, `
 		SELECT
-			bucket_name, filename, batch_id, region, batch_num, batch_size, status
+			bucket_name, filename, batch_id, output_region, batch_num, batch_size, status, input_regions
 		FROM
 			ExportFile
 		WHERE
@@ -657,7 +658,7 @@ func (db *ExportDB) LookupExportFile(ctx context.Context, filename string) (*mod
 		`, filename)
 
 	ef := model.ExportFile{}
-	if err := row.Scan(&ef.BucketName, &ef.Filename, &ef.BatchID, &ef.Region, &ef.BatchNum, &ef.BatchSize, &ef.Status); err != nil {
+	if err := row.Scan(&ef.BucketName, &ef.Filename, &ef.BatchID, &ef.OutputRegion, &ef.BatchNum, &ef.BatchSize, &ef.Status, &ef.InputRegions); err != nil {
 		if err == pgx.ErrNoRows {
 			return nil, database.ErrNotFound
 		}
@@ -762,11 +763,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, region, batch_num, batch_size, status)
+			(bucket_name, filename, batch_id, output_region, batch_num, batch_size, status, input_regions)
 		VALUES
-			($1, $2, $3, $4, $5, $6, $7)
+			($1, $2, $3, $4, $5, $6, $7, $8)
 		ON CONFLICT (filename) DO NOTHING
-		`, ef.BucketName, ef.Filename, ef.BatchID, ef.Region, ef.BatchNum, ef.BatchSize, ef.Status)
+		`, ef.BucketName, ef.Filename, ef.BatchID, ef.OutputRegion, ef.BatchNum, ef.BatchSize, ef.Status, ef.InputRegions)
 	if err != nil {
 		return fmt.Errorf("inserting to ExportFile: %w", err)
 	}
diff --git a/internal/export/exportfile.go b/internal/export/exportfile.go
index 483fd2d..5267bee 100644
--- a/internal/export/exportfile.go
+++ b/internal/export/exportfile.go
@@ -165,7 +165,7 @@ func marshalContents(eb *model.ExportBatch, exposures []*publishmodel.Exposure,
 	pbeke := export.TemporaryExposureKeyExport{
 		StartTimestamp: proto.Uint64(uint64(eb.StartTimestamp.Unix())),
 		EndTimestamp:   proto.Uint64(uint64(eb.EndTimestamp.Unix())),
-		Region:         proto.String(eb.Region),
+		Region:         proto.String(eb.OutputRegion),
 		BatchNum:       proto.Int32(int32(batchNum)),
 		BatchSize:      proto.Int32(int32(batchSize)),
 		Keys:           pbeks,
diff --git a/internal/export/model/export_model.go b/internal/export/model/export_model.go
index b0e5fa3..52d8058 100644
--- a/internal/export/model/export_model.go
+++ b/internal/export/model/export_model.go
@@ -16,6 +16,7 @@ package model
 
 import (
 	"errors"
+	"strings"
 	"time"
 )
 
@@ -35,12 +36,23 @@ type ExportConfig struct {
 	BucketName       string        `db:"bucket_name"`
 	FilenameRoot     string        `db:"filename_root"`
 	Period           time.Duration `db:"period_seconds"`
-	Region           string        `db:"region"`
+	OutputRegion     string        `db:"output_region"`
+	InputRegions     []string      `db:"input_regions"`
 	From             time.Time     `db:"from_timestamp"`
 	Thru             time.Time     `db:"thru_timestamp"`
 	SignatureInfoIDs []int64       `db:"signature_info_ids"`
 }
 
+// EffectiveInputRegions either returns `InputRegions` or if that array is
+// empty, the output region (`Region`) is returned (in an array).
+func (ec *ExportConfig) EffectiveInputRegions() []string {
+	return effectiveInputRegions(ec.OutputRegion, ec.InputRegions)
+}
+
+func (ec *ExportConfig) InputRegionsOnePerLine() string {
+	return strings.Join(ec.InputRegions, "\n")
+}
+
 func (ec *ExportConfig) Validate() error {
 	if ec.Period > oneDay {
 		return errors.New("maximum period is 24h")
@@ -88,20 +100,34 @@ type ExportBatch struct {
 	FilenameRoot     string    `db:"filename_root" json:"filenameRoot"`
 	StartTimestamp   time.Time `db:"start_timestamp" json:"startTimestamp"`
 	EndTimestamp     time.Time `db:"end_timestamp" json:"endTimestamp"`
-	Region           string    `db:"region" json:"region"`
+	OutputRegion     string    `db:"region" json:"output_region"`
+	InputRegions     []string  `db:"input_regions" json:"inputRegions"`
 	Status           string    `db:"status" json:"status"`
 	LeaseExpires     time.Time `db:"lease_expires" json:"leaseExpires"`
 	SignatureInfoIDs []int64   `db:"signature_info_ids"`
 }
 
+// EffectiveInputRegions either returns `InputRegions` or if that array is
+// empty, the output region (`Region`) is returned (in an array).
+func (eb *ExportBatch) EffectiveInputRegions() []string {
+	return effectiveInputRegions(eb.OutputRegion, eb.InputRegions)
+}
+
 type ExportFile struct {
-	BucketName string `db:"bucket_name"`
-	Filename   string `db:"filename"`
-	BatchID    int64  `db:"batch_id"`
-	Region     string `db:"region"`
-	BatchNum   int    `db:"batch_num"`
-	BatchSize  int    `db:"batch_size"`
-	Status     string `db:"status"`
+	BucketName   string   `db:"bucket_name"`
+	Filename     string   `db:"filename"`
+	BatchID      int64    `db:"batch_id"`
+	OutputRegion string   `db:"output_region"`
+	InputRegions []string `db:"input_regions" json:"inputRegions"`
+	BatchNum     int      `db:"batch_num"`
+	BatchSize    int      `db:"batch_size"`
+	Status       string   `db:"status"`
+}
+
+// EffectiveInputRegions either returns `InputRegions` or if that array is
+// empty, the output region (`Region`) is returned (in an array).
+func (ef *ExportFile) EffectiveInputRegions() []string {
+	return effectiveInputRegions(ef.OutputRegion, ef.InputRegions)
 }
 
 type SignatureInfo struct {
@@ -145,3 +171,10 @@ func toHTMLTime(t time.Time) string {
 	}
 	return t.UTC().Format("15:04")
 }
+
+func effectiveInputRegions(outRegion string, inRegions []string) []string {
+	if inRegions != nil && len(inRegions) > 0 {
+		return inRegions
+	}
+	return []string{outRegion}
+}
diff --git a/internal/export/worker.go b/internal/export/worker.go
index de88f0d..b319c61 100644
--- a/internal/export/worker.go
+++ b/internal/export/worker.go
@@ -88,12 +88,12 @@ func (s *Server) WorkerHandler(w http.ResponseWriter, r *http.Request) {
 
 func (s *Server) exportBatch(ctx context.Context, eb *model.ExportBatch, emitIndexForEmptyBatch bool) error {
 	logger := logging.FromContext(ctx)
-	logger.Infof("Processing export batch %d (root: %q, region: %s), max records per file %d", eb.BatchID, eb.FilenameRoot, eb.Region, s.config.MaxRecords)
+	logger.Infof("Processing export batch %d (root: %q, region: %s), max records per file %d", eb.BatchID, eb.FilenameRoot, eb.OutputRegion, s.config.MaxRecords)
 
 	criteria := publishdb.IterateExposuresCriteria{
 		SinceTimestamp:      eb.StartTimestamp,
 		UntilTimestamp:      eb.EndTimestamp,
-		IncludeRegions:      []string{eb.Region},
+		IncludeRegions:      eb.EffectiveInputRegions(),
 		OnlyLocalProvenance: false, // include federated ids
 	}
 
@@ -124,7 +124,7 @@ func (s *Server) exportBatch(ctx context.Context, eb *model.ExportBatch, emitInd
 		logger.Infof("No records for export batch %d", eb.BatchID)
 	}
 
-	exposures, err = ensureMinNumExposures(exposures, eb.Region, s.config.MinRecords, s.config.PaddingRange)
+	exposures, err = ensureMinNumExposures(exposures, eb.OutputRegion, s.config.MinRecords, s.config.PaddingRange)
 	if err != nil {
 		return fmt.Errorf("ensureMinNumExposures: %w", err)
 	}
diff --git a/migrations/000025_multiple_region_exports.down.sql b/migrations/000025_multiple_region_exports.down.sql
new file mode 100644
index 0000000..0d096a4
--- /dev/null
+++ b/migrations/000025_multiple_region_exports.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 input_regions;
+ALTER TABLE ExportConfig RENAME COLUMN output_region TO region;
+
+ALTER TABLE ExportBatch DROP COLUMN input_regions;
+ALTER TABLE ExportBatch RENAME COLUMN output_region TO region;
+
+ALTER TABLE ExportFile DROP COLUMN input_regions;
+ALTER TABLE ExportFile RENAME COLUMN output_region TO region;
+
+END;
diff --git a/migrations/000025_multiple_region_exports.up.sql b/migrations/000025_multiple_region_exports.up.sql
new file mode 100644
index 0000000..5be0384
--- /dev/null
+++ b/migrations/000025_multiple_region_exports.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 input_regions VARCHAR(5) [];
+ALTER TABLE ExportConfig RENAME COLUMN region TO output_region;
+
+ALTER TABLE ExportBatch ADD COLUMN input_regions VARCHAR(5) [];
+ALTER TABLE ExportBatch RENAME COLUMN region TO output_region;
+
+ALTER TABLE ExportFile ADD COLUMN input_regions VARCHAR(5) [];
+ALTER TABLE ExportFile RENAME COLUMN region TO output_region;
+
+END;
diff --git a/terraform/service_gcr_cleaner.tf b/terraform/service_gcr_cleaner.tf
new file mode 100644
index 0000000..d9677ca
--- /dev/null
+++ b/terraform/service_gcr_cleaner.tf
@@ -0,0 +1,128 @@
+# 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" "gcr-cleaner" {
+  project      = data.google_project.project.project_id
+  account_id   = "gcr-cleaner"
+  display_name = "Container Registry Cleaner"
+}
+
+resource "google_service_account_iam_member" "cloudbuild-deploy-gcr-cleaner" {
+  service_account_id = google_service_account.gcr-cleaner.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_storage_bucket_iam_member" "gcr-cleaner-objectadmin" {
+  bucket = "artifacts.${data.google_project.project.project_id}.appspot.com"
+  role   = "roles/storage.objectAdmin"
+  member = "serviceAccount:${google_service_account.gcr-cleaner.email}"
+}
+
+resource "google_cloud_run_service" "gcr-cleaner" {
+  name     = "gcr-cleaner"
+  location = var.region
+
+  template {
+    spec {
+      service_account_name = google_service_account.gcr-cleaner.email
+
+      containers {
+        image = "us-docker.pkg.dev/gcr-cleaner/gcr-cleaner/gcr-cleaner"
+      }
+    }
+
+    metadata {
+      annotations = {
+        "autoscaling.knative.dev/maxScale" : "3",
+      }
+    }
+  }
+
+  depends_on = [
+    google_project_service.services["run.googleapis.com"],
+  ]
+
+  lifecycle {
+    ignore_changes = [
+      template,
+    ]
+  }
+}
+
+
+#
+# Create scheduler job to invoke the service on a fixed interval.
+#
+
+resource "google_service_account" "gcr-cleaner-invoker" {
+  project      = data.google_project.project.project_id
+  account_id   = "gcr-cleaner-invoker"
+  display_name = "Container Registry Cleaner Invoker"
+}
+
+resource "google_cloud_run_service_iam_member" "gcr-cleaner-invoker" {
+  project  = google_cloud_run_service.gcr-cleaner.project
+  location = google_cloud_run_service.gcr-cleaner.location
+  service  = google_cloud_run_service.gcr-cleaner.name
+  role     = "roles/run.invoker"
+  member   = "serviceAccount:${google_service_account.gcr-cleaner-invoker.email}"
+}
+
+resource "google_cloud_scheduler_job" "gcr-cleaner-worker" {
+  for_each = toset([
+    "cleanup-export",
+    "cleanup-exposure",
+    "export",
+    "exposure",
+    "federationin",
+    "federationout",
+    "generate",
+  ])
+
+  name             = "gcr-cleaner-worker-${each.value}"
+  schedule         = var.registry_cleanup_cron_schedule
+  time_zone        = "Etc/UTC"
+  attempt_deadline = "600s"
+
+  http_target {
+    http_method = "POST"
+    uri         = "${google_cloud_run_service.gcr-cleaner.status.0.url}/http"
+
+    body = base64encode(jsonencode({
+      repo           = "gcr.io/${data.google_project.project.project_id}/github.com/google/exposure-notifications-server/cmd/${each.value}"
+      grace          = "2h"
+      allowed_tagged = true
+      keep           = 3
+    }))
+
+    oidc_token {
+      audience              = google_cloud_run_service.gcr-cleaner.status.0.url
+      service_account_email = google_service_account.gcr-cleaner-invoker.email
+    }
+  }
+
+  depends_on = [
+    google_app_engine_application.app,
+    google_cloud_run_service_iam_member.gcr-cleaner-invoker,
+  ]
+}
diff --git a/terraform/vars.tf b/terraform/vars.tf
index ccda806..3518023 100644
--- a/terraform/vars.tf
+++ b/terraform/vars.tf
@@ -57,6 +57,13 @@ variable "generate_cron_schedule" {
   description = "Schedule to execute the generation service."
 }
 
+variable "registry_cleanup_cron_schedule" {
+  type    = string
+  default = "0 0 1 1 0"
+
+  description = "Schedule to execute cleanup of old Container Registry images."
+}
+
 terraform {
   required_providers {
     google      = "~> 3.20"
diff --git a/tools/admin-console/templates/export.html b/tools/admin-console/templates/export.html
index cb12876..383b863 100644
--- a/tools/admin-console/templates/export.html
+++ b/tools/admin-console/templates/export.html
@@ -19,15 +19,24 @@
 
 <form method="POST" action="/exports/{{.export.ConfigID}}" class="form-horizontal">
   <div class="form-group row">
-    <label class="control-label col-sm-3" for="Region">Region:</label>
+    <label class="control-label col-sm-3" for="OutputRegion">Output Region:</label>
     <div class="col-sm-6">
-      <input type="text" id="Region" name="Region" size="5" value="{{.export.Region}}">
-      <small id="RegionHelpBlock" class="form-text text-muted">The region of this export.
-        Must corresponding to regions accepted in Authorized Apps config, otherwise the export
-				will never have any data.
+      <input type="text" id="OutputRegion" name="OutputRegion" size="5" value="{{.export.OutputRegion}}">
+      <small id="RegionHelpBlock" class="form-text text-muted">This is the `output`
+				region that will apper in the export files. If the input regions (below) are left
+				blank, then this will also serve as the input region.
 			</small>
     </div>
   </div>
+	<div class="form-group row">
+		<label class="control-label col-sm-3" for="InputRegions">Input Regions:</label>
+		<div class="col-sm-6">
+			<textarea class="form-control" name="InputRegions" id="InputRegions" rows="3">{{.export.InputRegionsOnePerLine}}</textarea>
+			<small id="InputRegionsHelpBlock" class="form-text text-muted">One per line,
+				leave blank for only using the output region (above). Allows for an export
+			  to combine multiple regions/apps uploaded to this server.</small>
+		</div>
+	</div>
 
 	<div class="form-group row">
 		<label class="control-label col-sm-3" for="BucketName">Cloud Storage Bucket Name:</label>
diff --git a/tools/admin-console/templates/index.html b/tools/admin-console/templates/index.html
index 82b7438..2360b39 100644
--- a/tools/admin-console/templates/index.html
+++ b/tools/admin-console/templates/index.html
@@ -18,7 +18,7 @@
 <ul>
  {{if .exports}}
    {{range .exports}}
-     <li><a href="/exports/{{.ConfigID}}">{{.ConfigID}}</a> Region: {{.Region}} | Bucket: {{.BucketName}} | From: {{.FormattedFromTime}} | Thru: {{.FormattedThruTime}}</li>
+     <li><a href="/exports/{{.ConfigID}}">{{.ConfigID}}</a> Region: {{.OutputRegion}} | Bucket: {{.BucketName}} | From: {{.FormattedFromTime}} | Thru: {{.FormattedThruTime}}</li>
    {{end}}
  {{else}}
    <li><i>There are no export configurations</i></li>
diff --git a/tools/export-config/main.go b/tools/export-config/main.go
index 26f25c8..842d9e8 100644
--- a/tools/export-config/main.go
+++ b/tools/export-config/main.go
@@ -34,7 +34,7 @@ var (
 	bucketName        = flag.String("bucket-name", "", "The bucket name to store the export file.")
 	filenameRoot      = flag.String("filename-root", "", "The root filename for the export file.")
 	period            = flag.Duration("period", 24*time.Hour, "The frequency with which to create export files.")
-	region            = flag.String("region", "", "The region for the export batches/files.")
+	region            = flag.String("region", "", "The output region for the export batches/files.")
 	fromTimestamp     = flag.String("from-timestamp", "", "The timestamp (RFC3339) when this config becomes active.")
 	thruTimestamp     = flag.String("thru-timestamp", "", "The timestamp (RFC3339) when this config ends.")
 	signingKey        = flag.String("signing-key", "", "The KMS resource ID to use for signing batches.")
@@ -108,7 +108,7 @@ func main() {
 		BucketName:       *bucketName,
 		FilenameRoot:     *filenameRoot,
 		Period:           *period,
-		Region:           *region,
+		OutputRegion:     *region,
 		From:             fromTime,
 		Thru:             thruTime,
 		SignatureInfoIDs: []int64{si.ID},
diff --git a/tools/export-generate/main.go b/tools/export-generate/main.go
index 4d2dedb..e3157f6 100644
--- a/tools/export-generate/main.go
+++ b/tools/export-generate/main.go
@@ -42,7 +42,7 @@ var (
 	keyID          = flag.String("key-id", "some_id", "Value to use in verification_key_id")
 	keyVersion     = flag.String("key-version", "1", "Value to use in verification_key_version")
 	filenameRoot   = flag.String("filename-root", "/tmp/testExport-", "The root filename for the export file(s).")
-	region         = flag.String("region", "US", "The region for the test export.")
+	region         = flag.String("region", "US", "The output region for the test export.")
 	startTimestamp = flag.String("start-timestamp", "", "The test export start timestamp (RFC3339, e.g. 2020-05-01T15:00:00Z). (default yesterday)")
 	endTimestamp   = flag.String("end-timestamp", "", "The test export end timestamp (RFC3339, e.g. 2020-05-02T15:00:00Z). (default now)")
 	numKeys        = flag.Int("num-keys", 450, "Number of total random temporary exposure keys to generate. Ignored if tek-file set.")
@@ -142,7 +142,7 @@ func main() {
 		FilenameRoot:   *filenameRoot,
 		StartTimestamp: startTime,
 		EndTimestamp:   endTime,
-		Region:         *region,
+		OutputRegion:   *region,
 	}
 	numBatches := int(math.Ceil(float64(actualNumKeys) / float64(*batchSize)))
 	log.Printf("number of batches: %d", numBatches)


Test patch:
diff --git a/internal/admin/exports/view_test.go b/internal/admin/exports/view_test.go
index e366abf..daef59e 100644
--- a/internal/admin/exports/view_test.go
+++ b/internal/admin/exports/view_test.go
@@ -34,7 +34,7 @@ func TestRenderSignatureInfo(t *testing.T) {
 	m["export"] = exportConfig
 
 	sigInfos := []*model.SignatureInfo{
-		&model.SignatureInfo{ID: 5},
+		{ID: 5},
 	}
 	usedSigInfos := map[int64]bool{5: true}
 	m["usedSigInfos"] = usedSigInfos
diff --git a/internal/export/database/export_test.go b/internal/export/database/export_test.go
index f6be8d4..6c09a39 100644
--- a/internal/export/database/export_test.go
+++ b/internal/export/database/export_test.go
@@ -130,7 +130,8 @@ func TestAddGetUpdateExportConfig(t *testing.T) {
 		BucketName:       "mocked",
 		FilenameRoot:     "root",
 		Period:           3 * time.Hour,
-		Region:           "i1",
+		OutputRegion:     "i1",
+		InputRegions:     []string{"US"},
 		From:             fromTime,
 		Thru:             thruTime,
 		SignatureInfoIDs: []int64{42, 84},
@@ -154,6 +155,7 @@ func TestAddGetUpdateExportConfig(t *testing.T) {
 	want.Period = 15 * time.Minute
 	want.Thru = time.Time{}
 	want.SignatureInfoIDs = []int64{1, 2, 3, 4, 5}
+	want.InputRegions = []string{"US", "CA"}
 
 	if err := exportDB.UpdateExportConfig(ctx, want); err != nil {
 		t.Fatal(err)
@@ -199,7 +201,7 @@ func TestIterateExportConfigs(t *testing.T) {
 	}
 	for _, ec := range ecs {
 		ec.Period = time.Hour
-		ec.Region = "R"
+		ec.OutputRegion = "R"
 		if err := New(testDB).AddExportConfig(ctx, ec); err != nil {
 			t.Fatal(err)
 		}
@@ -231,7 +233,7 @@ func TestBatches(t *testing.T) {
 		BucketName:       "mocked",
 		FilenameRoot:     "root",
 		Period:           time.Hour,
-		Region:           "R",
+		OutputRegion:     "R",
 		From:             now,
 		Thru:             now.Add(time.Hour),
 		SignatureInfoIDs: []int64{1, 2, 3, 4},
@@ -249,7 +251,7 @@ func TestBatches(t *testing.T) {
 			ConfigID:         config.ConfigID,
 			BucketName:       config.BucketName,
 			FilenameRoot:     config.FilenameRoot,
-			Region:           config.Region,
+			OutputRegion:     config.OutputRegion,
 			Status:           model.ExportBatchOpen,
 			StartTimestamp:   start,
 			EndTimestamp:     end,
@@ -281,10 +283,10 @@ func TestBatches(t *testing.T) {
 				t.Fatal("could not lease a batch")
 			}
 			if got.ConfigID != config.ConfigID || got.FilenameRoot != config.FilenameRoot ||
-				got.Region != config.Region || got.BucketName != config.BucketName {
+				got.OutputRegion != config.OutputRegion || got.BucketName != config.BucketName {
 				t.Errorf("LeaseBatch: got (%d, %q, %q, %q), want (%d, %q, %q, %q)",
-					got.ConfigID, got.BucketName, got.FilenameRoot, got.Region,
-					config.ConfigID, config.BucketName, config.FilenameRoot, config.Region)
+					got.ConfigID, got.BucketName, got.FilenameRoot, got.OutputRegion,
+					config.ConfigID, config.BucketName, config.FilenameRoot, config.OutputRegion)
 			}
 			if got.Status != model.ExportBatchPending {
 				t.Errorf("LeaseBatch: got status %q, want pending", got.Status)
@@ -342,7 +344,7 @@ func TestFinalizeBatch(t *testing.T) {
 		BucketName:   "some-bucket",
 		FilenameRoot: "filename-root",
 		Period:       time.Minute,
-		Region:       "US",
+		OutputRegion: "US",
 	}
 	if err := exportDB.AddExportConfig(ctx, ec); err != nil {
 		t.Fatal(err)
@@ -355,7 +357,7 @@ func TestFinalizeBatch(t *testing.T) {
 		FilenameRoot:   ec.FilenameRoot,
 		StartTimestamp: now.Add(-2 * time.Hour),
 		EndTimestamp:   now.Add(-time.Hour),
-		Region:         ec.Region,
+		OutputRegion:   ec.OutputRegion,
 		Status:         model.ExportBatchOpen,
 	}
 	if err := exportDB.AddExportBatches(ctx, []*model.ExportBatch{eb}); err != nil {
@@ -409,13 +411,13 @@ func TestFinalizeBatch(t *testing.T) {
 			t.Fatal(err)
 		}
 		want := &model.ExportFile{
-			BucketName: eb.BucketName,
-			Filename:   filename,
-			BatchID:    eb.BatchID,
-			Region:     eb.Region,
-			BatchNum:   i + 1,
-			BatchSize:  batchSize,
-			Status:     model.ExportBatchComplete,
+			BucketName:   eb.BucketName,
+			Filename:     filename,
+			BatchID:      eb.BatchID,
+			OutputRegion: eb.OutputRegion,
+			BatchNum:     i + 1,
+			BatchSize:    batchSize,
+			Status:       model.ExportBatchComplete,
 		}
 		if diff := cmp.Diff(want, got); diff != "" {
 			t.Errorf("mismatch for %q (-want, +got):\n%s", filename, diff)
@@ -436,7 +438,7 @@ func TestKeysInBatch(t *testing.T) {
 		BucketName:   "bucket-name",
 		FilenameRoot: "filename-root",
 		Period:       3600 * time.Second,
-		Region:       "US",
+		OutputRegion: "US",
 		From:         now.Add(-24 * time.Hour),
 	}
 	if err := New(testDB).AddExportConfig(ctx, ec); err != nil {
@@ -452,7 +454,7 @@ func TestKeysInBatch(t *testing.T) {
 		FilenameRoot:   ec.FilenameRoot,
 		StartTimestamp: startTimestamp,
 		EndTimestamp:   endTimestamp,
-		Region:         ec.Region,
+		OutputRegion:   ec.OutputRegion,
 		Status:         model.ExportBatchOpen,
 	}
 	if err := New(testDB).AddExportBatches(ctx, []*model.ExportBatch{eb}); err != nil {
@@ -462,14 +464,14 @@ func TestKeysInBatch(t *testing.T) {
 	// Create key aligned with the StartTimestamp
 	sek := &publishmodel.Exposure{
 		ExposureKey: []byte("aaa"),
-		Regions:     []string{ec.Region},
+		Regions:     []string{ec.OutputRegion},
 		CreatedAt:   startTimestamp,
 	}
 
 	// Create key aligned with the EndTimestamp
 	eek := &publishmodel.Exposure{
 		ExposureKey: []byte("bbb"),
-		Regions:     []string{ec.Region},
+		Regions:     []string{ec.OutputRegion},
 		CreatedAt:   endTimestamp,
 	}
 
@@ -496,7 +498,7 @@ func TestKeysInBatch(t *testing.T) {
 	// 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{leased.Region},
+		IncludeRegions: []string{leased.OutputRegion},
 		SinceTimestamp: leased.StartTimestamp,
 		UntilTimestamp: leased.EndTimestamp,
 	}
diff --git a/internal/export/exportfile_test.go b/internal/export/exportfile_test.go
index 4a3e46b..c5ca044 100644
--- a/internal/export/exportfile_test.go
+++ b/internal/export/exportfile_test.go
@@ -39,7 +39,7 @@ func TestMarshalUnmarshalExportFile(t *testing.T) {
 		FilenameRoot:     "files",
 		StartTimestamp:   batchStartTime,
 		EndTimestamp:     batchEndTime,
-		Region:           "US",
+		OutputRegion:     "US",
 		Status:           "",
 		LeaseExpires:     time.Time{},
 		SignatureInfoIDs: []int64{1, 2},
diff --git a/internal/export/model/export_model_test.go b/internal/export/model/export_model_test.go
new file mode 100644
index 0000000..cb8a72e
--- /dev/null
+++ b/internal/export/model/export_model_test.go
@@ -0,0 +1,115 @@
+// 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
+
+import (
+	"testing"
+	"time"
+
+	"github.com/google/go-cmp/cmp"
+)
+
+func TextExportRegions(t *testing.T) {
+	cases := []struct {
+		name         string
+		region       string
+		inputRegions []string
+		effective    []string
+	}{
+		{
+			name:         "single input",
+			region:       "US",
+			inputRegions: []string{},
+			effective:    []string{"US"},
+		},
+		{
+			name:         "multiple",
+			region:       "US",
+			inputRegions: []string{"WA", "OR"},
+			effective:    []string{"WA", "OR"},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ec := ExportConfig{
+				OutputRegion: tc.region,
+				InputRegions: tc.inputRegions,
+			}
+			got := ec.EffectiveInputRegions()
+			if diff := cmp.Diff(tc.effective, got); diff != "" {
+				t.Fatalf("mismatch (-want, +got):\n%s", diff)
+			}
+		})
+	}
+}
+
+func TestExportConfigFormatting(t *testing.T) {
+	cases := []struct {
+		name              string
+		ec                *ExportConfig
+		formattedFromTime string
+		formattedThruTime string
+		fromHTMLDate      string
+		fromHTMLTime      string
+		thruHTMLDate      string
+		thruHTMLTime      string
+	}{
+		{
+			name: "both empty",
+			ec: &ExportConfig{
+				From: time.Time{},
+				Thru: time.Time{},
+			},
+			formattedFromTime: "Mon Jan  1 00:00:00 UTC 0001",
+		},
+		{
+			name: "not data",
+			ec: &ExportConfig{
+				From: time.Date(2020, 05, 01, 12, 01, 02, 0, time.UTC),
+				Thru: time.Date(2020, 06, 01, 12, 01, 02, 0, time.UTC),
+			},
+			formattedFromTime: "Fri May  1 12:01:02 UTC 2020",
+			formattedThruTime: "Mon Jun  1 12:01:02 UTC 2020",
+			fromHTMLDate:      "2020-05-01",
+			fromHTMLTime:      "12:01",
+			thruHTMLDate:      "2020-06-01",
+			thruHTMLTime:      "12:01",
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			if got := tc.ec.FormattedFromTime(); tc.formattedFromTime != got {
+				t.Errorf("FormattedFromTime want: '%v' got '%v'", tc.formattedFromTime, got)
+			}
+			if got := tc.ec.FormattedThruTime(); tc.formattedThruTime != got {
+				t.Errorf("FormattedThruTime want: '%v' got '%v'", tc.formattedThruTime, got)
+			}
+			if got := tc.ec.FromHTMLDate(); tc.fromHTMLDate != got {
+				t.Errorf("FromHTMLDate want: '%v' got '%v'", tc.fromHTMLDate, got)
+			}
+			if got := tc.ec.FromHTMLTime(); tc.fromHTMLTime != got {
+				t.Errorf("FromHTMLTime want: '%v' got '%v'", tc.fromHTMLTime, got)
+			}
+			if got := tc.ec.ThruHTMLDate(); tc.thruHTMLDate != got {
+				t.Errorf("ThruHTMLDate want: '%v' got '%v'", tc.thruHTMLDate, got)
+			}
+			if got := tc.ec.ThruHTMLTime(); tc.thruHTMLTime != got {
+				t.Errorf("ThruHTMLTime want: '%v' got '%v'", tc.thruHTMLTime, got)
+			}
+		})
+	}
+}