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