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