migrate_config.go

  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, args []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.DataPath)
 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				cfg.SSH.KeyPath = filepath.Join("ssh", filepath.Base(keyPath))
 56			}
 57
 58			// Read config
 59			log.Info("Reading config repository...")
 60			r, err := git.Open(filepath.Join(reposPath, "config"))
 61			if err != nil {
 62				return fmt.Errorf("failed to open config repo: %w", err)
 63			}
 64
 65			head, err := r.HEAD()
 66			if err != nil {
 67				return fmt.Errorf("failed to get head: %w", err)
 68			}
 69
 70			tree, err := r.TreePath(head, "")
 71			if err != nil {
 72				return fmt.Errorf("failed to get tree: %w", err)
 73			}
 74
 75			isJson := false
 76			te, err := tree.TreeEntry("config.yaml")
 77			if err != nil {
 78				te, err = tree.TreeEntry("config.json")
 79				if err != nil {
 80					return fmt.Errorf("failed to get config file: %w", err)
 81				}
 82				isJson = true
 83			}
 84
 85			cc, err := te.Contents()
 86			if err != nil {
 87				return fmt.Errorf("failed to get config contents: %w", err)
 88			}
 89
 90			var ocfg Config
 91			if isJson {
 92				if err := json.Unmarshal(cc, &ocfg); err != nil {
 93					return fmt.Errorf("failed to unmarshal config: %w", err)
 94				}
 95			} else {
 96				if err := yaml.Unmarshal(cc, &ocfg); err != nil {
 97					return fmt.Errorf("failed to unmarshal config: %w", err)
 98				}
 99			}
