Federated via graft
4.2 kB
134 lines
1// Package config loads and validates the sync daemon's configuration.
2package config
3
4import (
5 "fmt"
6 "os"
7 "time"
8
9 "gopkg.in/yaml.v3"
10)
11
12// Config is the top-level configuration for the sync daemon.
13type Config struct {
14 SyncInterval time.Duration `yaml:"sync_interval"`
15 StateDB string `yaml:"state_db"`
16 Repos []RepoPair `yaml:"repos"`
17 // SourceURL is graft's own source repository, shown in the status
18 // dashboard's footer. Optional.
19 SourceURL string `yaml:"source_url"`
20}
21
22// RepoPair links one Forgejo repository to one Radicle repository and
23// declares which content types are kept in sync between them.
24type RepoPair struct {
25 Name string `yaml:"name"`
26 Forgejo ForgejoTarget `yaml:"forgejo"`
27 Radicle RadicleTarget `yaml:"radicle"`
28 Sync SyncScope `yaml:"sync"`
29}
30
31// ForgejoTarget identifies a repository on a Forgejo instance and where to
32// read its access token from. The token is never stored inline in the
33// config file so the file itself can be handled as non-sensitive.
34type ForgejoTarget struct {
35 BaseURL string `yaml:"base_url"`
36 Owner string `yaml:"owner"`
37 Repo string `yaml:"repo"`
38 TokenFile string `yaml:"token_file"`
39}
40
41// RadicleTarget identifies a repository on a Radicle node. HTTPBaseURL is
42// that node's radicle-httpd endpoint (e.g. https://r1.cyberwild.org), used
43// for reads; RadHome is the local RAD_HOME whose keys the "rad" CLI uses
44// for writes — the two are only the same node when the daemon runs
45// alongside it, which is not assumed here.
46type RadicleTarget struct {
47 RID string `yaml:"rid"`
48 HTTPBaseURL string `yaml:"http_base_url"`
49 RadHome string `yaml:"rad_home"`
50 // ExplorerURL is a Radicle Explorer deployment (e.g.
51 // https://app.radicle.xyz, or a self-hosted one) used to build "view
52 // this issue/patch" links in the status dashboard. Optional: links are
53 // omitted if unset.
54 ExplorerURL string `yaml:"explorer_url"`
55}
56
57// SyncScope selects which content types are synchronized for a repo pair.
58type SyncScope struct {
59 Git bool `yaml:"git"`
60 Issues bool `yaml:"issues"`
61 Patches bool `yaml:"patches"`
62}
63
64// Load reads and validates the configuration file at path.
65func Load(path string) (*Config, error) {
66 data, err := os.ReadFile(path)
67 if err != nil {
68 return nil, fmt.Errorf("read config: %w", err)
69 }
70
71 var cfg Config
72 if err := yaml.Unmarshal(data, &cfg); err != nil {
73 return nil, fmt.Errorf("parse config: %w", err)
74 }
75
76 if cfg.SyncInterval <= 0 {
77 cfg.SyncInterval = 5 * time.Minute
78 }
79 if cfg.StateDB == "" {
80 return nil, fmt.Errorf("state_db must be set")
81 }
82 if len(cfg.Repos) == 0 {
83 return nil, fmt.Errorf("no repos configured")
84 }
85 for i, r := range cfg.Repos {
86 if err := r.validate(); err != nil {
87 return nil, fmt.Errorf("repos[%d] (%s): %w", i, r.Name, err)
88 }
89 }
90
91 return &cfg, nil
92}
93
94func (r RepoPair) validate() error {
95 if r.Name == "" {
96 return fmt.Errorf("name is required")
97 }
98 if r.Forgejo.BaseURL == "" || r.Forgejo.Owner == "" || r.Forgejo.Repo == "" {
99 return fmt.Errorf("forgejo.base_url, owner and repo are required")
100 }
101 if r.Forgejo.TokenFile == "" {
102 return fmt.Errorf("forgejo.token_file is required")
103 }
104 if r.Radicle.RID == "" {
105 return fmt.Errorf("radicle.rid is required")
106 }
107 if r.Radicle.HTTPBaseURL == "" {
108 return fmt.Errorf("radicle.http_base_url is required")
109 }
110 if r.Radicle.RadHome == "" {
111 return fmt.Errorf("radicle.rad_home is required")
112 }
113 if !r.Sync.Git && !r.Sync.Issues && !r.Sync.Patches {
114 return fmt.Errorf("at least one of sync.git, sync.issues, sync.patches must be true")
115 }
116 return nil
117}
118
119// ReadToken reads a token from a file, trimming surrounding whitespace.
120// Token files are expected to be chmod 600 and owned by the service user.
121func ReadToken(path string) (string, error) {
122 data, err := os.ReadFile(path)
123 if err != nil {
124 return "", fmt.Errorf("read token file %s: %w", path, err)
125 }
126 token := string(data)
127 for len(token) > 0 && (token[len(token)-1] == '\n' || token[len(token)-1] == '\r' || token[len(token)-1] == ' ') {
128 token = token[:len(token)-1]
129 }
130 if token == "" {
131 return "", fmt.Errorf("token file %s is empty", path)
132 }
133 return token, nil
134}