migrate_config.go

  1package main
  2
  3import (
  4	"encoding/json"
  5	"errors"
  6	"fmt"
  7	"io"
  8	"os"
  9	"path/filepath"
 10	"strings"
 11	"time"
 12
 13	"github.com/charmbracelet/log"
 14	"github.com/charmbracelet/soft-serve/git"
 15	"github.com/charmbracelet/soft-serve/server/backend"
 16	"github.com/charmbracelet/soft-serve/server/backend/sqlite"
 17	"github.com/charmbracelet/soft-serve/server/config"
 18	"github.com/charmbracelet/soft-serve/server/utils"
 19	gitm "github.com/gogs/git-module"
 20	"github.com/spf13/cobra"
 21	"golang.org/x/crypto/ssh"
 22	"gopkg.in/yaml.v3"
 23)
 24
 25var (
 26	migrateConfig = &cobra.Command{
 27		Use:    "migrate-config",
 28		Short:  "Migrate config to new format",
 29		Hidden: true,
 30		RunE: func(cmd *cobra.Command, _ []string) error {
 31			ctx := cmd.Context()
 32
 33			logger := log.FromContext(ctx)
 34			// Disable logging timestamp
 35			logger.SetReportTimestamp(false)
 36
 37			keyPath := os.Getenv("SOFT_SERVE_KEY_PATH")
 38			reposPath := os.Getenv("SOFT_SERVE_REPO_PATH")
 39			bindAddr := os.Getenv("SOFT_SERVE_BIND_ADDRESS")
 40
 41			// Set up config
 42			cfg := config.DefaultConfig()
 43			if !cfg.Exist() {
 44				if err := cfg.WriteConfig(); err != nil {
 45					return fmt.Errorf("failed to write default config: %w", err)
 46				}
 47			}
 48
 49			ctx = config.WithContext(ctx, cfg)
 50			sb, err := sqlite.NewSqliteBackend(ctx)
 51			if err != nil {
 52				return fmt.Errorf("failed to create sqlite backend: %w", err)
 53			}
 54
 55			// FIXME: Admin user gets created when the database is created.
 56			sb.DeleteUser("admin") // nolint: errcheck
 57
 58			cfg = cfg.WithBackend(sb)
 59
 60			// Set SSH listen address
 61			logger.Info("Setting SSH listen address...")
 62			if bindAddr != "" {
 63				cfg.SSH.ListenAddr = bindAddr
 64			}
 65
 66			// Copy SSH host key
 67			logger.Info("Copying SSH host key...")
 68			if keyPath != "" {
 69				if err := os.MkdirAll(filepath.Join(cfg.DataPath, "ssh"), os.ModePerm); err != nil {
 70					return fmt.Errorf("failed to create ssh directory: %w", err)
 71				}
 72
 73				if err := copyFile(keyPath, filepath.Join(cfg.DataPath, "ssh", filepath.Base(keyPath))); err != nil {
 74					return fmt.Errorf("failed to copy ssh key: %w", err)
 75				}
 76
 77				if err := copyFile(keyPath+".pub", filepath.Join(cfg.DataPath, "ssh", filepath.Base(keyPath))+".pub"); err != nil {
 78					logger.Errorf("failed to copy ssh key: %s", err)
 79				}
 80
 81				cfg.SSH.KeyPath = filepath.Join(cfg.DataPath, "ssh", filepath.Base(keyPath))
 82			}
 83
 84			// Read config
 85			logger.Info("Reading config repository...")
 86			r, err := git.Open(filepath.Join(reposPath, "config"))
 87			if err != nil {
 88				return fmt.Errorf("failed to open config repo: %w", err)
 89			}
 90
 91			head, err := r.HEAD()
 92			if err != nil {
 93				return fmt.Errorf("failed to get head: %w", err)
 94			}
 95
 96			tree, err := r.TreePath(head, "")
 97			if err != nil {
 98				return fmt.Errorf("failed to get tree: %w", err)
 99			}
100
101			isJson := false // nolint: revive
102			te, err := tree.TreeEntry("config.yaml")
103			if err != nil {
104				te, err = tree.TreeEntry("config.json")
105				if err != nil {
106					return fmt.Errorf("failed to get config file: %w", err)
107				}
108				isJson = true
109			}
110
111			cc, err := te.Contents()
112			if err != nil {
113				return fmt.Errorf("failed to get config contents: %w", err)
114			}
115
116			var ocfg Config
117			if isJson {
118				if err := json.Unmarshal(cc, &ocfg); err != nil {
119					return fmt.Errorf("failed to unmarshal config: %w", err)
120				}
121			} else {
122				if err := yaml.Unmarshal(cc, &ocfg); err != nil {
123					return fmt.Errorf("failed to unmarshal config: %w", err)
124				}
125			}
126
127			readme, readmePath, err := git.LatestFile(r, "README*")
128			hasReadme := err == nil
129
130			// Set server name
131			cfg.Name = ocfg.Name
132
133			// Set server public url
134			cfg.SSH.PublicURL = fmt.Sprintf("ssh://%s:%d", ocfg.Host, ocfg.Port)
135
136			// Set server settings
137			logger.Info("Setting server settings...")
138			if cfg.Backend.SetAllowKeyless(ocfg.AllowKeyless) != nil {
139				fmt.Fprintf(os.Stderr, "failed to set allow keyless\n")
140			}
141			anon := backend.ParseAccessLevel(ocfg.AnonAccess)
142			if anon >= 0 {
143				if err := sb.SetAnonAccess(anon); err != nil {
144					fmt.Fprintf(os.Stderr, "failed to set anon access: %s\n", err)
145				}
146			}
147
148			// Copy repos
149			if reposPath != "" {
150				logger.Info("Copying repos...")
151				if err := os.MkdirAll(filepath.Join(cfg.DataPath, "repos"), os.ModePerm); err != nil {
152					return fmt.Errorf("failed to create repos directory: %w", err)
153				}
154
155				dirs, err := os.ReadDir(reposPath)
156				if err != nil {
157					return fmt.Errorf("failed to read repos directory: %w", err)
158				}
159
160				for _, dir := range dirs {
161					if !dir.IsDir() || dir.Name() == "config" {
162						continue
163					}
164
165					if !isGitDir(filepath.Join(reposPath, dir.Name())) {
166						continue
167					}
168
169					logger.Infof("  Copying repo %s", dir.Name())
170					src := filepath.Join(reposPath, utils.SanitizeRepo(dir.Name()))
171					dst := filepath.Join(cfg.DataPath, "repos", utils.SanitizeRepo(dir.Name())) + ".git"
172					if err := os.MkdirAll(dst, os.ModePerm); err != nil {
173						return fmt.Errorf("failed to create repo directory: %w", err)
174					}
175
176					if err := copyDir(src, dst); err != nil {
177						return fmt.Errorf("failed to copy repo: %w", err)
178					}
179
180					if _, err := sb.CreateRepository(dir.Name(), backend.RepositoryOptions{}); err != nil {
181						fmt.Fprintf(os.Stderr, "failed to create repository: %s\n", err)
182					}
183				}
184
185				if hasReadme {
186					logger.Infof("  Copying readme from \"config\" to \".soft-serve\"")
187
188					// Switch to main branch
189					bcmd := git.NewCommand("branch", "-M", "main")
190
191					rp := filepath.Join(cfg.DataPath, "repos", ".soft-serve.git")
192					nr, err := git.Init(rp, true)
193					if err != nil {
194						return fmt.Errorf("failed to init repo: %w", err)
195					}
196
197					if _, err := nr.SymbolicRef("HEAD", gitm.RefsHeads+"main"); err != nil {
198						return fmt.Errorf("failed to set HEAD: %w", err)
199					}
200
201					tmpDir, err := os.MkdirTemp("", "soft-serve")
202					if err != nil {
203						return fmt.Errorf("failed to create temp dir: %w", err)
204					}
205
206					r, err := git.Init(tmpDir, false)
207					if err != nil {
208						return fmt.Errorf("failed to clone repo: %w", err)
209					}
210
211					if _, err := bcmd.RunInDir(tmpDir); err != nil {
212						return fmt.Errorf("failed to create main branch: %w", err)
213					}
214
215					if err := os.WriteFile(filepath.Join(tmpDir, readmePath), []byte(readme), 0o644); err != nil {
216						return fmt.Errorf("failed to write readme: %w", err)
217					}
218
219					if err := r.Add(gitm.AddOptions{
220						All: true,
221					}); err != nil {
222						return fmt.Errorf("failed to add readme: %w", err)
223					}
224
225					if err := r.Commit(&gitm.Signature{
226						Name:  "Soft Serve",
227						Email: "vt100@charm.sh",
228						When:  time.Now(),
229					}, "Add readme"); err != nil {
230						return fmt.Errorf("failed to commit readme: %w", err)
231					}
232
233					if err := r.RemoteAdd("origin", "file://"+rp); err != nil {
234						return fmt.Errorf("failed to add remote: %w", err)
235					}
236
237					if err := r.Push("origin", "main"); err != nil {
238						return fmt.Errorf("failed to push readme: %w", err)
239					}
240
241					// Create `.soft-serve` repository and add readme
242					if _, err := sb.CreateRepository(".soft-serve", backend.RepositoryOptions{
243						ProjectName: "Home",
244						Description: "Soft Serve home repository",
245						Hidden:      true,
246						Private:     false,
247					}); err != nil {
248						fmt.Fprintf(os.Stderr, "failed to create repository: %s\n", err)
249					}
250				}
251			}
252
253			// Set repos metadata & collabs
254			logger.Info("Setting repos metadata & collabs...")
255			for _, r := range ocfg.Repos {
256				repo, name := r.Repo, r.Name
257				// Special case for config repo
258				if repo == "config" {
259					repo = ".soft-serve"
260					r.Private = false
261				}
262
263				if err := sb.SetProjectName(repo, name); err != nil {
264					logger.Errorf("failed to set repo name to %s: %s", repo, err)
265				}
266
267				if err := sb.SetDescription(repo, r.Note); err != nil {
268					logger.Errorf("failed to set repo description to %s: %s", repo, err)
269				}
270
271				if err := sb.SetPrivate(repo, r.Private); err != nil {
272					logger.Errorf("failed to set repo private to %s: %s", repo, err)
273				}
274
275				for _, collab := range r.Collabs {
276					if err := sb.AddCollaborator(repo, collab); err != nil {
277						logger.Errorf("failed to add repo collab to %s: %s", repo, err)
278					}
279				}
280			}
281
282			// Create users & collabs
283			logger.Info("Creating users & collabs...")
284			for _, user := range ocfg.Users {
285				keys := make(map[string]ssh.PublicKey)
286				for _, key := range user.PublicKeys {
287					pk, _, err := backend.ParseAuthorizedKey(key)
288					if err != nil {
289						continue
290					}
291					ak := backend.MarshalAuthorizedKey(pk)
292					keys[ak] = pk
293				}
294
295				pubkeys := make([]ssh.PublicKey, 0)
296				for _, pk := range keys {
297					pubkeys = append(pubkeys, pk)
298				}
299
300				username := strings.ToLower(user.Name)
301				username = strings.ReplaceAll(username, " ", "-")
302				logger.Infof("Creating user %q", username)
303				if _, err := sb.CreateUser(username, backend.UserOptions{
304					Admin:      user.Admin,
305					PublicKeys: pubkeys,
306				}); err != nil {
307					logger.Errorf("failed to create user: %s", err)
308				}
309
310				for _, repo := range user.CollabRepos {
311					if err := sb.AddCollaborator(repo, username); err != nil {
312						logger.Errorf("failed to add user collab to %s: %s\n", repo, err)
313					}
314				}
315			}
316
317			logger.Info("Writing config...")
318			defer logger.Info("Done!")
319			return cfg.WriteConfig()
320		},
321	}
322)
323
324// Returns true if path is a directory containing an `objects` directory and a
325// `HEAD` file.
326func isGitDir(path string) bool {
327	stat, err := os.Stat(filepath.Join(path, "objects"))
328	if err != nil {
329		return false
330	}
331	if !stat.IsDir() {
332		return false
333	}
334
335	stat, err = os.Stat(filepath.Join(path, "HEAD"))
336	if err != nil {
337		return false
338	}
339	if stat.IsDir() {
340		return false
341	}
342
343	return true
344}
345
346// copyFile copies a single file from src to dst.
347func copyFile(src, dst string) error {
348	var err error
349	var srcfd *os.File
350	var dstfd *os.File
351	var srcinfo os.FileInfo
352
353	if srcfd, err = os.Open(src); err != nil {
354		return err
355	}
356	defer srcfd.Close() // nolint: errcheck
357
358	if dstfd, err = os.Create(dst); err != nil {
359		return err
360	}
361	defer dstfd.Close() // nolint: errcheck
362
363	if _, err = io.Copy(dstfd, srcfd); err != nil {
364		return err
365	}
366	if srcinfo, err = os.Stat(src); err != nil {
367		return err
368	}
369	return os.Chmod(dst, srcinfo.Mode())
370}
371
372// copyDir copies a whole directory recursively.
373func copyDir(src string, dst string) error {
374	var err error
375	var fds []os.DirEntry
376	var srcinfo os.FileInfo
377
378	if srcinfo, err = os.Stat(src); err != nil {
379		return err
380	}
381
382	if err = os.MkdirAll(dst, srcinfo.Mode()); err != nil {
383		return err
384	}
385
386	if fds, err = os.ReadDir(src); err != nil {
387		return err
388	}
389
390	for _, fd := range fds {
391		srcfp := filepath.Join(src, fd.Name())
392		dstfp := filepath.Join(dst, fd.Name())
393
394		if fd.IsDir() {
395			if err = copyDir(srcfp, dstfp); err != nil {
396				err = errors.Join(err, err)
397			}
398		} else {
399			if err = copyFile(srcfp, dstfp); err != nil {
400				err = errors.Join(err, err)
401			}
402		}
403	}
404
405	return err
406}
407
408// Config is the configuration for the server.
409type Config struct {
410	Name         string       `yaml:"name" json:"name"`
411	Host         string       `yaml:"host" json:"host"`
412	Port         int          `yaml:"port" json:"port"`
413	AnonAccess   string       `yaml:"anon-access" json:"anon-access"`
414	AllowKeyless bool         `yaml:"allow-keyless" json:"allow-keyless"`
415	Users        []User       `yaml:"users" json:"users"`
416	Repos        []RepoConfig `yaml:"repos" json:"repos"`
417}
418
419// User contains user-level configuration for a repository.
420type User struct {
421	Name        string   `yaml:"name" json:"name"`
422	Admin       bool     `yaml:"admin" json:"admin"`
423	PublicKeys  []string `yaml:"public-keys" json:"public-keys"`
424	CollabRepos []string `yaml:"collab-repos" json:"collab-repos"`
425}
426
427// RepoConfig is a repository configuration.
428type RepoConfig struct {
429	Name    string   `yaml:"name" json:"name"`
430	Repo    string   `yaml:"repo" json:"repo"`
431	Note    string   `yaml:"note" json:"note"`
432	Private bool     `yaml:"private" json:"private"`
433	Readme  string   `yaml:"readme" json:"readme"`
434	Collabs []string `yaml:"collabs" json:"collabs"`
435}