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		Hidden: true,
 27		RunE: func(cmd *cobra.Command, _ []string) error {
 28			keyPath := os.Getenv("SOFT_SERVE_KEY_PATH")
 29			reposPath := os.Getenv("SOFT_SERVE_REPO_PATH")
 30			bindAddr := os.Getenv("SOFT_SERVE_BIND_ADDRESS")
 31			ctx := cmd.Context()
 32			cfg := config.DefaultConfig()
 33			sb, err := sqlite.NewSqliteBackend(ctx, cfg)
 34			if err != nil {
 35				return fmt.Errorf("failed to create sqlite backend: %w", err)
 36			}
 37
 38			cfg = cfg.WithBackend(sb)
 39
 40			// Set SSH listen address
 41			log.Info("Setting SSH listen address...")
 42			if bindAddr != "" {
 43				cfg.SSH.ListenAddr = bindAddr
 44			}
 45
 46			// Copy SSH host key
 47			log.Info("Copying SSH host key...")
 48			if keyPath != "" {
 49				if err := os.MkdirAll(filepath.Join(cfg.DataPath, "ssh"), 0700); err != nil {
 50					return fmt.Errorf("failed to create ssh directory: %w", err)
 51				}
 52
 53				if err := copyFile(keyPath, filepath.Join(cfg.DataPath, "ssh", filepath.Base(keyPath))); err != nil {
 54					return fmt.Errorf("failed to copy ssh key: %w", err)
 55				}
 56
 57				if err := copyFile(keyPath+".pub", filepath.Join(cfg.DataPath, "ssh", filepath.Base(keyPath))+".pub"); err != nil {
 58					log.Errorf("failed to copy ssh key: %s", err)
 59				}
 60
 61				cfg.SSH.KeyPath = filepath.Join(cfg.DataPath, "ssh", filepath.Base(keyPath))
 62			}
 63
 64			// Read config
 65			log.Info("Reading config repository...")
 66			r, err := git.Open(filepath.Join(reposPath, "config"))
 67			if err != nil {
 68				return fmt.Errorf("failed to open config repo: %w", err)
 69			}
 70
 71			head, err := r.HEAD()
 72			if err != nil {
 73				return fmt.Errorf("failed to get head: %w", err)
 74			}
 75
 76			tree, err := r.TreePath(head, "")
 77			if err != nil {
 78				return fmt.Errorf("failed to get tree: %w", err)
 79			}
 80
 81			isJson := false // nolint: revive
 82			te, err := tree.TreeEntry("config.yaml")
 83			if err != nil {
 84				te, err = tree.TreeEntry("config.json")
 85				if err != nil {
 86					return fmt.Errorf("failed to get config file: %w", err)
 87				}
 88				isJson = true
 89			}
 90
 91			cc, err := te.Contents()
 92			if err != nil {
 93				return fmt.Errorf("failed to get config contents: %w", err)
 94			}
 95
 96			var ocfg Config
 97			if isJson {
 98				if err := json.Unmarshal(cc, &ocfg); err != nil {
 99					return fmt.Errorf("failed to unmarshal config: %w", err)
100				}
101			} else {
102				if err := yaml.Unmarshal(cc, &ocfg); err != nil {
103					return fmt.Errorf("failed to unmarshal config: %w", err)
104				}
105			}
106
107			// Set server name
108			cfg.Name = ocfg.Name
109
110			// Set server public url
111			cfg.SSH.PublicURL = fmt.Sprintf("ssh://%s:%d", ocfg.Host, ocfg.Port)
112
113			// Set server settings
114			log.Info("Setting server settings...")
115			if cfg.Backend.SetAllowKeyless(ocfg.AllowKeyless) != nil {
116				fmt.Fprintf(os.Stderr, "failed to set allow keyless\n")
117			}
118			anon := backend.ParseAccessLevel(ocfg.AnonAccess)
119			if anon >= 0 {
120				if err := sb.SetAnonAccess(anon); err != nil {
121					fmt.Fprintf(os.Stderr, "failed to set anon access: %s\n", err)
122				}
123			}
124
125			// Copy repos
126			if reposPath != "" {
127				log.Info("Copying repos...")
128				dirs, err := os.ReadDir(reposPath)
129				if err != nil {
130					return fmt.Errorf("failed to read repos directory: %w", err)
131				}
132
133				for _, dir := range dirs {
134					if !dir.IsDir() {
135						continue
136					}
137
138					if !isGitDir(filepath.Join(reposPath, dir.Name())) {
139						continue
140					}
141
142					log.Infof("  Copying repo %s", dir.Name())
143					if err := os.MkdirAll(filepath.Join(cfg.DataPath, "repos"), 0700); err != nil {
144						return fmt.Errorf("failed to create repos directory: %w", err)
145					}
146
147					src := utils.SanitizeRepo(filepath.Join(reposPath, dir.Name()))
148					dst := utils.SanitizeRepo(filepath.Join(cfg.DataPath, "repos", dir.Name())) + ".git"
149					if err := copyDir(src, dst); err != nil {
150						return fmt.Errorf("failed to copy repo: %w", err)
151					}
152
153					if _, err := sb.CreateRepository(dir.Name(), backend.RepositoryOptions{}); err != nil {
154						fmt.Fprintf(os.Stderr, "failed to create repository: %s\n", err)
155					}
156				}
157			}
158
159			// Set repos metadata & collabs
160			log.Info("Setting repos metadata & collabs...")
161			for _, repo := range ocfg.Repos {
162				if err := sb.SetProjectName(repo.Repo, repo.Name); err != nil {
163					log.Errorf("failed to set repo name to %s: %s", repo.Repo, err)
164				}
165
166				if err := sb.SetDescription(repo.Repo, repo.Note); err != nil {
167					log.Errorf("failed to set repo description to %s: %s", repo.Repo, err)
168				}
169
170				if err := sb.SetPrivate(repo.Repo, repo.Private); err != nil {
171					log.Errorf("failed to set repo private to %s: %s", repo.Repo, err)
172				}
173
174				for _, collab := range repo.Collabs {
175					if err := sb.AddCollaborator(repo.Repo, collab); err != nil {
176						log.Errorf("failed to add repo collab to %s: %s", repo.Repo, err)
177					}
178				}
179			}
180
181			// Create users & collabs
182			log.Info("Creating users & collabs...")
183			for _, user := range ocfg.Users {
184				keys := make(map[string]ssh.PublicKey)
185				for _, key := range user.PublicKeys {
186					pk, _, err := backend.ParseAuthorizedKey(key)
187					if err != nil {
188						continue
189					}
190					ak := backend.MarshalAuthorizedKey(pk)
191					keys[ak] = pk
192				}
193
194				pubkeys := make([]ssh.PublicKey, 0)
195				for _, pk := range keys {
196					pubkeys = append(pubkeys, pk)
197				}
198
199				username := strings.ToLower(user.Name)
200				username = strings.ReplaceAll(username, " ", "-")
201				log.Infof("Creating user %q", username)
202				if _, err := sb.CreateUser(username, backend.UserOptions{
203					Admin:      user.Admin,
204					PublicKeys: pubkeys,
205				}); err != nil {
206					log.Errorf("failed to create user: %s", err)
207				}
208
209				for _, repo := range user.CollabRepos {
210					if err := sb.AddCollaborator(repo, username); err != nil {
211						log.Errorf("failed to add user collab to %s: %s\n", repo, err)
212					}
213				}
214			}
215
216			log.Info("Writing config...")
217			defer log.Info("Done!")
218			return config.WriteConfig(filepath.Join(cfg.DataPath, "config.yaml"), cfg)
219		},
220	}
221)
222
223// Returns true if path is a directory containing an `objects` directory and a
224// `HEAD` file.
225func isGitDir(path string) bool {
226	stat, err := os.Stat(filepath.Join(path, "objects"))
227	if err != nil {
228		return false
229	}
230	if !stat.IsDir() {
231		return false
232	}
233
234	stat, err = os.Stat(filepath.Join(path, "HEAD"))
235	if err != nil {
236		return false
237	}
238	if stat.IsDir() {
239		return false
240	}
241
242	return true
243}
244
245// copyFile copies a single file from src to dst.
246func copyFile(src, dst string) error {
247	var err error
248	var srcfd *os.File
249	var dstfd *os.File
250	var srcinfo os.FileInfo
251
252	if srcfd, err = os.Open(src); err != nil {
253		return err
254	}
255	defer srcfd.Close() // nolint: errcheck
256
257	if dstfd, err = os.Create(dst); err != nil {
258		return err
259	}
260	defer dstfd.Close() // nolint: errcheck
261
262	if _, err = io.Copy(dstfd, srcfd); err != nil {
263		return err
264	}
265	if srcinfo, err = os.Stat(src); err != nil {
266		return err
267	}
268	return os.Chmod(dst, srcinfo.Mode())
269}
270
271// copyDir copies a whole directory recursively.
272func copyDir(src string, dst string) error {
273	var err error
274	var fds []os.DirEntry
275	var srcinfo os.FileInfo
276
277	if srcinfo, err = os.Stat(src); err != nil {
278		return err
279	}
280
281	if err = os.MkdirAll(dst, srcinfo.Mode()); err != nil {
282		return err
283	}
284
285	if fds, err = os.ReadDir(src); err != nil {
286		return err
287	}
288	for _, fd := range fds {
289		srcfp := filepath.Join(src, fd.Name())
290		dstfp := filepath.Join(dst, fd.Name())
291
292		if fd.IsDir() {
293			if err = copyDir(srcfp, dstfp); err != nil {
294				fmt.Println(err)
295			}
296		} else {
297			if err = copyFile(srcfp, dstfp); err != nil {
298				fmt.Println(err)
299			}
300		}
301	}
302	return nil
303}
304
305// Config is the configuration for the server.
306type Config struct {
307	Name         string       `yaml:"name" json:"name"`
308	Host         string       `yaml:"host" json:"host"`
309	Port         int          `yaml:"port" json:"port"`
310	AnonAccess   string       `yaml:"anon-access" json:"anon-access"`
311	AllowKeyless bool         `yaml:"allow-keyless" json:"allow-keyless"`
312	Users        []User       `yaml:"users" json:"users"`
313	Repos        []RepoConfig `yaml:"repos" json:"repos"`
314}
315
316// User contains user-level configuration for a repository.
317type User struct {
318	Name        string   `yaml:"name" json:"name"`
319	Admin       bool     `yaml:"admin" json:"admin"`
320	PublicKeys  []string `yaml:"public-keys" json:"public-keys"`
321	CollabRepos []string `yaml:"collab-repos" json:"collab-repos"`
322}
323
324// RepoConfig is a repository configuration.
325type RepoConfig struct {
326	Name    string   `yaml:"name" json:"name"`
327	Repo    string   `yaml:"repo" json:"repo"`
328	Note    string   `yaml:"note" json:"note"`
329	Private bool     `yaml:"private" json:"private"`
330	Readme  string   `yaml:"readme" json:"readme"`
331	Collabs []string `yaml:"collabs" json:"collabs"`
332}