Federated via graft
7.4 kB
198 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 // PublicHost is the hostname graft is reachable at (e.g.
21 // graft.cyberwild.org), used to build ActivityPub actor/acct URIs.
22 // Required only if any series should be reachable over ActivityPub.
23 PublicHost string `yaml:"public_host"`
24 // AdminPassword gates the dashboard's self-service actions (adding a
25 // peer, starting a new repo) — anyone who knows it can use them.
26 // Defaults to "graft" if unset; Load logs a warning when that default
27 // is still in effect, since it's meant to be changed.
28 AdminPassword string `yaml:"admin_password"`
29}
30
31// RepoPair links one Forgejo repository to either one Radicle repository or
32// a second Forgejo repository, and declares which content types are kept in
33// sync between them. Exactly one of Radicle and ForgejoMirror is set: the
34// Radicle case is the original, fully-featured pairing (git, issues,
35// patches); the ForgejoMirror case is a plain Forgejo-to-Forgejo git mirror
36// for series that have no Radicle side at all — issues and patches aren't
37// supported there yet, since that mirroring logic is written entirely in
38// terms of a Radicle client (see internal/sync/issues.go, patches.go).
39type RepoPair struct {
40 Name string `yaml:"name"`
41 Forgejo ForgejoTarget `yaml:"forgejo"`
42 Radicle *RadicleTarget `yaml:"radicle"`
43 ForgejoMirror *ForgejoTarget `yaml:"forgejo_mirror"`
44 Sync SyncScope `yaml:"sync"`
45 // Series groups this pair with others that mirror the same underlying
46 // repository (one per forge/Radicle side) under one row in the status
47 // dashboard. Defaults to Name — i.e. its own row — if unset.
48 Series string `yaml:"series"`
49 // Bluesky, if set, posts a short note to this account whenever this
50 // pair mirrors a new commit or opens a patch/PR. Optional: nil means
51 // this pair never posts to Bluesky.
52 Bluesky *BlueskyTarget `yaml:"bluesky"`
53}
54
55// BlueskyTarget identifies the Bluesky account to post mirror activity to.
56// AppPasswordFile follows the same convention as ForgejoTarget.TokenFile —
57// an app password (never the account's real password), read from a
58// separate chmod-600 file so the config itself holds no secrets.
59type BlueskyTarget struct {
60 Handle string `yaml:"handle"`
61 AppPasswordFile string `yaml:"app_password_file"`
62 // PDSHost is the XRPC endpoint to talk to. Optional: defaults to
63 // https://bsky.social, correct for any account hosted there (the
64 // common case).
65 PDSHost string `yaml:"pds_host"`
66}
67
68// ForgejoTarget identifies a repository on a Forgejo instance and where to
69// read its access token from. The token is never stored inline in the
70// config file so the file itself can be handled as non-sensitive.
71type ForgejoTarget struct {
72 BaseURL string `yaml:"base_url"`
73 Owner string `yaml:"owner"`
74 Repo string `yaml:"repo"`
75 TokenFile string `yaml:"token_file"`
76}
77
78// RadicleTarget identifies a repository on a Radicle node. HTTPBaseURL is
79// that node's radicle-httpd endpoint (e.g. https://r1.cyberwild.org), used
80// for reads; RadHome is the local RAD_HOME whose keys the "rad" CLI uses
81// for writes — the two are only the same node when the daemon runs
82// alongside it, which is not assumed here.
83type RadicleTarget struct {
84 RID string `yaml:"rid"`
85 HTTPBaseURL string `yaml:"http_base_url"`
86 RadHome string `yaml:"rad_home"`
87 // ExplorerURL is a Radicle Explorer deployment (e.g.
88 // https://app.radicle.xyz, or a self-hosted one) used to build "view
89 // this issue/patch" links in the status dashboard. Optional: links are
90 // omitted if unset.
91 ExplorerURL string `yaml:"explorer_url"`
92}
93
94// SyncScope selects which content types are synchronized for a repo pair.
95type SyncScope struct {
96 Git bool `yaml:"git"`
97 Issues bool `yaml:"issues"`
98 Patches bool `yaml:"patches"`
99}
100
101// Load reads and validates the configuration file at path.
102func Load(path string) (*Config, error) {
103 data, err := os.ReadFile(path)
104 if err != nil {
105 return nil, fmt.Errorf("read config: %w", err)
106 }
107
108 var cfg Config
109 if err := yaml.Unmarshal(data, &cfg); err != nil {
110 return nil, fmt.Errorf("parse config: %w", err)
111 }
112
113 if cfg.SyncInterval <= 0 {
114 cfg.SyncInterval = 5 * time.Minute
115 }
116 if cfg.AdminPassword == "" {
117 cfg.AdminPassword = "graft"
118 }
119 if cfg.StateDB == "" {
120 return nil, fmt.Errorf("state_db must be set")
121 }
122 if len(cfg.Repos) == 0 {
123 return nil, fmt.Errorf("no repos configured")
124 }
125 for i, r := range cfg.Repos {
126 if err := r.validate(); err != nil {
127 return nil, fmt.Errorf("repos[%d] (%s): %w", i, r.Name, err)
128 }
129 }
130
131 return &cfg, nil
132}
133
134func (r RepoPair) validate() error {
135 if r.Name == "" {
136 return fmt.Errorf("name is required")
137 }
138 if r.Forgejo.BaseURL == "" || r.Forgejo.Owner == "" || r.Forgejo.Repo == "" {
139 return fmt.Errorf("forgejo.base_url, owner and repo are required")
140 }
141 if r.Forgejo.TokenFile == "" {
142 return fmt.Errorf("forgejo.token_file is required")
143 }
144 if r.Radicle == nil && r.ForgejoMirror == nil {
145 return fmt.Errorf("exactly one of radicle or forgejo_mirror is required")
146 }
147 if r.Radicle != nil && r.ForgejoMirror != nil {
148 return fmt.Errorf("radicle and forgejo_mirror are mutually exclusive")
149 }
150 if r.Radicle != nil {
151 if r.Radicle.RID == "" {
152 return fmt.Errorf("radicle.rid is required")
153 }
154 if r.Radicle.HTTPBaseURL == "" {
155 return fmt.Errorf("radicle.http_base_url is required")
156 }
157 if r.Radicle.RadHome == "" {
158 return fmt.Errorf("radicle.rad_home is required")
159 }
160 }
161 if r.ForgejoMirror != nil {
162 if r.ForgejoMirror.BaseURL == "" || r.ForgejoMirror.Owner == "" || r.ForgejoMirror.Repo == "" {
163 return fmt.Errorf("forgejo_mirror.base_url, owner and repo are required")
164 }
165 if r.ForgejoMirror.TokenFile == "" {
166 return fmt.Errorf("forgejo_mirror.token_file is required")
167 }
168 if r.Sync.Issues || r.Sync.Patches {
169 return fmt.Errorf("forgejo_mirror pairs only support sync.git — issues/patches mirroring needs a Radicle side")
170 }
171 }
172 if !r.Sync.Git && !r.Sync.Issues && !r.Sync.Patches {
173 return fmt.Errorf("at least one of sync.git, sync.issues, sync.patches must be true")
174 }
175 if r.Bluesky != nil {
176 if r.Bluesky.Handle == "" || r.Bluesky.AppPasswordFile == "" {
177 return fmt.Errorf("bluesky.handle and app_password_file are required when bluesky is set")
178 }
179 }
180 return nil
181}
182
183// ReadToken reads a token from a file, trimming surrounding whitespace.
184// Token files are expected to be chmod 600 and owned by the service user.
185func ReadToken(path string) (string, error) {
186 data, err := os.ReadFile(path)
187 if err != nil {
188 return "", fmt.Errorf("read token file %s: %w", path, err)
189 }
190 token := string(data)
191 for len(token) > 0 && (token[len(token)-1] == '\n' || token[len(token)-1] == '\r' || token[len(token)-1] == ' ') {
192 token = token[:len(token)-1]
193 }
194 if token == "" {
195 return "", fmt.Errorf("token file %s is empty", path)
196 }
197 return token, nil
198}