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/access"
 16	"github.com/charmbracelet/soft-serve/server/backend"
 17	"github.com/charmbracelet/soft-serve/server/config"
 18	"github.com/charmbracelet/soft-serve/server/db"
 19	"github.com/charmbracelet/soft-serve/server/proto"
 20	"github.com/charmbracelet/soft-serve/server/sshutils"
 21	"github.com/charmbracelet/soft-serve/server/utils"
 22	gitm "github.com/gogs/git-module"
 23	"github.com/spf13/cobra"
 24	"golang.org/x/crypto/ssh"
 25	"gopkg.in/yaml.v3"
 26)
 27
 28// Deprecated: will be removed in a future release.
 29var migrateConfig = &cobra.Command{
 30	Use:    "migrate-config",
 31	Short:  "Migrate config to new format",
 32	Hidden: true,
 33	RunE: func(cmd *cobra.Command, _ []string) error {
 34		ctx := cmd.Context()
 35
 36		logger := log.FromContext(ctx)
 37		// Disable logging timestamp
 38		logger.SetReportTimestamp(false)
 39
 40		keyPath := os.Getenv("SOFT_SERVE_KEY_PATH")
 41		reposPath := os.Getenv("SOFT_SERVE_REPO_PATH")
 42		bindAddr := os.Getenv("SOFT_SERVE_BIND_ADDRESS")
 43		cfg := config.DefaultConfig()
 44		if err := cfg.ParseEnv(); err != nil {
 45			return fmt.Errorf("parse env: %w", err)
 46		}
 47
 48		ctx = config.WithContext(ctx, cfg)
 49		db, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource)
 50		if err != nil {
 51			return fmt.Errorf("open database: %w", err)
 52		}
 53
 54		defer db.Close() // nolint: errcheck
 55		sb := backend.New(ctx, cfg, db)
 56
 57		// FIXME: Admin user gets created when the database is created.
 58		sb.DeleteUser(ctx, "admin") // nolint: errcheck
 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, nil, "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 sb.SetAllowKeyless(ctx, ocfg.AllowKeyless) != nil {
139			fmt.Fprintf(os.Stderr, "failed to set allow keyless\n")
140		}
141		anon := access.ParseAccessLevel(ocfg.AnonAccess)
142		if anon >= 0 {
143			if err := sb.SetAnonAccess(ctx, 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(ctx, dir.Name(), nil, proto.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 { // nolint: gosec
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(ctx, ".soft-serve", nil, proto.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(ctx, repo, name); err != nil {
264				logger.Errorf("failed to set repo name to %s: %s", repo, err)
265			}
266
267			if err := sb.SetDescription(ctx, 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(ctx, 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(ctx, repo, collab, access.ReadWriteAccess); 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 := sshutils.ParseAuthorizedKey(key)
288				if err != nil {
289					continue
290				}
291				ak := sshutils.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(ctx, username, proto.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(ctx, repo, username, access.ReadWriteAccess); 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// Returns true if path is a directory containing an `objects` directory and a
324// `HEAD` file.
325func isGitDir(path string) bool {
326	stat, err := os.Stat(filepath.Join(path, "objects"))
327	if err != nil {
328		return false
329	}
330	if !stat.IsDir() {
331		return false
332	}
333
334	stat, err = os.Stat(filepath.Join(path, "HEAD"))
335	if err != nil {
336		return false
337	}
338	if stat.IsDir() {
339		return false
340	}
341
342	return true
343}
344
345// copyFile copies a single file from src to dst.
346func copyFile(src, dst string) error {
347	var err error
348	var srcfd *os.File
349	var dstfd *os.File
350	var srcinfo os.FileInfo
351
352	if srcfd, err = os.Open(src); err != nil {
353		return err
354	}
355	defer srcfd.Close() // nolint: errcheck
356
357	if dstfd, err = os.Create(dst); err != nil {
358		return err
359	}
360	defer dstfd.Close() // nolint: errcheck
361
362	if _, err = io.Copy(dstfd, srcfd); err != nil {
363		return err
364	}
365	if srcinfo, err = os.Stat(src); err != nil {
366		return err
367	}
368	return os.Chmod(dst, srcinfo.Mode())
369}
370
371// copyDir copies a whole directory recursively.
372func copyDir(src string, dst string) error {
373	var err error
374	var fds []os.DirEntry
375	var srcinfo os.FileInfo
376
377	if srcinfo, err = os.Stat(src); err != nil {
378		return err
379	}
380
381	if err = os.MkdirAll(dst, srcinfo.Mode()); err != nil {
382		return err
383	}
384
385	if fds, err = os.ReadDir(src); err != nil {
386		return err
387	}
388
389	for _, fd := range fds {
390		srcfp := filepath.Join(src, fd.Name())
391		dstfp := filepath.Join(dst, fd.Name())
392
393		if fd.IsDir() {
394			if err = copyDir(srcfp, dstfp); err != nil {
395				err = errors.Join(err, err)
396			}
397		} else {
398			if err = copyFile(srcfp, dstfp); err != nil {
399				err = errors.Join(err, err)
400			}
401		}
402	}
403
404	return err
405}
406
407// Config is the configuration for the server.
408type Config struct {
409	Name         string       `yaml:"name" json:"name"`
410	Host         string       `yaml:"host" json:"host"`
411	Port         int          `yaml:"port" json:"port"`
412	AnonAccess   string       `yaml:"anon-access" json:"anon-access"`
413	AllowKeyless bool         `yaml:"allow-keyless" json:"allow-keyless"`
414	Users        []User       `yaml:"users" json:"users"`
415	Repos        []RepoConfig `yaml:"repos" json:"repos"`
416}
417
418// User contains user-level configuration for a repository.
419type User struct {
420	Name        string   `yaml:"name" json:"name"`
421	Admin       bool     `yaml:"admin" json:"admin"`
422	PublicKeys  []string `yaml:"public-keys" json:"public-keys"`
423	CollabRepos []string `yaml:"collab-repos" json:"collab-repos"`
424}
425
426// RepoConfig is a repository configuration.
427type RepoConfig struct {
428	Name    string   `yaml:"name" json:"name"`
429	Repo    string   `yaml:"repo" json:"repo"`
430	Note    string   `yaml:"note" json:"note"`
431	Private bool     `yaml:"private" json:"private"`
432	Readme  string   `yaml:"readme" json:"readme"`
433	Collabs []string `yaml:"collabs" json:"collabs"`
434}