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