Repository: google/exposure-notifications-server
Pull request #1174 (2020-11-19 21:39:41)

Add mirror to admin console
Fixes https://github.com/google/exposure-notifications-server/issues/1069

**Release Note**

<!-- Enter your extended release note in the below block. If the PR requires
additional action from users switching to the new release, include the string
"action required". If no release note is required, write "NONE". -->

```release-note
Add mirror to admin console
```

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/1174#" 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:

- ~~[OWNERS](https://github.com/google/exposure-notifications-server/blob/main/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/index/index.go b/internal/admin/index/index.go
index faed149..e81eb62 100644
--- a/internal/admin/index/index.go
+++ b/internal/admin/index/index.go
@@ -23,6 +23,7 @@ import (
 	aadb "github.com/google/exposure-notifications-server/internal/authorizedapp/database"
 	exdb "github.com/google/exposure-notifications-server/internal/export/database"
 	exportimportdatabase "github.com/google/exposure-notifications-server/internal/exportimport/database"
+	mirrordatabase "github.com/google/exposure-notifications-server/internal/mirror/database"
 	"github.com/google/exposure-notifications-server/internal/serverenv"
 	hadb "github.com/google/exposure-notifications-server/internal/verification/database"
 )
@@ -82,6 +83,14 @@ func (h *indexHandler) Execute(c *gin.Context) {
 	}
 	m["siginfos"] = sigInfos
 
+	// Load mirrors
+	mirrors, err := mirrordatabase.New(db).Mirrors(ctx)
+	if err != nil {
+		admin.ErrorPage(c, err.Error())
+		return
+	}
+	m["mirrors"] = mirrors
+
 	m.AddTitle("Exposure Notification Key Server - Admin Console")
 	c.HTML(http.StatusOK, "index", m)
 }
