1package main
2
3import (
4 "encoding/json"
5 "fmt"
6 "io"
7 "os"
8 "path/filepath"
9 "strings"
10
11 "github.com/charmbracelet/log"
12 "github.com/charmbracelet/soft-serve/git"
13 "github.com/charmbracelet/soft-serve/server/backend"
14 "github.com/charmbracelet/soft-serve/server/backend/sqlite"
15 "github.com/charmbracelet/soft-serve/server/config"
16 "github.com/charmbracelet/soft-serve/server/utils"
17 "github.com/spf13/cobra"
18 "golang.org/x/crypto/ssh"
19 "gopkg.in/yaml.v3"
20)
21
22var (
23 migrateConfig = &cobra.Command{
24 Use: "migrate-config",
25 Short: "Migrate config to new format",
26 RunE: func(_ *cobra.Command, _ []string) error {
27 keyPath := os.Getenv("SOFT_SERVE_KEY_PATH")
28 reposPath := os.Getenv("SOFT_SERVE_REPO_PATH")
29 bindAddr := os.Getenv("SOFT_SERVE_BIND_ADDRESS")
30 cfg := config.DefaultConfig()
31 sb, err := sqlite.NewSqliteBackend(cfg)
32 if err != nil {
33 return fmt.Errorf("failed to create sqlite backend: %w", err)
34 }
35
36 cfg = cfg.WithBackend(sb)
37
38 // Set SSH listen address
39 log.Info("Setting SSH listen address...")
40 if bindAddr != "" {
41 cfg.SSH.ListenAddr = bindAddr
42 }
43
44 // Copy SSH host key
45 log.Info("Copying SSH host key...")
46 if keyPath != "" {
47 if err := os.MkdirAll(filepath.Join(cfg.DataPath, "ssh"), 0700); err != nil {
48 return fmt.Errorf("failed to create ssh directory: %w", err)
49 }
50
51 if err := copyFile(keyPath, filepath.Join(cfg.DataPath, "ssh", filepath.Base(keyPath))); err != nil {
52 return fmt.Errorf("failed to copy ssh key: %w", err)
53 }
54
55 if err := copyFile(keyPath+".pub", filepath.Join(cfg.DataPath, "ssh", filepath.Base(keyPath))+".pub"); err != nil {
56 log.Errorf("failed to copy ssh key: %s", err)
57 }
58
59 cfg.SSH.KeyPath = filepath.Join("ssh", filepath.Base(keyPath))
60 }
61
62 // Read config
63 log.Info("Reading config repository...")
64 r, err := git.Open(filepath.Join(reposPath, "config"))
65 if err != nil {
66 return fmt.Errorf("failed to open config repo: %w", err)
67 }
68
69 head, err := r.HEAD()
70 if err != nil {
71 return fmt.Errorf("failed to get head: %w", err)
72 }
73
74 tree, err := r.TreePath(head, "")
75 if err != nil {
76 return fmt.Errorf("failed to get tree: %w", err)
77 }
78
79 isJson := false // nolint: revive
80 te, err := tree.TreeEntry("config.yaml")
81 if err != nil {
82 te, err = tree.TreeEntry("config.json")
83 if err != nil {
84 return fmt.Errorf("failed to get config file: %w", err)
85 }
86 isJson = true
87 }
88
89 cc, err := te.Contents()
90 if err != nil {
91 return fmt.Errorf("failed to get config contents: %w", err)
92 }
93
94 var ocfg Config
95 if isJson {
96 if err := json.Unmarshal(cc, &ocfg); err != nil {
97 return fmt.Errorf("failed to unmarshal config: %w", err)
98 }
99 } else {
100 if err := yaml.Unmarshal(cc, &ocfg); err != nil {
101 return fmt.Errorf("failed to unmarshal config: %w", err)
102 }
103 }
104
105 // Set server name
106 cfg.Name = ocfg.Name
107
108 // Set server public url
109 cfg.SSH.PublicURL = fmt.Sprintf("ssh://%s:%d", ocfg.Host, ocfg.Port)
110
111 // Set server settings
112 log.Info("Setting server settings...")
113 if cfg.Backend.SetAllowKeyless(ocfg.AllowKeyless) != nil {
114 fmt.Fprintf(os.Stderr, "failed to set allow keyless\n")
115 }
116 anon := backend.ParseAccessLevel(ocfg.AnonAccess)
117 if anon >= 0 {
118 if err := sb.SetAnonAccess(anon); err != nil {
119 fmt.Fprintf(os.Stderr, "failed to set anon access: %s\n", err)
120 }
121 }
122
123 // Copy repos
124 if reposPath != "" {
125 log.Info("Copying repos...")
126 dirs, err := os.ReadDir(reposPath)
127 if err != nil {
128 return fmt.Errorf("failed to read repos directory: %w", err)
129 }
130
131 for _, dir := range dirs {
132 if !dir.IsDir() {
133 continue
134 }
135
136 if !isGitDir(filepath.Join(reposPath, dir.Name())) {
137 continue
138 }
139
140 log.Infof(" Copying repo %s", dir.Name())
141 if err := os.MkdirAll(filepath.Join(cfg.DataPath, "repos"), 0700); err != nil {
142 return fmt.Errorf("failed to create repos directory: %w", err)
143 }
144
145 src := utils.SanitizeRepo(filepath.Join(reposPath, dir.Name()))
146 dst := utils.SanitizeRepo(filepath.Join(cfg.DataPath, "repos", dir.Name())) + ".git"
147 if err := copyDir(src, dst); err != nil {
148 return fmt.Errorf("failed to copy repo: %w", err)
149 }
150
151 if _, err := sb.CreateRepository(dir.Name(), backend.RepositoryOptions{}); err != nil {
152 fmt.Fprintf(os.Stderr, "failed to create repository: %s\n", err)
153 }
154 }
155 }
156
157 // Set repos metadata & collabs
158 log.Info("Setting repos metadata & collabs...")
159 for _, repo := range ocfg.Repos {
160 if err := sb.SetProjectName(repo.Repo, repo.Name); err != nil {
161 log.Errorf("failed to set repo name to %s: %s", repo.Repo, err)
162 }
163
164 if err := sb.SetDescription(repo.Repo, repo.Note); err != nil {
165 log.Errorf("failed to set repo description to %s: %s", repo.Repo, err)
166 }
167
168 if err := sb.SetPrivate(repo.Repo, repo.Private); err != nil {
169 log.Errorf("failed to set repo private to %s: %s", repo.Repo, err)
170 }
171
172 for _, collab := range repo.Collabs {
173 if err := sb.AddCollaborator(repo.Repo, collab); err != nil {
174 log.Errorf("failed to add repo collab to %s: %s", repo.Repo, err)
175 }
176 }
177 }
178
179 // Create users & collabs
180 log.Info("Creating users & collabs...")
181 for _, user := range ocfg.Users {
182 keys := make(map[string]ssh.PublicKey)
183 for _, key := range user.PublicKeys {
184 pk, _, err := backend.ParseAuthorizedKey(key)
185 if err != nil {
186 continue
187 }
188 ak := backend.MarshalAuthorizedKey(pk)
189 keys[ak] = pk
190 }
191
192 pubkeys := make([]ssh.PublicKey, 0)
193 for _, pk := range keys {
194 pubkeys = append(pubkeys, pk)
195 }
196
197 username := strings.ToLower(user.Name)
198 username = strings.ReplaceAll(username, " ", "-")
199 log.Infof("Creating user %q", username)
200 if _, err := sb.CreateUser(username, backend.UserOptions{
201 Admin: user.Admin,
202 PublicKeys: pubkeys,
203 }); err != nil {
204 log.Errorf("failed to create user: %s", err)
205 }
206
207 for _, repo := range user.CollabRepos {
208 if err := sb.AddCollaborator(repo, username); err != nil {
209 log.Errorf("failed to add user collab to %s: %s\n", repo, err)
210 }
211 }
212 }
213
214 log.Info("Writing config...")
215 defer log.Info("Done!")
216 return config.WriteConfig(filepath.Join(cfg.DataPath, "config.yaml"), cfg)
217 },
218 }
219)
220
221// Returns true if path is a directory containing an `objects` directory and a
222// `HEAD` file.
223func isGitDir(path string) bool {
224 stat, err := os.Stat(filepath.Join(path, "objects"))
225 if err != nil {
226 return false
227 }
228 if !stat.IsDir() {
229 return false
230 }
231
232 stat, err = os.Stat(filepath.Join(path, "HEAD"))
233 if err != nil {
234 return false
235 }
236 if stat.IsDir() {
237 return false
238 }
239
240 return true
241}
242
243// copyFile copies a single file from src to dst.
244func copyFile(src, dst string) error {
245 var err error
246 var srcfd *os.File
247 var dstfd *os.File
248 var srcinfo os.FileInfo
249
250 if srcfd, err = os.Open(src); err != nil {
251 return err
252 }
253 defer srcfd.Close() // nolint: errcheck
254
255 if dstfd, err = os.Create(dst); err != nil {
256 return err
257 }
258 defer dstfd.Close() // nolint: errcheck
259
260 if _, err = io.Copy(dstfd, srcfd); err != nil {
261 return err
262 }
263 if srcinfo, err = os.Stat(src); err != nil {
264 return err
265 }
266 return os.Chmod(dst, srcinfo.Mode())
267}
268
269// copyDir copies a whole directory recursively.
270func copyDir(src string, dst string) error {
271 var err error
272 var fds []os.DirEntry
273 var srcinfo os.FileInfo
274
275 if srcinfo, err = os.Stat(src); err != nil {
276 return err
277 }
278
279 if err = os.MkdirAll(dst, srcinfo.Mode()); err != nil {
280 return err
281 }
282
283 if fds, err = os.ReadDir(src); err != nil {
284 return err
285 }
286 for _, fd := range fds {
287 srcfp := filepath.Join(src, fd.Name())
288 dstfp := filepath.Join(dst, fd.Name())
289
290 if fd.IsDir() {
291 if err = copyDir(srcfp, dstfp); err != nil {
292 fmt.Println(err)
293 }
294 } else {
295 if err = copyFile(srcfp, dstfp); err != nil {
296 fmt.Println(err)
297 }
298 }
299 }
300 return nil
301}
302
303// Config is the configuration for the server.
304type Config struct {
305 Name string `yaml:"name" json:"name"`
306 Host string `yaml:"host" json:"host"`
307 Port int `yaml:"port" json:"port"`
308 AnonAccess string `yaml:"anon-access" json:"anon-access"`
309 AllowKeyless bool `yaml:"allow-keyless" json:"allow-keyless"`
310 Users []User `yaml:"users" json:"users"`
311 Repos []RepoConfig `yaml:"repos" json:"repos"`
312}
313
314// User contains user-level configuration for a repository.
315type User struct {
316 Name string `yaml:"name" json:"name"`
317 Admin bool `yaml:"admin" json:"admin"`
318 PublicKeys []string `yaml:"public-keys" json:"public-keys"`
319 CollabRepos []string `yaml:"collab-repos" json:"collab-repos"`
320}
321
322// RepoConfig is a repository configuration.
323type RepoConfig struct {
324 Name string `yaml:"name" json:"name"`
325 Repo string `yaml:"repo" json:"repo"`
326 Note string `yaml:"note" json:"note"`
327 Private bool `yaml:"private" json:"private"`
328 Readme string `yaml:"readme" json:"readme"`
329 Collabs []string `yaml:"collabs" json:"collabs"`
330}