Skip to content
This repository was archived by the owner on Sep 30, 2024. It is now read-only.

Commit 72e5dc3

Browse files
oneclickexport: add core export functionality (#39813)
This includes ExportRequest processing, site config fetching and redacting and creating a zip archive.
1 parent 1596dd2 commit 72e5dc3

File tree

4 files changed

+267
-0
lines changed

4 files changed

+267
-0
lines changed

cmd/frontend/oneclickexport/export.go

+95
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package oneclickexport
2+
3+
import (
4+
"archive/zip"
5+
"bytes"
6+
"io"
7+
"os"
8+
"path/filepath"
9+
10+
"github.com/sourcegraph/log"
11+
)
12+
13+
type Exporter interface {
14+
// Export accepts an ExportRequest and returns bytes of a zip archive
15+
// with requested data.
16+
Export(request ExportRequest) ([]byte, error)
17+
}
18+
19+
var _ Exporter = &DataExporter{}
20+
21+
type DataExporter struct {
22+
logger log.Logger
23+
configProcessors map[string]Processor[ConfigRequest]
24+
}
25+
26+
type ExportRequest struct {
27+
IncludeSiteConfig bool `json:"includeSiteConfig"`
28+
}
29+
30+
// Export generates and returns a ZIP archive with the data, specified in request.
31+
// It works like this:
32+
// 1) tmp directory is created (exported files will end up in this directory and
33+
// this directory is zipped in the end)
34+
// 2) ExportRequest is read and each corresponding processor is invoked
35+
// 3) Tmp directory is zipped after all the Processors finished their job
36+
func (e *DataExporter) Export(request ExportRequest) ([]byte, error) {
37+
// 1) creating a tmp dir
38+
dir, err := os.MkdirTemp(os.TempDir(), "export-*")
39+
if err != nil {
40+
e.logger.Fatal("Error during code tmp dir creation", log.Error(err))
41+
}
42+
defer os.RemoveAll(dir)
43+
44+
// 2) tmp dir is passed to every processor
45+
if request.IncludeSiteConfig {
46+
e.configProcessors["siteConfig"].Process(ConfigRequest{}, dir)
47+
}
48+
49+
// 3) after all request parts are processed, zip the tmp dir and return its bytes
50+
var buf bytes.Buffer
51+
zw := zip.NewWriter(&buf)
52+
err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
53+
if err != nil {
54+
return err
55+
}
56+
57+
// currently, all the directories are skipped because only files are added to the
58+
// archive
59+
if info.IsDir() {
60+
return nil
61+
}
62+
63+
// create file header
64+
header, err := zip.FileInfoHeader(info)
65+
if err != nil {
66+
return err
67+
}
68+
69+
header.Method = zip.Deflate
70+
header.Name = filepath.Base(path)
71+
72+
headerWriter, err := zw.CreateHeader(header)
73+
if err != nil {
74+
return err
75+
}
76+
77+
file, err := os.Open(path)
78+
if err != nil {
79+
return err
80+
}
81+
defer file.Close()
82+
83+
_, err = io.Copy(headerWriter, file)
84+
return err
85+
})
86+
if err != nil {
87+
return nil, err
88+
}
89+
90+
if err := zw.Close(); err != nil {
91+
return nil, err
92+
}
93+
94+
return buf.Bytes(), nil
95+
}
+112
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package oneclickexport
2+
3+
import (
4+
"archive/zip"
5+
"bytes"
6+
"io"
7+
"testing"
8+
9+
"github.com/google/go-cmp/cmp"
10+
"github.com/sourcegraph/log/logtest"
11+
"github.com/sourcegraph/sourcegraph/internal/conf"
12+
"github.com/sourcegraph/sourcegraph/internal/extsvc"
13+
"github.com/sourcegraph/sourcegraph/lib/errors"
14+
"github.com/sourcegraph/sourcegraph/schema"
15+
)
16+
17+
func TestExport(t *testing.T) {
18+
logger := logtest.Scoped(t)
19+
20+
conf.Mock(&conf.Unified{
21+
SiteConfiguration: schema.SiteConfiguration{
22+
AuthProviders: []schema.AuthProviders{{
23+
Github: &schema.GitHubAuthProvider{
24+
ClientID: "myclientid",
25+
ClientSecret: "myclientsecret",
26+
DisplayName: "GitHub",
27+
Type: extsvc.TypeGitHub,
28+
Url: "https://github.com",
29+
AllowOrgs: []string{"myorg"},
30+
},
31+
}},
32+
PermissionsUserMapping: &schema.PermissionsUserMapping{
33+
BindID: "username",
34+
Enabled: true,
35+
},
36+
ExperimentalFeatures: &schema.ExperimentalFeatures{
37+
SearchIndexQueryContexts: true,
38+
},
39+
},
40+
})
41+
t.Cleanup(func() { conf.Mock(nil) })
42+
43+
exporter := &DataExporter{
44+
logger: logger,
45+
configProcessors: map[string]Processor[ConfigRequest]{
46+
"siteConfig": &SiteConfigProcessor{
47+
logger: logger,
48+
Type: "siteConfig",
49+
},
50+
},
51+
}
52+
53+
archive, err := exporter.Export(ExportRequest{IncludeSiteConfig: true})
54+
if err != nil {
55+
t.Fatal(err)
56+
}
57+
58+
zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
59+
if err != nil {
60+
t.Fatal(err)
61+
}
62+
63+
found := false
64+
65+
want := `{
66+
"auth.providers": [
67+
{
68+
"allowOrgs": [
69+
"myorg"
70+
],
71+
"clientID": "myclientid",
72+
"clientSecret": "REDACTED",
73+
"displayName": "GitHub",
74+
"type": "github",
75+
"url": "https://github.com"
76+
}
77+
],
78+
"experimentalFeatures": {
79+
"search.index.query.contexts": true
80+
},
81+
"permissions.userMapping": {
82+
"bindID": "username",
83+
"enabled": true
84+
}
85+
}`
86+
87+
for _, f := range zr.File {
88+
if f.Name != "site-config.json" {
89+
continue
90+
}
91+
found = true
92+
rc, err := f.Open()
93+
if err != nil {
94+
t.Fatal(err)
95+
}
96+
97+
haveBytes, err := io.ReadAll(rc)
98+
if err != nil {
99+
t.Fatal(err)
100+
}
101+
102+
have := string(haveBytes)
103+
104+
if diff := cmp.Diff(want, have); diff != "" {
105+
t.Fatalf("Exported site config is different. (-want +got):\n%s", diff)
106+
}
107+
}
108+
109+
if !found {
110+
t.Fatal(errors.New("site config file not found in exported zip archive"))
111+
}
112+
}
+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package oneclickexport
2+
3+
import (
4+
"io/ioutil"
5+
6+
"github.com/sourcegraph/log"
7+
"github.com/sourcegraph/sourcegraph/internal/conf"
8+
)
9+
10+
// Processor is a generic interface for any data export processor.
11+
//
12+
// Processors are called from DataExporter and store exported data in provided
13+
// directory which is zipped after all the processors finished their job.
14+
type Processor[T any] interface {
15+
Process(payload T, dir string)
16+
ProcessorType() string
17+
}
18+
19+
var _ Processor[ConfigRequest] = &SiteConfigProcessor{}
20+
21+
type SiteConfigProcessor struct {
22+
logger log.Logger
23+
Type string
24+
}
25+
26+
type ConfigRequest struct {
27+
}
28+
29+
// Process function of SiteConfigProcessor loads site config, redacts the secrets
30+
// and stores it in a provided tmp directory dir.
31+
func (g SiteConfigProcessor) Process(_ ConfigRequest, dir string) {
32+
siteConfig, err := conf.RedactSecrets(conf.Raw())
33+
if err != nil {
34+
g.logger.Error("Error during site config redacting", log.Error(err))
35+
}
36+
37+
configBytes := []byte(siteConfig.Site)
38+
39+
err = ioutil.WriteFile(dir+"/site-config.json", configBytes, 0644)
40+
41+
if err != nil {
42+
g.logger.Error("Error during site config export", log.Error(err))
43+
}
44+
}
45+
46+
func (g SiteConfigProcessor) ProcessorType() string {
47+
return g.Type
48+
}

internal/conf/store.go

+12
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package conf
22

33
import (
4+
"encoding/json"
45
"fmt"
56
"os"
67
"runtime/debug"
@@ -55,6 +56,17 @@ func (s *store) Raw() conftypes.RawUnified {
5556

5657
s.rawMu.RLock()
5758
defer s.rawMu.RUnlock()
59+
60+
if s.mock != nil {
61+
raw, err := json.Marshal(s.mock.SiteConfig())
62+
if err != nil {
63+
return conftypes.RawUnified{}
64+
}
65+
return conftypes.RawUnified{
66+
Site: string(raw),
67+
ServiceConnections: s.mock.ServiceConnectionConfig,
68+
}
69+
}
5870
return s.raw
5971
}
6072

0 commit comments

Comments
 (0)