// Package admin manage repos package admin import ( "errors" "fmt" "io" "io/fs" "log" "log/slog" "os" "path/filepath" "github.com/go-git/go-billy/v5/memfs" "github.com/go-git/go-billy/v5/osfs" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/storage/filesystem" "github.com/go-git/go-git/v5/storage/memory" "gopkg.in/ini.v1" "sigs.k8s.io/yaml" ) const ( // Read mode operations for repo Read Action = 0 // Write mode operations for repo Write = 1 // Admin mode operations for repo Admin = 2 // GitExportMagic magic file name for daemon export GitExportMagic = "git-daemon-export-ok" // GitWebExportMagic magic filename for web repos GitWebExportMagic = "git-web-export-ok" mgmtRepoName = "mgmt.git" ) var ( ErrMgmtRepoNotFound = errors.New("mgmt repo does not exist") defaultRepo = &GitRepo{ Name: "mgmt", Public: false, Permissions: []*Permission{ { Role: "admin", Mode: 1, }, }, } defaultServerConfig = &ServerRepos{ Name: "default config", Version: "v1alpha1", Repos: []*GitRepo{ defaultRepo, }, } ) // Action composite type for modes type Action int // GitWeb git web configuration type GitWeb struct { Owner string `json:"owner"` Description string `json:"description"` Category string `json:"category"` URL string `json:"url"` } // Permission authorization controls type Permission struct { Role string `json:"role"` Mode Action `json:"mode"` } // GitRepo git repository type GitRepo struct { // Public "git-daemon-export-ok" magic file for git-http-backend Public bool `json:"public"` // Name game of repository Name string `json:"name"` // Web config settings GitWebConfig *GitWeb `json:"git_web_config"` // Permissions for authorization Permissions []*Permission `json:"permissions"` } // ServerRepos repos that are part of this server instance type ServerRepos struct { // Name of the configuration Name string `json:"name"` // Version of the config file Version string `json:"version"` // Repos a list of repos that are managed Repos []*GitRepo `json:"repos"` // this is set by the cli on start basePath string } func loadConfigFromGit(baseDir, filePath string) ([]byte, error) { mgmtPath := filepath.Join(baseDir, mgmtRepoName) slog.Info("loading server config", "configPath", mgmtPath) _, err := os.Stat(mgmtPath) if errors.Is(err, os.ErrNotExist) { return []byte(""), ErrMgmtRepoNotFound } else if err != nil { log.Fatalf("An unexpected error was encountered %s", err) } repoURI := fmt.Sprintf("file://%s", mgmtPath) slog.Info("mgmt repo uri", "uri", repoURI) fs := memfs.New() storer := memory.NewStorage() _, err = git.Clone(storer, fs, &git.CloneOptions{ URL: repoURI, }) if err != nil { return []byte(""), fmt.Errorf("couldn't clone mgmt repo %s %w", repoURI, err) } file, err := fs.Open(filePath) if err != nil { return []byte(""), fmt.Errorf("couldn't open file in repo %w", err) } defer file.Close() return io.ReadAll(file) } func loadLocalFile(path string) ([]byte, error) { file, err := os.Open(path) if err != nil { return []byte{}, fmt.Errorf("config file not opened or found %w", err) } defer file.Close() configBytes, err := io.ReadAll(file) if err != nil { return []byte{}, fmt.Errorf("config file not read %w", err) } return configBytes, nil } // setupPolicyFile func setupPolicyFile(src string) (string, error) { workingPolicyFile, err := os.CreateTemp("", "go-git-server-policy") if err != nil { return "", fmt.Errorf("coudn't read base policy %w", err) } defer workingPolicyFile.Close() basePolicy, err := os.ReadFile(src) if err != nil { return "", fmt.Errorf("coudn't read base policy %w", err) } written, err := workingPolicyFile.Write(basePolicy) if err != nil { return "", fmt.Errorf("encountered error writting policy %w", err) } if written == 0 { return "", fmt.Errorf("nothing was written") } return workingPolicyFile.Name(), nil } // loadServerConfig configPath should be the absolutepath to the configmap if it's not in a repo func loadServerConfig(mgmtRepo bool, baseDir, configPath string) (*ServerRepos, error) { var ( configBytes []byte err error ) if mgmtRepo { configBytes, err = loadConfigFromGit(baseDir, configPath) if err != nil { return &ServerRepos{}, err } } else { configBytes, err = loadLocalFile(configPath) if err != nil { slog.Error("Local server config couldn't be loaded") return &ServerRepos{}, err } } config := &ServerRepos{} err = yaml.Unmarshal(configBytes, &config) if err != nil { return &ServerRepos{}, errors.New("could not parse gitserver config") } return config, nil } // ServerPolicies generate casbin policies func (s *ServerRepos) ServerPolicies() [][]string { policies := [][]string{} for _, repo := range s.Repos { policies = append(policies, repo.CasbinPolicies()...) } return policies } // ConfigureRepos run reconciler for all repos func (s *ServerRepos) ConfigureRepos() error { slog.Info("configuring repos", "toBeConfigured", len(s.Repos)) var errs []error for _, repo := range s.Repos { if err := repo.ReconcileRepo(s.basePath); err != nil { slog.Error("error reconcile", "repo", repo.Name, "err", err) errs = append(errs, err) } } return errors.Join(errs...) } func readOnlyPaths(role, repoName string) [][]string { // WARN this is kind of hard coded pathing... not sure that's great return [][]string{ {role, fmt.Sprintf("/%s/info/refs", repoName), "GET"}, {role, fmt.Sprintf("/%s/git-upload-pack", repoName), "POST"}, } } func writePaths(role, repoName string) [][]string { // WARN this is kind of hard coded pathing... not sure that's great return [][]string{{role, fmt.Sprintf("/%s/git-receive-pack", repoName), "POST"}} } // Policy generate policy for repo base on mode func (p *Permission) Policy(repoName string) [][]string { policies := [][]string{} // if read mode or greater e.g. write mode roleName := fmt.Sprintf("role:%s", p.Role) if p.Mode >= Read { policies = append(policies, readOnlyPaths(roleName, repoName)...) } // if write mode if p.Mode >= Write { policies = append(policies, writePaths(roleName, repoName)...) } return policies } // CasbinPolicies generate all policies func (r *GitRepo) CasbinPolicies() [][]string { policies := [][]string{} for _, perm := range r.Permissions { policies = append(policies, perm.Policy(r.Name)...) } return policies } // ReconcileRepo update repo export settings, update web config func (r *GitRepo) ReconcileRepo(basePath string) error { // if exist -> continue repoBase := filepath.Join(basePath, fmt.Sprintf("%s.git", r.Name)) slog.Info("reconciling repo", "repoPath", repoBase) _, err := os.Stat(repoBase) if errors.Is(err, fs.ErrNotExist) { // if no exist -> init bare repoFs := osfs.New(repoBase) strg := filesystem.NewStorage(repoFs, nil) _, _ = git.Init(strg, nil) } // set export file for git-http-backend okExport := filepath.Join(repoBase, GitExportMagic) _, err = os.Stat(okExport) if errors.Is(err, fs.ErrNotExist) { // Create web export f, err := os.Create(okExport) f.Close() if err != nil { return fmt.Errorf("%s couldn't be created %w", GitExportMagic, err) } } r.ConfigureExport(repoBase) if r.GitWebConfig == nil { r.GitWebConfig = &GitWeb{} } r.GitWebConfig.ReconcileGitConf(repoBase) return nil } // ConfigureExport setup repo for sharing and configure web settings func (r *GitRepo) ConfigureExport(repoBase string) { if !r.Public { return } okExport := filepath.Join(repoBase, GitWebExportMagic) _, err := os.Create(okExport) if err != nil { log.Fatalf("%s couldn't be created %s", GitWebExportMagic, err) } } // ReconcileGitConf reconcile gitweb configuration section of gitconfig func (r *GitWeb) ReconcileGitConf(repoBase string) { confPath := filepath.Join(repoBase, "config") cfg, err := ini.Load(confPath) if err != nil { log.Fatalf("couldn't read gitconfig %s", err) } // check if empty, delete if (GitWeb{} == *r) { if cfg.HasSection("gitweb") { cfg.DeleteSection("gitweb") if err := cfg.SaveTo(confPath); err != nil { log.Fatalf("couldn't save gitconfig %s", err) } } return } section := cfg.Section("gitweb") section.Key("description").SetValue(r.Description) section.Key("owner").SetValue(r.Owner) section.Key("url").SetValue(r.URL) section.Key("category").SetValue(r.Category) if err := cfg.SaveTo(confPath); err != nil { log.Fatalf("couldn't save gitconfig %s", err) } }