diff --git a/internal/admin/mirrors/form.go b/internal/admin/mirrors/form.go
new file mode 100644
index 0000000..6eb18f5
--- /dev/null
+++ b/internal/admin/mirrors/form.go
@@ -0,0 +1,43 @@
+// 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, softwar
+// 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 mirrors is part of the admin system.
+package mirrors
+
+import (
+	mirrormodel "github.com/google/exposure-notifications-server/internal/mirror/model"
+)
+
+type formData struct {
+	Action string `form:"Action" binding:"required"`
+
+	IndexFile          string `form:"IndexFile" binding:"required"`
+	ExportRoot         string `form:"ExportRoot" binding:"required"`
+	CloudStorageBucket string `form:"CloudStorageBucket" binding:"required"`
+	FilenameRoot       string `form:"FilenameRoot" binding:"required"`
+	FilenameRewrite    string `form:"FilenameRewrite"`
+}
+
+func (f *formData) PopulateMirror(m *mirrormodel.Mirror) error {
+	m.IndexFile = f.IndexFile
+	m.ExportRoot = f.ExportRoot
+	m.CloudStorageBucket = f.CloudStorageBucket
+	m.FilenameRoot = f.FilenameRoot
+	if f.FilenameRewrite != "" {
+		m.FilenameRewrite = &f.FilenameRewrite
+	} else {
+		m.FilenameRewrite = nil
+	}
+	return nil
+}
diff --git a/internal/admin/mirrors/save.go b/internal/admin/mirrors/save.go
new file mode 100644
index 0000000..c2232a9
--- /dev/null
+++ b/internal/admin/mirrors/save.go
@@ -0,0 +1,94 @@
+// 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, softwar
+// 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 mirrors
+
+import (
+	"fmt"
+	"net/http"
+	"strconv"
+
+	"github.com/gin-gonic/gin"
+	"github.com/google/exposure-notifications-server/internal/admin"
+	mirrordatabase "github.com/google/exposure-notifications-server/internal/mirror/database"
+	mirrormodel "github.com/google/exposure-notifications-server/internal/mirror/model"
+	"github.com/google/exposure-notifications-server/internal/serverenv"
+)
+
+type saveController struct {
+	config *admin.Config
+	env    *serverenv.ServerEnv
+}
+
+func NewSave(c *admin.Config, env *serverenv.ServerEnv) admin.Controller {
+	return &saveController{config: c, env: env}
+}
+
+func (v *saveController) Execute(c *gin.Context) {
+	var form formData
+	if err := c.Bind(&form); err != nil {
+		admin.ErrorPage(c, err.Error())
+		return
+	}
+
+	ctx := c.Request.Context()
+	m := admin.TemplateMap{}
+
+	db := mirrordatabase.New(v.env.Database())
+	mirror := &mirrormodel.Mirror{}
+	if idParam := c.Param("id"); idParam != "0" {
+		cfgID, err := strconv.ParseInt(idParam, 10, 64)
+		if err != nil {
+			admin.ErrorPage(c, "unable to parse `id` param.")
+			return
+		}
+		mirror, err = db.GetMirror(ctx, cfgID)
+		if err != nil {
+			admin.ErrorPage(c, fmt.Sprintf("Error loading mirror: %v", err))
+			return
+		}
+	}
+
+	switch form.Action {
+	case "delete":
+		if err := db.DeleteMirror(ctx, mirror); err != nil {
+			admin.ErrorPage(c, fmt.Sprintf("Failed to delete mirror: %v", err))
+			return
+		}
+
+		m.AddSuccess(fmt.Sprintf("Deleted mirror %d", mirror.ID))
+		c.Redirect(http.StatusSeeOther, "/")
+	case "save":
+		if err := form.PopulateMirror(mirror); err != nil {
+			admin.ErrorPage(c, fmt.Sprintf("error processing mirror: %v", err))
+			return
+		}
+
+		updateFn := db.AddMirror
+		if mirror.ID != 0 {
+			updateFn = db.UpdateMirror
+		}
+		if err := updateFn(ctx, mirror); err != nil {
+			admin.ErrorPage(c, fmt.Sprintf("Error writing mirror: %v", err))
+			return
+		}
+
+		m.AddSuccess(fmt.Sprintf("Updated mirror %d", mirror.ID))
+
+		m["mirror"] = mirror
+		c.HTML(http.StatusOK, "mirror", m)
+	default:
+		admin.ErrorPage(c, "Invalid form action")
+	}
+}
diff --git a/internal/admin/mirrors/view.go b/internal/admin/mirrors/view.go
new file mode 100644
index 0000000..ef8b3ef
--- /dev/null
+++ b/internal/admin/mirrors/view.go
@@ -0,0 +1,70 @@
+// 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, softwar
+// 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 mirrors
+
+import (
+	"fmt"
+	"net/http"
+	"strconv"
+
+	"github.com/gin-gonic/gin"
+	"github.com/google/exposure-notifications-server/internal/admin"
+	mirrordatabase "github.com/google/exposure-notifications-server/internal/mirror/database"
+	mirrormodel "github.com/google/exposure-notifications-server/internal/mirror/model"
+	"github.com/google/exposure-notifications-server/internal/serverenv"
+)
+
+type viewController struct {
+	config *admin.Config
+	env    *serverenv.ServerEnv
+}
+
+func NewView(c *admin.Config, env *serverenv.ServerEnv) admin.Controller {
+	return &viewController{config: c, env: env}
+}
+
+func (v *viewController) Execute(c *gin.Context) {
+	ctx := c.Request.Context()
+	m := admin.TemplateMap{}
+
+	db := mirrordatabase.New(v.env.Database())
+	mirror := &mirrormodel.Mirror{}
+	if idParam := c.Param("id"); idParam != "0" {
+		id, err := strconv.ParseInt(idParam, 10, 64)
+		if err != nil {
+			admin.ErrorPage(c, "unable to parse `id` param.")
+			return
+		}
+		mirror, err = db.GetMirror(ctx, id)
+		if err != nil {
+			admin.ErrorPage(c, fmt.Sprintf("Error loading mirror: %v", err))
+			return
+		}
+	}
+
+	var mirrorFiles []*mirrormodel.MirrorFile
+	if mirror.ID != 0 {
+		var err error
+		mirrorFiles, err = db.ListFiles(ctx, mirror.ID)
+		if err != nil {
+			admin.ErrorPage(c, fmt.Sprintf("Error loading mirror files: %v", err))
+			return
+		}
+	}
+
+	m["mirror"] = mirror
+	m["mirrorFiles"] = mirrorFiles
+	c.HTML(http.StatusOK, "mirror", m)
+}
diff --git a/internal/database/database_util.go b/internal/database/database_util.go
index b0e9f14..6e48296 100644
--- a/internal/database/database_util.go
+++ b/internal/database/database_util.go
@@ -24,6 +24,7 @@ import (
 	"path/filepath"
 	"runtime"
 	"strconv"
+	"strings"
 	"testing"
 	"time"
 
@@ -65,11 +66,14 @@ func NewTestDatabaseWithConfig(tb testing.TB) (*DB, *Config) {
 		tb.Fatalf("failed to create Docker pool: %s", err)
 	}
 
+	// Determine the container image to use.
+	repo, tag := postgresRepo(tb)
+
 	// Start the container.
 	dbname, username, password := "en-server", "my-username", "abcd1234"
 	container, err := pool.RunWithOptions(&dockertest.RunOptions{
-		Repository: "postgres",
-		Tag:        "12-alpine",
+		Repository: repo,
+		Tag:        tag,
 		Env: []string{
 			"LANG=C",
 			"POSTGRES_DB=" + dbname,
@@ -81,6 +85,11 @@ func NewTestDatabaseWithConfig(tb testing.TB) (*DB, *Config) {
 		tb.Fatalf("failed to start postgres container: %s", err)
 	}
 
+	// Force the database container to stop.
+	if err := container.Expire(120); err != nil {
+		tb.Fatalf("failed to force-stop container: %v", err)
+	}
+
 	// Ensure container is cleaned up.
 	tb.Cleanup(func() {
 		if err := pool.Purge(container); err != nil {
@@ -105,16 +114,12 @@ func NewTestDatabaseWithConfig(tb testing.TB) (*DB, *Config) {
 	q.Add("sslmode", "disable")
 	connURL.RawQuery = q.Encode()
 
-	// Wait for the container to start - we'll retry connections in a loop below,
-	// but there's no point in trying immediately.
-	time.Sleep(1 * time.Second)
-
-	b, err := retry.NewFibonacci(500 * time.Millisecond)
+	// Wait for the container to start.
+	b, err := retry.NewConstant(1 * time.Second)
 	if err != nil {
 		tb.Fatalf("failed to configure backoff: %v", err)
 	}
-	b = retry.WithMaxRetries(10, b)
-	b = retry.WithCappedDuration(10*time.Second, b)
+	b = retry.WithMaxRetries(30, b)
 
 	// Establish a connection to the database. Use a Fibonacci backoff instead of
 	// exponential so wait times scale appropriately.
@@ -192,3 +197,16 @@ func dbMigrationsDir() string {
 	}
 	return filepath.Join(filepath.Dir(filename), "../../migrations")
 }
+
+func postgresRepo(tb testing.TB) (string, string) {
+	postgresImageRef := os.Getenv("CI_POSTGRES_IMAGE")
+	if postgresImageRef == "" {
+		postgresImageRef = "postgres:13-alpine"
+	}
+
+	parts := strings.SplitN(postgresImageRef, ":", 2)
+	if len(parts) != 2 {
+		tb.Fatalf("invalid postgres ref %v", postgresImageRef)
+	}
+	return parts[0], parts[1]
+}
diff --git a/internal/export/exportfile.go b/internal/export/exportfile.go
index 6e13b95..5a5bf39 100644
--- a/internal/export/exportfile.go
+++ b/internal/export/exportfile.go
@@ -20,6 +20,7 @@ import (
 	"crypto"
 	"crypto/rand"
 	"crypto/sha256"
+	"encoding/hex"
 	"fmt"
 	"io/ioutil"
 	"sort"
@@ -51,18 +52,18 @@ type Signer struct {
 	Signer        crypto.Signer
 }
 
-// MarshalExportFile converts the inputs into an encoded byte array.
-func MarshalExportFile(eb *model.ExportBatch, exposures, revisedExposures []*publishmodel.Exposure, batchNum, batchSize int, signers []*Signer) ([]byte, error) {
+// MarshalExportFile converts the inputs into an encoded byte array and sha256 (base64) of the marshaled protobuf contents.
+func MarshalExportFile(eb *model.ExportBatch, exposures, revisedExposures []*publishmodel.Exposure, batchNum, batchSize int, signers []*Signer) ([]byte, string, error) {
 	// create main exposure key export binary
 	expContents, err := marshalContents(eb, exposures, revisedExposures, int32(batchNum), int32(batchSize), signers)
 	if err != nil {
-		return nil, fmt.Errorf("unable to marshal exposure keys: %w", err)
+		return nil, "", fmt.Errorf("unable to marshal exposure keys: %w", err)
 	}
 
-	// create signature file
-	sigContents, err := marshalSignature(expContents, int32(batchNum), int32(batchSize), signers)
+	// create signature file - all exports are generated w/ batchNum: 1 batchSize: 1 - have signature match
+	sigContents, err := marshalSignature(expContents, int32(1), int32(1), signers)
 	if err != nil {
-		return nil, fmt.Errorf("unable to marshal signature file: %w", err)
+		return nil, "", fmt.Errorf("unable to marshal signature file: %w", err)
 	}
 
 	// create compressed archive of binary and signature
@@ -70,24 +71,27 @@ func MarshalExportFile(eb *model.ExportBatch, exposures, revisedExposures []*pub
 	zw := zip.NewWriter(buf)
 	zf, err := zw.Create(exportBinaryName)
 	if err != nil {
-		return nil, fmt.Errorf("unable to create zip entry for export: %w", err)
+		return nil, "", fmt.Errorf("unable to create zip entry for export: %w", err)
 	}
 	_, err = zf.Write(expContents)
 	if err != nil {
-		return nil, fmt.Errorf("unable to write export to archive: %w", err)
+		return nil, "", fmt.Errorf("unable to write export to archive: %w", err)
 	}
 	zf, err = zw.Create(exportSignatureName)
 	if err != nil {
-		return nil, fmt.Errorf("unable to create zip entry for signature: %w", err)
+		return nil, "", fmt.Errorf("unable to create zip entry for signature: %w", err)
 	}
 	_, err = zf.Write(sigContents)
 	if err != nil {
-		return nil, fmt.Errorf("unable to write signature to archive: %w", err)
+		return nil, "", fmt.Errorf("unable to write signature to archive: %w", err)
 	}
 	if err := zw.Close(); err != nil {
-		return nil, fmt.Errorf("unable to close archive: %w", err)
+		return nil, "", fmt.Errorf("unable to close archive: %w", err)
 	}
-	return buf.Bytes(), nil
+
+	digest := sha256.Sum256(expContents)
+	pbSHA := hex.EncodeToString(digest[:])
+	return buf.Bytes(), pbSHA, nil
 }
 
 // UnmarshalExportFile extracts the protobuf encoded exposure key present in the zip archived payload.
@@ -172,6 +176,17 @@ func assignReportType(reportType *string, pbek *export.TemporaryExposureKey) {
 	}
 }
 
+// The batch num and batch size are always set to 1 / 1 when actually generating a file.
+// This is to avoid a device side issue where all batches must be passed in toegher.
+// By making all batches size one, we make the unit of atomicity a single export file, avoiding
+// the need for clients to do any kind of batching before passing to the OS.
+//
+// If this is part of a larger batch, the end timestamps are adjusted. Android hashes
+// the start/end/batchNum to de-duplicate. If there are X files that have the same timing
+// metadata, then only the first would get processed. We compensate here by bumping the end
+// timestamp by the file num in the batch.
+//
+//
 func marshalContents(eb *model.ExportBatch, exposures, revisedExposures []*publishmodel.Exposure, batchNum int32, batchSize int32, signers []*Signer) ([]byte, error) {
 	exportBytes := fixedHeader
 	if len(exportBytes) != fixedHeaderWidth {
@@ -205,12 +220,16 @@ func marshalContents(eb *model.ExportBatch, exposures, revisedExposures []*publi
 		exportSigInfos = append(exportSigInfos, createSignatureInfo(si.SignatureInfo))
 	}
 
+	offset := int64(0)
+	if batchSize > 1 {
+		offset = int64(batchNum)
+	}
 	pbeke := export.TemporaryExposureKeyExport{
 		StartTimestamp: proto.Uint64(uint64(eb.StartTimestamp.Unix())),
-		EndTimestamp:   proto.Uint64(uint64(eb.EndTimestamp.Unix())),
+		EndTimestamp:   proto.Uint64(uint64(eb.EndTimestamp.Unix() + offset)),
 		Region:         proto.String(eb.OutputRegion),
-		BatchNum:       proto.Int32(batchNum),
-		BatchSize:      proto.Int32(batchSize),
+		BatchNum:       proto.Int32(1), // all batches are now size 1 (single file)
+		BatchSize:      proto.Int32(1), // so it's always 1 of 1.
 		Keys:           pbeks,
 		RevisedKeys:    pbRevisedKeys,
 		SignatureInfos: exportSigInfos,
diff --git a/internal/export/worker.go b/internal/export/worker.go
index eddb96c..ae3bb9e 100644
--- a/internal/export/worker.go
+++ b/internal/export/worker.go
@@ -147,8 +147,8 @@ func (s *Server) batchExposures(ctx context.Context, criteria publishdatabase.It
 	// determine the total number of groups (which is embedded in each export
 	// file). This technique avoids SELECT COUNT which would lock the database
 	// slowing new uploads.
-	groups := []*group{}
-	nextGroup := group{}
+	primaryKeys := make([]*publishmodel.Exposure, 0, s.config.MinRecords)
+	revisedKeys := make([]*publishmodel.Exposure, 0, s.config.MinRecords)
 	totalNewKeys, totalRevisedKeys := 0, 0
 	droppedKeys := 0
 
@@ -164,12 +164,8 @@ func (s *Server) batchExposures(ctx context.Context, criteria publishdatabase.It
 		if exp.CreatedAt.After(maxCreatedAt) {
 			maxCreatedAt = exp.CreatedAt
 		}
-		nextGroup.exposures = append(nextGroup.exposures, exp)
+		primaryKeys = append(primaryKeys, exp)
 		totalNewKeys++
-		if nextGroup.Length() == s.config.MaxRecords {
-			groups = append(groups, &nextGroup)
-			nextGroup = group{}
-		}
 		return nil
 	})
 	if err != nil {
@@ -183,12 +179,8 @@ func (s *Server) batchExposures(ctx context.Context, criteria publishdatabase.It
 			droppedKeys++
 			return nil
 		}
-		nextGroup.revised = append(nextGroup.revised, exp)
+		revisedKeys = append(revisedKeys, exp)
 		totalRevisedKeys++
-		if nextGroup.Length() == s.config.MaxRecords {
-			groups = append(groups, &nextGroup)
-			nextGroup = group{}
-		}
 		return nil
 	})
 	if err != nil {
@@ -202,15 +194,39 @@ func (s *Server) batchExposures(ctx context.Context, criteria publishdatabase.It
 		metricsMiddleWare.RecordExportWorkerBadKeyLength(ctx, droppedKeys)
 	}
 
+	// Sort all the keys that we got so that if the batch is re-run, the contents are stable IFF
+	// the sizing information is the same.
+	sortExposures(primaryKeys)
+	sortExposures(revisedKeys)
+
+	// Break these into groups according to the max records per file.
+	groups := make([]*group, 0, 1)
+	nextGroup := &group{}
+	for _, exp := range primaryKeys {
+		nextGroup.exposures = append(nextGroup.exposures, exp)
+		if nextGroup.Length() >= s.config.MaxRecords {
+			groups = append(groups, nextGroup)
+			nextGroup = &group{}
+		}
+	}
+	for _, exp := range revisedKeys {
+		nextGroup.revised = append(nextGroup.revised, exp)
+		if nextGroup.Length() >= s.config.MaxRecords {
+			groups = append(groups, nextGroup)
+			nextGroup = &group{}
+		}
+	}
+
 	// If the last group has anything, add it to the list.
 	if nextGroup.Length() > 0 {
-		groups = append(groups, &nextGroup)
+		groups = append(groups, nextGroup)
 	}
 
 	if len(groups) == 0 {
 		logger.Infof("No records for export batch")
-	} else {
-
+	} else if len(primaryKeys) < s.config.MinRecords {
+		// only drop into the padding code if the overall sum of groups is less than requested. Otherwise the pre-sorting
+		// will give away the generated data.
 		lastGroup := groups[len(groups)-1]
 		var generated []*publishmodel.Exposure
 		lastGroup.exposures, generated, err = ensureMinNumExposures(lastGroup.exposures, outputRegion, s.config.MinRecords, s.config.PaddingRange, maxCreatedAt)
@@ -287,16 +303,17 @@ func (s *Server) exportBatch(ctx context.Context, eb *model.ExportBatch, emitInd
 			return nil
 		}
 
-		// TODO(squee1945): Uploading in parallel (to a point) probably makes better
-		// use of network.
+		// 20201120 - Batch num/size changed to always be 1/1.
+		// The batch numbering being deemed unnecessary.
+		// However timing adjustments are put in place for variable batch sizes.
 		objectName, err := s.createFile(ctx,
 			createFileInfo{
 				exposures:        group.exposures,
 				revisedExposures: group.revised,
 				exportBatch:      eb,
 				signatureInfos:   sigInfos,
-				batchNum:         i + 1,
-				batchSize:        batchSize,
+				batchNum:         i + 1,     // the batchNum and batchSize are flattened to 1 and 1 when
+				batchSize:        batchSize, // creating the file, but are needed to vary for timestamp offset calculation.
 			})
 		if err != nil {
 			return fmt.Errorf("creating export file %d for batch %d: %w", i+1, eb.BatchID, err)
@@ -342,12 +359,12 @@ func (s *Server) createFile(ctx context.Context, cfi createFileInfo) (string, er
 	}
 
 	// Generate exposure key export file.
-	data, err := MarshalExportFile(cfi.exportBatch, cfi.exposures, cfi.revisedExposures, cfi.batchNum, cfi.batchSize, signers)
+	data, pbHexSHA, err := MarshalExportFile(cfi.exportBatch, cfi.exposures, cfi.revisedExposures, cfi.batchNum, cfi.batchSize, signers)
 	if err != nil {
 		return "", fmt.Errorf("marshaling export file: %w", err)
 	}
 
-	objectName := exportFilename(cfi.exportBatch, cfi.batchNum)
+	objectName := exportFilename(cfi.exportBatch, cfi.batchNum, pbHexSHA)
 	logger.Infof("Created file %v, signed with %v keys", objectName, len(signers))
 	ctx, cancel := context.WithTimeout(ctx, blobOperationTimeout)
 	defer cancel()
@@ -433,8 +450,14 @@ func (s *Server) createIndex(ctx context.Context, eb *model.ExportBatch, newObje
 	return indexObjectName, len(objects), nil
 }
 
-func exportFilename(eb *model.ExportBatch, batchNum int) string {
-	return fmt.Sprintf("%s/%d-%d-%05d%s", eb.FilenameRoot, eb.StartTimestamp.Unix(), eb.EndTimestamp.Unix(), batchNum, filenameSuffix)
+// The batchNum is still needed in the filename to preserve a stable filename sort
+// order when generating the index file.
+func exportFilename(eb *model.ExportBatch, batchNum int, pbHexSHA string) string {
+	first6 := pbHexSHA
+	if len(pbHexSHA) >= 6 {
+		first6 = pbHexSHA[0:6]
+	}
+	return fmt.Sprintf("%s/%d-%d-%05d-%s%s", eb.FilenameRoot, eb.StartTimestamp.Unix(), eb.EndTimestamp.Unix(), batchNum, first6, filenameSuffix)
 }
 
 func exportIndexFilename(eb *model.ExportBatch) string {
diff --git a/internal/maintenance/maintenance.go b/internal/maintenance/maintenance.go
new file mode 100644
index 0000000..2c68a2a
--- /dev/null
+++ b/internal/maintenance/maintenance.go
@@ -0,0 +1,48 @@
+// 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 maintenance provides utilities for maintenance mode handling
+package maintenance
+
+import (
+	"fmt"
+	"net/http"
+)
+
+type MaintenanceConfig interface {
+	MaintenanceMode() bool
+}
+
+type Responder struct {
+	inMaintenance bool
+}
+
+func New(c MaintenanceConfig) *Responder {
+	return &Responder{
+		inMaintenance: c.MaintenanceMode(),
+	}
+}
+
+func (r *Responder) Handle(next http.Handler) http.Handler {
+	if r.inMaintenance {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			w.Header().Set("Content-Type", "application/json")
+			w.WriteHeader(http.StatusTooManyRequests)
+			fmt.Fprintf(w, "{\"error\": \"please try again later\"}")
+		})
+	}
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		next.ServeHTTP(w, r)
+	})
+}
diff --git a/internal/mirror/database/mirror.go b/internal/mirror/database/mirror.go
index 810a21c..a8047f7 100644
--- a/internal/mirror/database/mirror.go
+++ b/internal/mirror/database/mirror.go
@@ -51,6 +51,36 @@ func (db *MirrorDB) AddMirror(ctx context.Context, m *model.Mirror) error {
 	})
 }
 
+// UpdateMirror updates the given mirror struct in the database. It must already
+// exist in the database, keyed off of ID.
+func (db *MirrorDB) UpdateMirror(ctx context.Context, m *model.Mirror) error {
+	return db.db.InTx(ctx, pgx.Serializable, func(tx pgx.Tx) error {
+		result, err := tx.Exec(ctx, `
+			UPDATE
+				Mirror
+			SET
+				index_file = $2,
+				export_root = $3,
+				cloud_storage_bucket = $4,
+				filename_root = $5,
+				filename_rewrite = $6
+			WHERE id = $1
+		`, m.ID, m.IndexFile, m.ExportRoot, m.CloudStorageBucket, m.FilenameRoot, m.FilenameRewrite)
+		if err != nil {
+			return fmt.Errorf("failed to update mirror: %w", err)
+		}
+
+		switch v := result.RowsAffected(); v {
+		case 0:
+			return fmt.Errorf("no rows were updated (does the record exist?)")
+		case 1:
+			return nil
+		default:
+			return fmt.Errorf("only 1 row should have been updated, got %d", v)
+		}
+	})
+}
+
 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, `
@@ -77,6 +107,34 @@ func (db *MirrorDB) DeleteMirror(ctx context.Context, m *model.Mirror) error {
 	})
 }
 
+// GetMirror retruns the mirror with the given ID.
+func (db *MirrorDB) GetMirror(ctx context.Context, id int64) (*model.Mirror, error) {
+	var mirror *model.Mirror
+
+	if err := db.db.InTx(ctx, pgx.ReadCommitted, func(tx pgx.Tx) error {
+		row := tx.QueryRow(ctx, `
+			SELECT
+				id, index_file, export_root, cloud_storage_bucket, filename_root, filename_rewrite
+			FROM
+				mirror
+			WHERE
+				id = $1
+		`, id)
+
+		var err error
+		mirror, err = scanOneMirror(row)
+		if err != nil {
+			return err
+		}
+		return nil
+	}); err != nil {
+		return nil, fmt.Errorf("failed to get mirror %d: %w", id, err)
+	}
+
+	return mirror, nil
+}
+
+// Mirrors returns the list of mirrors for the database, ordered by id.
 func (db *MirrorDB) Mirrors(ctx context.Context) ([]*model.Mirror, error) {
 	var mirrors []*model.Mirror
 
@@ -95,14 +153,14 @@ func (db *MirrorDB) Mirrors(ctx context.Context) ([]*model.Mirror, error) {
 
 		for rows.Next() {
 			if err := rows.Err(); err != nil {
-				return fmt.Errorf("faield to iterate: %w", err)
+				return fmt.Errorf("failed to iterate: %w", err)
 			}
 
-			var m model.Mirror
-			if err := rows.Scan(&m.ID, &m.IndexFile, &m.ExportRoot, &m.CloudStorageBucket, &m.FilenameRoot, &m.FilenameRewrite); err != nil {
-				return fmt.Errorf("reading row: %w", err)
+			m, err := scanOneMirror(rows)
+			if err != nil {
+				return err
 			}
-			mirrors = append(mirrors, &m)
+			mirrors = append(mirrors, m)
 		}
 		return nil
 	}); err != nil {
@@ -112,6 +170,15 @@ func (db *MirrorDB) Mirrors(ctx context.Context) ([]*model.Mirror, error) {
 	return mirrors, nil
 }
 
+// scanOneMirror scans a single pgx row into a mirror model.
+func scanOneMirror(row pgx.Row) (*model.Mirror, error) {
+	var m model.Mirror
+	if err := row.Scan(&m.ID, &m.IndexFile, &m.ExportRoot, &m.CloudStorageBucket, &m.FilenameRoot, &m.FilenameRewrite); err != nil {
+		return nil, fmt.Errorf("failed to scan mirror row: %w", err)
+	}
+	return &m, nil
+}
+
 type SyncFile struct {
 	RemoteFile string
 	LocalFile  string
diff --git a/internal/publish/config.go b/internal/publish/config.go
index ebfad21..329e82a 100644
--- a/internal/publish/config.go
+++ b/internal/publish/config.go
@@ -21,6 +21,7 @@ import (
 
 	"github.com/google/exposure-notifications-server/internal/authorizedapp"
 	"github.com/google/exposure-notifications-server/internal/database"
+	"github.com/google/exposure-notifications-server/internal/maintenance"
 	"github.com/google/exposure-notifications-server/internal/publish/model"
 	"github.com/google/exposure-notifications-server/internal/revision"
 	"github.com/google/exposure-notifications-server/internal/setup"
@@ -38,6 +39,7 @@ var _ setup.SecretManagerConfigProvider = (*Config)(nil)
 var _ setup.ObservabilityExporterConfigProvider = (*Config)(nil)
 var _ model.TransformerConfig = (*Config)(nil)
 var _ setup.KeyManagerConfigProvider = (*Config)(nil)
+var _ maintenance.MaintenanceConfig = (*Config)(nil)
 
 // Config represents the configuration and associated environment variables for
 // the publish components.
@@ -50,8 +52,10 @@ type Config struct {
 	ObservabilityExporter observability.Config
 	RevisionToken         revision.Config
 
-	Port             string `env:"PORT, default=8080"`
-	MaxKeysOnPublish uint   `env:"MAX_KEYS_ON_PUBLISH, default=30"`
+	Port        string `env:"PORT, default=8080"`
+	Maintenance bool   `env:"MAINTENANCE_MODE, default=false"`
+
+	MaxKeysOnPublish uint `env:"MAX_KEYS_ON_PUBLISH, default=30"`
 	// Provides compatibility w/ 1.5 release.
 	MaxSameStartIntervalKeys uint          `env:"MAX_SAME_START_INTERVAL_KEYS, default=3"`
 	MaxIntervalAge           time.Duration `env:"MAX_INTERVAL_AGE_ON_PUBLISH, default=360h"`
@@ -99,6 +103,10 @@ type Config struct {
 	DebugLogBadCertificates bool `env:"DEBUG_LOG_BAD_CERTIFICATES"`
 }
 
+func (c *Config) MaintenanceMode() bool {
+	return c.Maintenance
+}
+
 func (c *Config) Validate() error {
 	var result *multierror.Error
 
diff --git a/internal/publish/publish_v1.go b/internal/publish/publish_v1.go
index 87ce448..0a6fc4e 100644
--- a/internal/publish/publish_v1.go
+++ b/internal/publish/publish_v1.go
@@ -23,6 +23,7 @@ import (
 	"go.opencensus.io/trace"
 
 	"github.com/google/exposure-notifications-server/internal/jsonutil"
+	"github.com/google/exposure-notifications-server/internal/maintenance"
 	"github.com/google/exposure-notifications-server/internal/metrics/metricsware"
 	verifyapi "github.com/google/exposure-notifications-server/pkg/api/v1"
 	"github.com/google/exposure-notifications-server/pkg/logging"
@@ -64,33 +65,35 @@ func (h *PublishHandler) handleRequest(w http.ResponseWriter, r *http.Request) *
 
 // Handle returns an http.Handler that can process V1 publish requests.
 func (h *PublishHandler) Handle() http.Handler {
+	mResponder := maintenance.New(h.config)
 	return h.tracker.HandleTrack(chaff.HeaderDetector("X-Chaff"),
-		http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-			response := h.handleRequest(w, r)
+		mResponder.Handle(
+			http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				response := h.handleRequest(w, r)
 
-			ctx := r.Context()
-			metrics := h.serverenv.MetricsExporter(ctx)
-			metricsMiddleware := metricsware.NewMiddleWare(&metrics)
+				ctx := r.Context()
+				metrics := h.serverenv.MetricsExporter(ctx)
+				metricsMiddleware := metricsware.NewMiddleWare(&metrics)
 
-			if err := response.padResponse(h.config); err != nil {
-				metricsMiddleware.RecordPaddingFailure(ctx)
-				logging.FromContext(ctx).Errorw("failed to pad response", "error", err)
-			}
+				if err := response.padResponse(h.config); err != nil {
+					metricsMiddleware.RecordPaddingFailure(ctx)
+					logging.FromContext(ctx).Errorw("failed to pad response", "error", err)
+				}
 
-			if response.metrics != nil {
-				response.metrics()
-			}
+				if response.metrics != nil {
+					response.metrics()
+				}
 
-			data, err := json.Marshal(response.pubResponse)
-			if err != nil {
-				w.Header().Set("Content-Type", "application/json")
-				w.WriteHeader(http.StatusInternalServerError)
-				fmt.Fprintf(w, "{\"error\": \"%v\"}", err.Error())
-				return
-			}
+				data, err := json.Marshal(response.pubResponse)
+				if err != nil {
+					w.Header().Set("Content-Type", "application/json")
+					w.WriteHeader(http.StatusInternalServerError)
+					fmt.Fprintf(w, "{\"error\": \"%v\"}", err.Error())
+					return
+				}
 
-			w.Header().Set("Content-Type", "application/json")
-			w.WriteHeader(response.status)
-			fmt.Fprintf(w, "%s", data)
-		}))
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(response.status)
+				fmt.Fprintf(w, "%s", data)
+			})))
 }
diff --git a/internal/publish/publish_v1alpha1.go b/internal/publish/publish_v1alpha1.go
index 1ec0665..a461f8e 100644
--- a/internal/publish/publish_v1alpha1.go
+++ b/internal/publish/publish_v1alpha1.go
@@ -23,6 +23,7 @@ import (
 	"go.opencensus.io/trace"
 
 	"github.com/google/exposure-notifications-server/internal/jsonutil"
+	"github.com/google/exposure-notifications-server/internal/maintenance"
 	"github.com/google/exposure-notifications-server/internal/metrics/metricsware"
 	verifyapi "github.com/google/exposure-notifications-server/pkg/api/v1"
 	"github.com/google/exposure-notifications-server/pkg/api/v1alpha1"
@@ -81,42 +82,44 @@ func (h *PublishHandler) handleV1Apha1Request(w http.ResponseWriter, r *http.Req
 }
 
 func (h *PublishHandler) HandleV1Alpha1() http.Handler {
+	mResponder := maintenance.New(h.config)
 	return h.tracker.HandleTrack(chaff.HeaderDetector("X-Chaff"),
-		http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-			response := h.handleV1Apha1Request(w, r)
-
-			ctx := r.Context()
-			metrics := h.serverenv.MetricsExporter(ctx)
-			metricsMiddleware := metricsware.NewMiddleWare(&metrics)
-
-			if err := response.padResponse(h.config); err != nil {
-				metricsMiddleware.RecordPaddingFailure(ctx)
-				logging.FromContext(ctx).Errorw("failed to padd response", "error", err)
-			}
-
-			// Downgrade the v1 response to a v1alpha1 response.
-			alpha1Response := v1alpha1.PublishResponse{
-				RevisionToken:     response.pubResponse.RevisionToken,
-				InsertedExposures: response.pubResponse.InsertedExposures,
-				Error:             response.pubResponse.ErrorMessage,
-				Padding:           response.pubResponse.Padding,
-				Warnings:          response.pubResponse.Warnings,
-			}
-
-			if response.metrics != nil {
-				response.metrics()
-			}
-
-			data, err := json.Marshal(&alpha1Response)
-			if err != nil {
+		mResponder.Handle(
+			http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				response := h.handleV1Apha1Request(w, r)
+
+				ctx := r.Context()
+				metrics := h.serverenv.MetricsExporter(ctx)
+				metricsMiddleware := metricsware.NewMiddleWare(&metrics)
+
+				if err := response.padResponse(h.config); err != nil {
+					metricsMiddleware.RecordPaddingFailure(ctx)
+					logging.FromContext(ctx).Errorw("failed to padd response", "error", err)
+				}
+
+				// Downgrade the v1 response to a v1alpha1 response.
+				alpha1Response := v1alpha1.PublishResponse{
+					RevisionToken:     response.pubResponse.RevisionToken,
+					InsertedExposures: response.pubResponse.InsertedExposures,
+					Error:             response.pubResponse.ErrorMessage,
+					Padding:           response.pubResponse.Padding,
+					Warnings:          response.pubResponse.Warnings,
+				}
+
+				if response.metrics != nil {
+					response.metrics()
+				}
+
+				data, err := json.Marshal(&alpha1Response)
+				if err != nil {
+					w.Header().Set("Content-Type", "application/json")
+					w.WriteHeader(http.StatusInternalServerError)
+					fmt.Fprintf(w, "{\"error\": \"%v\"}", err.Error())
+					return
+				}
+
 				w.Header().Set("Content-Type", "application/json")
-				w.WriteHeader(http.StatusInternalServerError)
-				fmt.Fprintf(w, "{\"error\": \"%v\"}", err.Error())
-				return
-			}
-
-			w.Header().Set("Content-Type", "application/json")
-			w.WriteHeader(response.status)
-			fmt.Fprintf(w, "%s", data)
-		}))
+				w.WriteHeader(response.status)
+				fmt.Fprintf(w, "%s", data)
+			})))
 }
diff --git a/scripts/ci-terraform.sh b/scripts/ci-terraform.sh
index 48f97a0..7d07e9f 100755
--- a/scripts/ci-terraform.sh
+++ b/scripts/ci-terraform.sh
@@ -127,7 +127,7 @@ function destroy() {
   local db_inst_name
   # Fetching databases from previous terraform deployment output is not always reliable,
   # especially when previous terraform deployment failed. So grepping from terraform state instead.
-  db_inst_name="$(terraform state show module.en.google_sql_database_instance.db-inst | grep -Eo 'en-server-[a-zA-Z0-9]+' | uniq)" || best_effort
+  db_inst_name="$(terraform state show module.en.google_sql_database_instance.db-inst | grep -Eo 'name.*=.*"terraform-[a-zA-Z0-9]+"' | grep -Eo 'terraform-[a-zA-Z0-9]+' | uniq)" || best_effort
   if [[ -n "${db_inst_name}" ]]; then
     echo "Deleting db ${db_inst_name}"
     gcloud sql instances delete ${db_inst_name} -q --project=${PROJECT_ID}
diff --git a/tools/admin-console/main.go b/tools/admin-console/main.go
index da16d43..afe9e6d 100644
--- a/tools/admin-console/main.go
+++ b/tools/admin-console/main.go
@@ -27,6 +27,7 @@ import (
 	"github.com/google/exposure-notifications-server/internal/admin/exports"
 	"github.com/google/exposure-notifications-server/internal/admin/healthauthority"
 	"github.com/google/exposure-notifications-server/internal/admin/index"
+	"github.com/google/exposure-notifications-server/internal/admin/mirrors"
 	"github.com/google/exposure-notifications-server/internal/admin/siginfo"
 	"github.com/google/exposure-notifications-server/internal/setup"
 )
@@ -75,6 +76,12 @@ func main() {
 	saveExportImporterController := exportimporters.NewSave(&config, env)
 	router.POST("/export-importers/:id", saveExportImporterController.Execute)
 
+	// Mirror handling.
+	mirrorController := mirrors.NewView(&config, env)
+	router.GET("/mirrors/:id", mirrorController.Execute)
+	saveMirrorController := mirrors.NewSave(&config, env)
+	router.POST("/mirrors/:id", saveMirrorController.Execute)
+
 	// Signature Info.
 	sigInfoController := siginfo.NewView(&config, env)
 	router.GET("/siginfo/:id", sigInfoController.Execute)
diff --git a/tools/admin-console/templates/index.html b/tools/admin-console/templates/index.html
index 61bcb01..348a81f 100644
--- a/tools/admin-console/templates/index.html
+++ b/tools/admin-console/templates/index.html
@@ -176,6 +176,46 @@
       </div>
     </div>
   </div>
+
+  <div class="col mb-4">
+    <div class="card">
+      <div class="card-header">
+        <h5 class="mb-0">Mirrors</h5>
+      </div>
+
+      {{if .mirrors}}
+        <div class="list-group list-group-flush">
+          {{range .mirrors}}
+            <a href="/mirrors/{{.ID}}" class="list-group-item list-group-item-action">
+              <div class="d-flex w-100 justify-content-between">
+                <h5 class="mb-1">{{.CloudStorageBucket}}</h5>
+                <small>ID: {{.ID}}</small>
+              </div>
+              <p class="mb-0">
+                Filename Root: {{.FilenameRoot}}
+              </p>
+              <p class="mb-0">
+                Export Root: {{.ExportRoot}}
+              </p>
+              <p class="mb-1">
+                Index File: {{.IndexFile}}
+              </p>
+            </a>
+          {{end}}
+        </div>
+      {{else}}
+        <div class="card-body">
+          <p class="text-center mb-0"><em>There are no mirrors.</em></p>
+        </div>
+      {{end}}
+
+      <div class="card-body">
+        <div class="card-text">
+          <a href="/mirrors/0" class="btn btn-block btn-primary">Create new Mirror</a>
+        </div>
+      </div>
+    </div>
+  </div>
 </div>
 
 {{template "bottom" .}}
diff --git a/tools/admin-console/templates/mirror.html b/tools/admin-console/templates/mirror.html
new file mode 100644
index 0000000..72198d4
--- /dev/null
+++ b/tools/admin-console/templates/mirror.html
@@ -0,0 +1,96 @@
+{{define "mirror"}}
+{{template "top" .}}
+
+<div class="card shadow-sm mb-3">
+  <div class="card-header">
+    {{if not .mirror.ID}}
+      New Mirror
+    {{else}}
+      Update mirror <span class="font-weight-bold text-monospace">{{.mirror.ID}}</span>
+    {{end}}
+  </div>
+  <div class="card-body">
+    <form method="POST" action="/mirrors/{{.mirror.ID}}" class="floating-form">
+      <div class="form-label-group">
+        <input type="text" name="IndexFile" id="index-file" value="{{.mirror.IndexFile}}" class="form-control" placeholder="Index File">
+        <label for="index-file">Index File</label>
+        <small class="form-text text-muted">
+          Full URL of the index file from which to read. Example: <code>https://mysever/exports/index.txt</code>.
+        </small>
+      </div>
+
+      <div class="form-label-group">
+        <input type="text" name="ExportRoot" id="export-root" value="{{.mirror.ExportRoot}}" class="form-control" placeholder="Export Root">
+        <label for="export-root">Export Root</label>
+        <small class="form-text text-muted">
+          Full URL to the export root. Example: <code>https://myserver/</code>.
+        </small>
+      </div>
+
+      <div class="form-label-group">
+        <input type="text" name="CloudStorageBucket" id="cloud-storage-bucket" value="{{.mirror.CloudStorageBucket}}" class="form-control" placeholder="Cloud Storage Bucket">
+        <label for="cloud-storage-bucket">Cloud Storage Bucket</label>
+        <small class="form-text text-muted">
+          Name of the destination storage bucket. Example: <code>my-bucket</code>.
+        </small>
+      </div>
+
+      <div class="form-label-group">
+        <input type="text" name="FilenameRoot" id="filename-root" value="{{.mirror.FilenameRoot}}" class="form-control" placeholder="Filename Root">
+        <label for="filename-root">Filename Root</label>
+        <small class="form-text text-muted">
+          Root where the new file should be written. Example: <code>/path/to/new</code>
+        </small>
+      </div>
+
+      <div class="form-label-group">
+        <input type="text" name="FilenameRewrite" id="filename-rewrite" value="{{.mirror.FilenameRewrite | deref}}" class="form-control" placeholder="Filename Rewrite">
+        <label for="filename-rewrite">Filename Rewrite</label>
+        <small class="form-text text-muted">
+          Optional regular expression for rewriting imports.
+        </small>
+      </div>
+
+      <button type="submit" class="mt-5 btn btn-primary btn-block" name="Action" value="save">Save changes</button>
+
+      {{if .mirror.ID}}
+        <button type="submit" name="Action" value="delete" class="mt-3 btn btn-link btn-sm px-0 text-danger">
+          <span class="oi oi-trash" aria-hidden="true"></span> Delete
+        </button>
+      {{end}}
+    </form>
+  </div>
+</div>
+
+{{if .mirror.ID}}
+  <div class="card shadow-sm">
+    <div class="card-header">Mirror Files</div>
+    {{if .mirrorFiles}}
+      <table class="table table-striped mb-0">
+        <thead>
+          <tr>
+            <th scope="col">Filename</th>
+            <th scope="col">Local Filename</th>
+          </tr>
+        </thead>
+        <tbody>
+          {{range .mirrorFiles}}
+            <tr>
+              <td class="text-monospace">{{.Filename}}</td>
+              <td class="text-monospace">{{.LocalFilename}}</td>
+            </tr>
+          {{end}}
+        </tbody>
+      </table>
+    {{else}}
+      <div class="card-body">
+        <div class="alert alert-warning" role="alert">
+          There are currently no mirror files.
+        </div>
+      </div>
+    {{end}}
+  </div>
+{{end}}
+
+{{template "bottom" .}}
+{{end}}
diff --git a/tools/export-generate/main.go b/tools/export-generate/main.go
index 117c0d8..8edf2a1 100644
--- a/tools/export-generate/main.go
+++ b/tools/export-generate/main.go
@@ -205,11 +205,11 @@ func (e *exportFileWriter) writeFile() {
 		SignatureInfo: signatureInfo,
 		Signer:        e.privateKey,
 	}
-	data, err := export.MarshalExportFile(e.exportBatch, e.exposures, e.revisions, e.curBatch, e.numBatches, []*export.Signer{signer})
+	data, hexSHA, err := export.MarshalExportFile(e.exportBatch, e.exposures, e.revisions, e.curBatch, e.numBatches, []*export.Signer{signer})
 	if err != nil {
 		log.Fatalf("error marshaling export file: %v", err)
 	}
-	fileName := fmt.Sprintf(e.exportBatch.FilenameRoot+"%d-records-%d-of-%d"+filenameSuffix, e.totalKeys, e.curBatch, e.numBatches)
+	fileName := fmt.Sprintf(e.exportBatch.FilenameRoot+"%d-records-%d-of-%d-%s"+filenameSuffix, e.totalKeys, e.curBatch, e.numBatches, hexSHA[0:6])
 	log.Printf("Creating %v", fileName)
 	err = ioutil.WriteFile(fileName, data, 0666)
 	if err != nil {


Test patch:
diff --git a/internal/admin/exports/form_test.go b/internal/admin/exports/form_test.go
index a31d478..6abf12f 100644
--- a/internal/admin/exports/form_test.go
+++ b/internal/admin/exports/form_test.go
@@ -1,3 +1,17 @@
+// 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, softwar
+// 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 exports
 
 import (
diff --git a/internal/admin/mirrors/view_test.go b/internal/admin/mirrors/view_test.go
new file mode 100644
index 0000000..cad26e6
--- /dev/null
+++ b/internal/admin/mirrors/view_test.go
@@ -0,0 +1,40 @@
+// 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, softwar
+// 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 mirrors
+
+import (
+	"net/http/httptest"
+	"testing"
+
+	"github.com/google/exposure-notifications-server/internal/admin"
+	mirrormodel "github.com/google/exposure-notifications-server/internal/mirror/model"
+)
+
+func TestRender(t *testing.T) {
+	m := admin.TemplateMap{}
+	mirror := &mirrormodel.Mirror{}
+	m["mirror"] = mirror
+
+	recorder := httptest.NewRecorder()
+	config := admin.Config{
+		TemplatePath: "../../../tools/admin-console/templates",
+		TopFile:      "top",
+		BotFile:      "bottom",
+	}
+	err := config.RenderTemplate(recorder, "mirror", m)
+	if err != nil {
+		t.Fatalf("error rendering template: %v", err)
+	}
+}
diff --git a/internal/export/exportfile_test.go b/internal/export/exportfile_test.go
index 9cfdd62..e4cbd30 100644
--- a/internal/export/exportfile_test.go
+++ b/internal/export/exportfile_test.go
@@ -95,7 +95,7 @@ func TestMarshalUnmarshalExportFile(t *testing.T) {
 
 	signer := &customTestSigner{}
 
-	blob, err := MarshalExportFile(batch, exposures, revisedExposures, 1, 1, []*Signer{
+	blob, pbHexSHA, err := MarshalExportFile(batch, exposures, revisedExposures, 1, 1, []*Signer{
 		{SignatureInfo: signatureInfo, Signer: signer},
 	})
 	if err != nil {
@@ -112,6 +112,11 @@ func TestMarshalUnmarshalExportFile(t *testing.T) {
 		t.Errorf("wrong message digest want: %v, got: %v", wantDigest, b64digest)
 	}
 
+	wantHexSHA := "8cdf96f439ea7d7c79869fba2da23c26e8a5b055a88b217c0c4ffbdce1993093"
+	if wantHexSHA != pbHexSHA {
+		t.Errorf("want PB hex sha to be: %v, got: %v", wantHexSHA, pbHexSHA)
+	}
+
 	infos := []*export.SignatureInfo{
 		{
 			VerificationKeyVersion: proto.String("1"),
diff --git a/internal/export/worker_test.go b/internal/export/worker_test.go
index 3650d4d..f6692a8 100644
--- a/internal/export/worker_test.go
+++ b/internal/export/worker_test.go
@@ -147,8 +147,9 @@ func TestBatchExposures(t *testing.T) {
 	ctx := context.Background()
 
 	config := Config{
-		MinRecords:         5,
+		MinRecords:         1,
 		PaddingRange:       0,
+		MaxRecords:         1,
 		TruncateWindow:     time.Hour,
 		MaxInsertBatchSize: 100,
 	}
@@ -357,3 +358,83 @@ func TestBatchExposures(t *testing.T) {
 		}
 	}
 }
+
+func TestVariableBatchMaxSize(t *testing.T) {
+	t.Parallel()
+
+	testDB := database.NewTestDatabase(t)
+	testPublishDB := publishdb.New(testDB)
+	ctx := context.Background()
+
+	// Using a 1 hour truncate window
+	baseTime := time.Date(2020, 10, 28, 1, 0, 0, 0, time.UTC).Truncate(time.Hour)
+	exposures := make([]*publishmodel.Exposure, 20)
+	want := make(map[string]struct{})
+	for i := 0; i < 20; i++ {
+		// All keys alignd to the same hour.
+		exposures[i] = &publishmodel.Exposure{
+			ExposureKey:     getKey(t),
+			Regions:         []string{"US"},
+			IntervalNumber:  100,
+			IntervalCount:   144,
+			CreatedAt:       baseTime,
+			LocalProvenance: true,
+			Traveler:        false,
+			ReportType:      verifyapi.ReportTypeClinical,
+		}
+		want[exposures[i].ExposureKeyBase64()] = struct{}{}
+	}
+	if _, err := testPublishDB.InsertAndReviseExposures(ctx, &publishdb.InsertAndReviseExposuresRequest{
+		Incoming:     exposures,
+		RequireToken: false,
+	}); err != nil {
+		t.Fatalf("inserting exposures: %v", err)
+	}
+
+	for batchSize := 1; batchSize <= len(exposures); batchSize++ {
+		config := Config{
+			MinRecords:         1,
+			PaddingRange:       0,
+			MaxRecords:         batchSize,
+			TruncateWindow:     time.Hour,
+			MaxInsertBatchSize: 100,
+		}
+		server := Server{
+			config: &config,
+			env:    serverenv.New(ctx, serverenv.WithDatabase(testDB)),
+		}
+
+		// Build the iteration criteria for the incremental batches.
+		criteria := publishdb.IterateExposuresCriteria{
+			SinceTimestamp:      baseTime,
+			UntilTimestamp:      baseTime.Add(time.Hour),
+			IncludeRegions:      []string{"US"}, // current list of foreign countries
+			IncludeTravelers:    true,
+			OnlyNonTravelers:    false,
+			OnlyLocalProvenance: true,
+		}
+
+		groups, err := server.batchExposures(ctx, criteria, "REMOTE")
+		if err != nil {
+			t.Fatalf("failed to read exposures: %v", err)
+		}
+		if len(groups) == 0 {
+			t.Fatalf("export batch should have found some keys")
+		}
+
+		got := make(map[string]struct{})
+		for _, group := range groups {
+			for _, exp := range group.exposures {
+				b64 := exp.ExposureKeyBase64()
+				if _, ok := got[b64]; ok {
+					t.Fatalf("hourly batch included duplicate key")
+				}
+				got[b64] = struct{}{}
+			}
+		}
+
+		if diff := cmp.Diff(want, got); diff != "" {
+			t.Errorf("ReadExposures mismatch (-want, +got):\n%s", diff)
+		}
+	}
+}
diff --git a/internal/mirror/database/mirror_test.go b/internal/mirror/database/mirror_test.go
index 8f7b2cf..e9fde48 100644
--- a/internal/mirror/database/mirror_test.go
+++ b/internal/mirror/database/mirror_test.go
@@ -24,7 +24,7 @@ import (
 	"github.com/google/go-cmp/cmp/cmpopts"
 )
 
-func TestAddListDeleteMirror(t *testing.T) {
+func TestMirror_Lifecycle(t *testing.T) {
 	t.Parallel()
 
 	testDB := database.NewTestDatabase(t)
@@ -51,27 +51,53 @@ func TestAddListDeleteMirror(t *testing.T) {
 		}
 	}
 
-	got, err := mirrorDB.Mirrors(ctx)
+	// List
+	mirrors, err := mirrorDB.Mirrors(ctx)
 	if err != nil {
 		t.Fatal(err)
 	}
+	if len(mirrors) < 1 {
+		t.Fatal("no mirrors")
+	}
 
 	ignore := cmpopts.IgnoreUnexported(model.Mirror{})
-	if diff := cmp.Diff(want, got, ignore); diff != "" {
+	if diff := cmp.Diff(want, mirrors, ignore); diff != "" {
 		t.Errorf("mismatch (-want, +got):\n%s", diff)
 	}
 
+	// Get and update
+	{
+		mirror, err := mirrorDB.GetMirror(ctx, mirrors[0].ID)
+		if err != nil {
+			t.Fatal(err)
+		}
+		if mirror.ID != 1 {
+			t.Fatalf("expected %d to be 1", mirror.ID)
+		}
+
+		mirror.IndexFile = "foo"
+		if err := mirrorDB.UpdateMirror(ctx, mirror); err != nil {
+			t.Fatal(err)
+		}
+		mirror, err = mirrorDB.GetMirror(ctx, mirrors[0].ID)
+		if err != nil {
+			t.Fatal(err)
+		}
+		if got, want := mirror.IndexFile, "foo"; got != want {
+			t.Fatalf("expected %q to be %q", got, want)
+		}
+	}
+
 	for _, w := range want {
 		if err := mirrorDB.DeleteMirror(ctx, w); err != nil {
 			t.Fatal(err)
 		}
 	}
 
-	got, err = mirrorDB.Mirrors(ctx)
+	got, err := mirrorDB.Mirrors(ctx)
 	if err != nil {
 		t.Fatal(err)
 	}
-
 	if len(got) != 0 {
 		t.Fatalf("got back deleted mirrors")
 	}
diff --git a/internal/publish/publish_test.go b/internal/publish/publish_test.go
index 0f80773..d123fe0 100644
--- a/internal/publish/publish_test.go
+++ b/internal/publish/publish_test.go
@@ -115,6 +115,7 @@ func TestPublishWithBypass(t *testing.T) {
 		ErrorCode          string
 		SkipVersions       map[version]bool
 		SkipKeys           map[int]bool // which keys should be skipped (partial success)
+		MaintenanceMode    bool
 	}{
 		{
 			Name:       "successful_insert_bypass_ha_verification",
@@ -380,6 +381,34 @@ func TestPublishWithBypass(t *testing.T) {
 			Code:      http.StatusUnauthorized,
 			Error:     "unable to validate diagnosis verification: token is expired by 1m",
 		},
+		{
+			Name: "maintenance_mode",
+			HealthAuthority: &vermodel.HealthAuthority{
+				Issuer:   issNames.next(),
+				Audience: "unit.test.server",
+				Name:     "Unit Test Gov DOH",
+			},
+			HealthAuthorityKey: &vermodel.HealthAuthorityKey{
+				Version: "v1",
+				From:    time.Now().Add(-1 * time.Minute),
+			},
+			TestRegion: regions.next(),
+			AuthorizedApp: func() *aamodel.AuthorizedApp {
+				authApp := aamodel.NewAuthorizedApp()
+				authApp.AppPackageName = names.next()
+				authApp.AllowedRegions[regions.current()] = struct{}{}
+				return authApp
+			}(),
+			Publish: verifyapi.Publish{
+				Keys:                util.GenerateExposureKeys(2, 5, false),
+				HealthAuthorityID:   names.current(),
+				VerificationPayload: "totally not a JWT",
+			},
+			Regions:         []string{regions.current()},
+			Code:            http.StatusTooManyRequests,
+			Error:           "try again",
+			MaintenanceMode: true,
+		},
 	}
 
 	for _, ver := range versions {
@@ -436,6 +465,7 @@ func TestPublishWithBypass(t *testing.T) {
 				config.ResponsePaddingRange = 100
 				config.MaxMagnitudeSymptomOnsetDays = 14
 				config.MaxSypmtomOnsetReportDays = 28
+				config.Maintenance = tc.MaintenanceMode
 				env := serverenv.New(ctx,
 					serverenv.WithDatabase(testDB),
 					serverenv.WithAuthorizedAppProvider(aaProvider),
@@ -541,7 +571,7 @@ func TestPublishWithBypass(t *testing.T) {
 				if resp.StatusCode != tc.Code {
 					t.Fatalf("http response code want: %v, got %v.", tc.Code, resp.StatusCode)
 				}
-				if len(response.Padding) == 0 {
+				if !tc.MaintenanceMode && len(response.Padding) == 0 {
 					t.Errorf("padding is empty on response: %+v", response)
 				}