cmd/relui: load development state on boot

When relui starts, it will look in dev-data-directory for data
persisted from the last boot. This enables local development to continue
when testing changes manually, building on CL 246298.

For golang/go#40279

Change-Id: I02f8b6e1178f82425cafcd2a0544327ba84e028e
Reviewed-on: https://go-review.googlesource.com/c/build/+/250917
Run-TryBot: Alexander Rakoczy <alex@golang.org>
TryBot-Result: Gobot Gobot <gobot@golang.org>
Reviewed-by: Carlos Amedee <carlos@golang.org>
Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
diff --git a/cmd/relui/main.go b/cmd/relui/main.go
index 21c52db..4ba50cb 100644
--- a/cmd/relui/main.go
+++ b/cmd/relui/main.go
@@ -23,7 +23,11 @@
 
 func main() {
 	flag.Parse()
-	s := &server{store: newFileStore(*devDataDir), configs: loadWorkflowConfig("./workflows")}
+	fs := newFileStore(*devDataDir)
+	if err := fs.load(); err != nil {
+		log.Fatalf("Error loading state from %q: %v", *devDataDir, err)
+	}
+	s := &server{store: fs, configs: loadWorkflowConfig("./workflows")}
 	http.Handle("/workflows/create", http.HandlerFunc(s.createWorkflowHandler))
 	http.Handle("/workflows/new", http.HandlerFunc(s.newWorkflowHandler))
 	http.Handle("/", fileServerHandler(relativeFile("./static"), http.HandlerFunc(s.homeHandler)))
@@ -31,7 +35,6 @@
 	if port == "" {
 		port = "8080"
 	}
-
 	log.Printf("Listening on :" + port)
 	log.Fatal(http.ListenAndServe(":"+port, http.DefaultServeMux))
 }
diff --git a/cmd/relui/store.go b/cmd/relui/store.go
index a861058..acb2a4c 100644
--- a/cmd/relui/store.go
+++ b/cmd/relui/store.go
@@ -42,6 +42,7 @@
 	ls *reluipb.LocalStorage
 
 	// persistDir is a path to a directory for saving application data in textproto format.
+	// Set persistDir to an empty string to disable saving and loading from the filesystem.
 	persistDir string
 }
 
@@ -83,3 +84,21 @@
 	}
 	return nil
 }
+
+// load reads fileStore state from persistDir/fileStoreName.
+func (f *fileStore) load() error {
+	if f.persistDir == "" {
+		return nil
+	}
+	path := filepath.Join(f.persistDir, fileStoreName)
+	b, err := ioutil.ReadFile(path)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return nil
+		}
+		return fmt.Errorf("ioutil.ReadFile(%q) = _, %v", path, err)
+	}
+	f.mu.Lock()
+	defer f.mu.Unlock()
+	return proto.UnmarshalText(string(b), f.ls)
+}
diff --git a/cmd/relui/store_test.go b/cmd/relui/store_test.go
index 79843db..db5fcd3 100644
--- a/cmd/relui/store_test.go
+++ b/cmd/relui/store_test.go
@@ -16,9 +16,9 @@
 )
 
 func TestFileStorePersist(t *testing.T) {
-	dir, err := ioutil.TempDir("", "memory-store-test")
+	dir, err := ioutil.TempDir("", "fileStore-test")
 	if err != nil {
-		t.Fatalf("ioutil.TempDir(%q, %q) = _, %v", "", "memory-store-test", err)
+		t.Fatalf("ioutil.TempDir(%q, %q) = _, %v", "", "fileStore-test", err)
 	}
 	defer os.RemoveAll(dir)
 	want := &reluipb.LocalStorage{
@@ -50,3 +50,95 @@
 		t.Errorf("reluipb.LocalStorage mismatch (-want, +got):\n%s", diff)
 	}
 }
+
+func TestFileStoreLoad(t *testing.T) {
+	dir, err := ioutil.TempDir("", "fileStore-test")
+	if err != nil {
+		t.Fatalf("ioutil.TempDir(%q, %q) = _, %v", "", "fileStore-test", err)
+	}
+	defer os.RemoveAll(dir)
+	if err := os.MkdirAll(filepath.Join(dir, "relui"), 0755); err != nil {
+		t.Errorf("os.MkDirAll(%q, %v) = %w", filepath.Join(dir, "relui"), 0755, err)
+	}
+	want := &reluipb.LocalStorage{
+		Workflows: []*reluipb.Workflow{
+			{
+				Name:           "Load Test",
+				BuildableTasks: []*reluipb.BuildableTask{{Name: "Load Test Task"}},
+			},
+		},
+	}
+	data := []byte(proto.MarshalTextString(want))
+	dst := filepath.Join(dir, "relui", fileStoreName)
+	if err := ioutil.WriteFile(dst, data, 0644); err != nil {
+		t.Fatalf("ioutil.WriteFile(%q, _, %v) = %v", dst, 0644, err)
+	}
+
+	fs := newFileStore(filepath.Join(dir, "relui"))
+	if err := fs.load(); err != nil {
+		t.Errorf("reluipb.load() = %v, wanted no error", err)
+	}
+
+	if diff := cmp.Diff(want, fs.localStorage()); diff != "" {
+		t.Errorf("reluipb.LocalStorage mismatch (-want, +got):\n%s", diff)
+	}
+}
+
+func TestFileStoreLoadErrors(t *testing.T) {
+	empty, err := ioutil.TempDir("", "fileStoreLoad")
+	if err != nil {
+		t.Fatalf("ioutil.TempDir(%q, %q) = %v, wanted no error", "", "fileStoreLoad", err)
+	}
+	defer os.RemoveAll(empty)
+
+	collision, err := ioutil.TempDir("", "fileStoreLoad")
+	if err != nil {
+		t.Fatalf("ioutil.TempDir(%q, %q) = %v, wanted no error", "", "fileStoreLoad", err)
+	}
+	defer os.RemoveAll(collision)
+	// We want to trigger an error when trying to read the file, so make a directory with the same name.
+	if err := os.MkdirAll(filepath.Join(collision, fileStoreName), 0755); err != nil {
+		t.Errorf("os.MkDirAll(%q, %v) = %w", filepath.Join(collision, fileStoreName), 0755, err)
+	}
+
+	corrupt, err := ioutil.TempDir("", "fileStoreLoad")
+	if err != nil {
+		t.Fatalf("ioutil.TempDir(%q, %q) = %v, wanted no error", "", "fileStoreLoad", err)
+	}
+	defer os.RemoveAll(corrupt)
+	if err := ioutil.WriteFile(filepath.Join(corrupt, fileStoreName), []byte("oh no"), 0644); err != nil {
+		t.Fatalf("ioutil.WriteFile(%q, %q, %v) = %v, wanted no error", filepath.Join(corrupt, fileStoreName), "oh no", 0644, err)
+	}
+
+	cases := []struct {
+		desc    string
+		dir     string
+		wantErr bool
+	}{
+		{
+			desc: "no persistDir configured",
+		},
+		{
+			desc: "no file in persistDir",
+			dir:  empty,
+		},
+		{
+			desc:    "other error reading file",
+			dir:     collision,
+			wantErr: true,
+		},
+		{
+			desc:    "corrupt data in persistDir",
+			dir:     corrupt,
+			wantErr: true,
+		},
+	}
+	for _, c := range cases {
+		t.Run(c.desc, func(t *testing.T) {
+			f := newFileStore(c.dir)
+			if err := f.load(); (err != nil) != c.wantErr {
+				t.Errorf("f.load() = %v, wantErr = %t", err, c.wantErr)
+			}
+		})
+	}
+}