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