100
101			// Set server name
102			cfg.Name = ocfg.Name
103
104			// Set server public url
105			cfg.SSH.PublicURL = fmt.Sprintf("ssh://%s:%d", ocfg.Host, ocfg.Port)
106
107			// Set server settings
108			log.Info("Setting server settings...")
109			if cfg.Backend.SetAllowKeyless(ocfg.AllowKeyless) != nil {
110				fmt.Fprintf(os.Stderr, "failed to set allow keyless\n")
111			}
112			anon := backend.ParseAccessLevel(ocfg.AnonAccess)
113			if anon >= 0 {
114				if err := sb.SetAnonAccess(anon); err != nil {
115					fmt.Fprintf(os.Stderr, "failed to set anon access: %s\n", err)
116				}
117			}
118
119			// Copy repos
120			if reposPath != "" {
121				log.Info("Copying repos...")
122				dirs, err := os.ReadDir(reposPath)
123				if err != nil {
124					return fmt.Errorf("failed to read repos directory: %w", err)
125				}
126
127				for _, dir := range dirs {
128					if !dir.IsDir() {
129						continue
130					}
131
132					if !isGitDir(filepath.Join(reposPath, dir.Name())) {
133						continue
134					}
135
136					log.Infof("  Copying repo %s", dir.Name())
137					if err := os.MkdirAll(filepath.Join(cfg.DataPath, "repos"), 0700); err != nil {
138						return fmt.Errorf("failed to create repos directory: %w", err)
139					}
140
141					src := utils.SanitizeRepo(filepath.Join(reposPath, dir.Name()))
142					dst := utils.SanitizeRepo(filepath.Join(cfg.DataPath, "repos", dir.Name())) + ".git"
143					if err := copyDir(src, dst); err != nil {
144						return fmt.Errorf("failed to copy repo: %w", err)
145					}
146
147					if _, err := sb.CreateRepository(dir.Name(), backend.RepositoryOptions{}); err != nil {
148						fmt.Fprintf(os.Stderr, "failed to create repository: %s\n", err)
149					}
150				}
151			}
152
153			// Set repos metadata & collabs
154			log.Info("Setting repos metadata & collabs...")
155			for _, repo := range ocfg.Repos {
156				if err := sb.SetProjectName(repo.Repo, repo.Name); err != nil {
157					log.Errorf("failed to set repo name to %s: %s", repo.Repo, err)
158				}
159
160				if err := sb.SetDescription(repo.Repo, repo.Note); err != nil {
161					log.Errorf("failed to set repo description to %s: %s", repo.Repo, err)
162				}
163
164				if err := sb.SetPrivate(repo.Repo, repo.Private); err != nil {
165					log.Errorf("failed to set repo private to %s: %s", repo.Repo, err)
166				}
167
168				for _, collab := range repo.Collabs {
169					if err := sb.AddCollaborator(repo.Repo, collab); err != nil {
170						log.Errorf("failed to add repo collab to %s: %s", repo.Repo, err)
171					}
172				}
173			}
174
175			// Create users & collabs
176			log.Info("Creating users & collabs...")
177			for _, user := range ocfg.Users {
178				keys := make(map[string]ssh.PublicKey)
179				for _, key := range user.PublicKeys {
180					pk, _, err := backend.ParseAuthorizedKey(key)
181					if err != nil {
182						continue
183					}
184					ak := backend.MarshalAuthorizedKey(pk)
185					keys[ak] = pk
186				}
187
188				pubkeys := make([]ssh.PublicKey, 0)
189				for _, pk := range keys {
190					pubkeys = append(pubkeys, pk)
191				}
192
193				username := strings.ToLower(user.Name)
194				username = strings.ReplaceAll(username, " ", "-")
195				log.Infof("Creating user %q", username)
196				if _, err := sb.CreateUser(username, backend.UserOptions{
197					Admin:      user.Admin,
198					PublicKeys: pubkeys,
199				}); err != nil {
200					log.Errorf("failed to create user: %s", err)
201				}
202
203				for _, repo := range user.CollabRepos {
204					if err := sb.AddCollaborator(repo, username); err != nil {
205						log.Errorf("failed to add user collab to %s: %s\n", repo, err)
206					}
207				}
208			}
209
210			log.Info("Writing config...")
211			defer log.Info("Done!")
212			return config.WriteConfig(filepath.Join(cfg.DataPath, "config.yaml"), cfg)
213		},
214	}
215)
216
217// Returns true if path is a directory containing an `objects` directory and a
218// `HEAD` file.
219func isGitDir(path string) bool {
220	stat, err := os.Stat(filepath.Join(path, "objects"))
221	if err != nil {
222		return false
223	}
224	if !stat.IsDir() {
225		return false
226	}
227
228	stat, err = os.Stat(filepath.Join(path, "HEAD"))
229	if err != nil {
230		return false
231	}
232	if stat.IsDir() {
233		return false
234	}
235
236	return true
237}
238
239// copyFile copies a single file from src to dst
240func copyFile(src, dst string) error {
241	var err error
242	var srcfd *os.File
243	var dstfd *os.File
244	var srcinfo os.FileInfo
245
246	if srcfd, err = os.Open(src); err != nil {
247		return err
248	}
249	defer srcfd.Close()
250
251	if dstfd, err = os.Create(dst); err != nil {
252		return err
253	}
254	defer dstfd.Close()
255
256	if _, err = io.Copy(dstfd, srcfd); err != nil {
257		return err
258	}
259	if srcinfo, err = os.Stat(src); err != nil {
260		return err
261	}
262	return os.Chmod(dst, srcinfo.Mode())
263}
264
265// copyDir copies a whole directory recursively
266func copyDir(src string, dst string) error {
267	var err error
268	var fds []os.DirEntry
269	var srcinfo os.FileInfo
270
271	if srcinfo, err = os.Stat(src); err != nil {
272		return err
273	}
274
275	if err = os.MkdirAll(dst, srcinfo.Mode()); err != nil {
276		return err
277	}
278
279	if fds, err = os.ReadDir(src); err != nil {
280		return err
281	}
282	for _, fd := range fds {
283		srcfp := filepath.Join(src, fd.Name())
284		dstfp := filepath.Join(dst, fd.Name())
285
286		if fd.IsDir() {
287			if err = copyDir(srcfp, dstfp); err != nil {
288				fmt.Println(err)
289			}
290		} else {
291			if err = copyFile(srcfp, dstfp); err != nil {
292				fmt.Println(err)
293			}
294		}
295	}
296	return nil
297}
298
299// func copyDir(src, dst string) error {
300// 	entries, err := os.ReadDir(src)
301// 	if err != nil {
302// 		return err
303// 	}
304// 	for _, entry := range entries {
305// 		sourcePath := filepath.Join(src, entry.Name())
306// 		destPath := filepath.Join(dst, entry.Name())
307//
308// 		fileInfo, err := os.Stat(sourcePath)
309// 		if err != nil {
310// 			return err
311// 		}
312//
313// 		stat, ok := fileInfo.Sys().(*syscall.Stat_t)
314// 		if !ok {
315// 			return fmt.Errorf("failed to get raw syscall.Stat_t data for '%s'", sourcePath)
316// 		}
317//
318// 		switch fileInfo.Mode() & os.ModeType {
319// 		case os.ModeDir:
320// 			if err := createIfNotExists(destPath, 0755); err != nil {
321// 				return err
322// 			}
323// 			if err := copyDir(sourcePath, destPath); err != nil {
324// 				return err
325// 			}
326// 		case os.ModeSymlink:
327// 			if err := copySymLink(sourcePath, destPath); err != nil {
328// 				return err
329// 			}
330// 		default:
331// 			if err := copyFile(sourcePath, destPath); err != nil {
332// 				return err
333// 			}
334// 		}
335//
336// 		if err := os.Lchown(destPath, int(stat.Uid), int(stat.Gid)); err != nil {
337// 			return err
338// 		}
339//
340// 		fInfo, err := entry.Info()
341// 		if err != nil {
342// 			return err
343// 		}
344//
345// 		isSymlink := fInfo.Mode()&os.ModeSymlink != 0
346// 		if !isSymlink {
347// 			if err := os.Chmod(destPath, fInfo.Mode()); err != nil {
348// 				return err
349// 			}
350// 		}
351// 	}
352// 	return nil
353// }
354//
355// func copyFile(srcFile, dstFile string) error {
356// 	out, err := os.Create(dstFile)
357// 	if err != nil {
358// 		return err
359// 	}
360//
361// 	defer out.Close()
362//
363// 	in, err := os.Open(srcFile)
364// 	defer in.Close()
365// 	if err != nil {
366// 		return err
367// 	}
368//
369// 	_, err = io.Copy(out, in)
370// 	if err != nil {
371// 		return err
372// 	}
373//
374// 	return nil
375// }
376
377func exists(filePath string) bool {
378	if _, err := os.Stat(filePath); os.IsNotExist(err) {
379		return false
380	}
381
382	return true
383}
384
385func createIfNotExists(dir string, perm os.FileMode) error {
386	if exists(dir) {
387		return nil
388	}
389
390	if err := os.MkdirAll(dir, perm); err != nil {
391		return fmt.Errorf("failed to create directory: '%s', error: '%s'", dir, err.Error())
392	}
393
394	return nil
395}
396
397func copySymLink(source, dest string) error {
398	link, err := os.Readlink(source)
399	if err != nil {
400		return err
401	}
402	return os.Symlink(link, dest)
403}
404
405type Config struct {
406	Name         string       `yaml:"name" json:"name"`
407	Host         string       `yaml:"host" json:"host"`
408	Port         int          `yaml:"port" json:"port"`
409	AnonAccess   string       `yaml:"anon-access" json:"anon-access"`
410	AllowKeyless bool         `yaml:"allow-keyless" json:"allow-keyless"`
411	Users        []User       `yaml:"users" json:"users"`
412	Repos        []RepoConfig `yaml:"repos" json:"repos"`
413}
414
415// User contains user-level configuration for a repository.
416type User struct {
417	Name        string   `yaml:"name" json:"name"`
418	Admin       bool     `yaml:"admin" json:"admin"`
419	PublicKeys  []string `yaml:"public-keys" json:"public-keys"`
420	CollabRepos []string `yaml:"collab-repos" json:"collab-repos"`
421}
422
423// RepoConfig is a repository configuration.
424type RepoConfig struct {
425	Name    string   `yaml:"name" json:"name"`
426	Repo    string   `yaml:"repo" json:"repo"`
427	Note    string   `yaml:"note" json:"note"`
428	Private bool     `yaml:"private" json:"private"`
429	Readme  string   `yaml:"readme" json:"readme"`
430	Collabs []string `yaml:"collabs" json:"collabs"`
431}