1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 package appconfig
20
21 import (
22 "context"
23 "fmt"
24 "io/ioutil"
25 "net/http"
26 "strings"
27
28 "github.com/google/go-github/v47/github"
29 "github.com/pkg/errors"
30 "github.com/rs/zerolog"
31 )
32
33
34
35
36 type RemoteRefParser func(path string, b []byte) (*RemoteRef, error)
37
38
39 type RemoteRef struct {
40
41 Remote string `yaml:"remote" json:"remote"`
42
43
44
45 Path string `yaml:"path" json:"path"`
46
47
48
49 Ref string `yaml:"ref" json:"ref"`
50 }
51
52 func (r RemoteRef) SplitRemote() (owner, repo string, err error) {
53 slash := strings.IndexByte(r.Remote, '/')
54 if slash <= 0 || slash >= len(r.Remote)-1 {
55 return "", "", errors.Errorf("invalid remote value: %s", r.Remote)
56 }
57 return r.Remote[:slash], r.Remote[slash+1:], nil
58 }
59
60
61 type Config struct {
62 Content []byte
63
64
65
66 Source string
67 Path string
68 IsRemote bool
69 }
70
71
72
73 func (c Config) IsUndefined() bool {
74 return len(c.Content) == 0 && c.Source == "" && c.Path == ""
75 }
76
77
78 type Loader struct {
79 paths []string
80
81 parser RemoteRefParser
82 defaultRepo string
83 defaultPaths []string
84 }
85
86
87 func NewLoader(paths []string, opts ...Option) *Loader {
88 defaultPaths := make([]string, len(paths))
89 for i, p := range paths {
90 defaultPaths[i] = strings.TrimPrefix(p, ".github/")
91 }
92
93 ld := Loader{
94 paths: paths,
95 parser: YAMLRemoteRefParser,
96 defaultRepo: ".github",
97 defaultPaths: defaultPaths,
98 }
99
100 for _, opt := range opts {
101 opt(&ld)
102 }
103
104 return &ld
105 }
106
107
108
109
110
111
112
113
114
115 func (ld *Loader) LoadConfig(ctx context.Context, client *github.Client, owner, repo, ref string) (Config, error) {
116 logger := zerolog.Ctx(ctx)
117
118 c := Config{
119 Source: fmt.Sprintf("%s/%s@%s", owner, repo, ref),
120 }
121
122 for _, p := range ld.paths {
123 c.Path = p
124
125 logger.Debug().Msgf("Trying configuration at %s in %s", c.Path, c.Source)
126 content, exists, err := getFileContents(ctx, client, owner, repo, ref, p)
127 if err != nil {
128 return c, err
129 }
130 if !exists {
131 continue
132 }
133
134
135 if ld.parser != nil {
136 remote, err := ld.parser(p, content)
137 if err != nil {
138 return c, err
139 }
140 if remote != nil {
141 logger.Debug().Msgf("Found remote configuration at %s in %s", p, c.Source)
142 return ld.loadRemoteConfig(ctx, client, *remote, c)
143 }
144 }
145
146
147 logger.Debug().Msgf("Found configuration at %s in %s", c.Path, c.Source)
148 c.Content = content
149 return c, nil
150 }
151
152
153
154 if ld.defaultRepo != "" && len(ld.defaultPaths) > 0 {
155 return ld.loadDefaultConfig(ctx, client, owner)
156 }
157
158
159 return Config{}, nil
160 }
161
162 func (ld *Loader) loadRemoteConfig(ctx context.Context, client *github.Client, remote RemoteRef, c Config) (Config, error) {
163 logger := zerolog.Ctx(ctx)
164 notFoundErr := fmt.Errorf("invalid remote reference: file does not exist")
165
166 owner, repo, err := remote.SplitRemote()
167 if err != nil {
168 return c, err
169 }
170
171 path := remote.Path
172 if path == "" && len(ld.paths) > 0 {
173 path = ld.paths[0]
174 }
175
176
177
178 c.Source = fmt.Sprintf("%s/%s", owner, repo)
179 c.Path = path
180 c.IsRemote = true
181
182 ref := remote.Ref
183 if ref == "" {
184
185
186
187 r, _, err := client.Repositories.Get(ctx, owner, repo)
188 if err != nil {
189 if isNotFound(err) {
190 return c, notFoundErr
191 }
192 return c, errors.Wrap(err, "failed to get remote repository")
193 }
194 ref = r.GetDefaultBranch()
195 }
196 c.Source = fmt.Sprintf("%s@%s", c.Source, ref)
197
198 logger.Debug().Msgf("Trying remote configuration at %s in %s", c.Path, c.Source)
199 content, exists, err := getFileContents(ctx, client, owner, repo, ref, c.Path)
200 if err != nil {
201 return c, err
202 }
203 if !exists {
204
205
206
207
208 return c, notFoundErr
209 }
210
211 c.Content = content
212 return c, nil
213 }
214
215 func (ld *Loader) loadDefaultConfig(ctx context.Context, client *github.Client, owner string) (Config, error) {
216 logger := zerolog.Ctx(ctx)
217
218 r, _, err := client.Repositories.Get(ctx, owner, ld.defaultRepo)
219 if err != nil {
220 if isNotFound(err) {
221
222 return Config{}, nil
223 }
224 c := Config{Source: fmt.Sprintf("%s/%s", owner, ld.defaultRepo)}
225 return c, errors.Wrap(err, "failed to get default repository")
226 }
227
228 ref := r.GetDefaultBranch()
229 c := Config{
230 Source: fmt.Sprintf("%s/%s@%s", owner, r.GetName(), ref),
231 }
232
233 for _, p := range ld.defaultPaths {
234 c.Path = p
235
236 logger.Debug().Msgf("Trying default configuration at %s in %s", c.Path, c.Source)
237 content, exists, err := getFileContents(ctx, client, owner, r.GetName(), ref, p)
238 if err != nil {
239 return c, err
240 }
241 if !exists {
242 continue
243 }
244
245
246 if ld.parser != nil {
247 remote, err := ld.parser(p, content)
248 if err != nil {
249 return c, err
250 }
251 if remote != nil {
252 logger.Debug().Msgf("Found remote default configuration at %s in %s", p, c.Source)
253 return ld.loadRemoteConfig(ctx, client, *remote, c)
254 }
255 }
256
257
258 logger.Debug().Msgf("Found default configuration at %s in %s", c.Path, c.Source)
259 c.Content = content
260 return c, nil
261 }
262
263
264 return Config{}, nil
265 }
266
267
268
269 func getFileContents(ctx context.Context, client *github.Client, owner, repo, ref, path string) ([]byte, bool, error) {
270 file, _, _, err := client.Repositories.GetContents(ctx, owner, repo, path, &github.RepositoryContentGetOptions{
271 Ref: ref,
272 })
273 if err != nil {
274 switch {
275 case isNotFound(err):
276 return nil, false, nil
277 case isTooLargeError(err):
278 b, err := getLargeFileContents(ctx, client, owner, repo, ref, path)
279 return b, true, err
280 }
281 return nil, false, errors.Wrap(err, "failed to read file")
282 }
283
284
285 if file == nil {
286 return nil, false, nil
287 }
288
289 content, err := file.GetContent()
290 if err != nil {
291 return nil, true, errors.Wrap(err, "failed to decode file content")
292 }
293
294 return []byte(content), true, nil
295 }
296
297
298
299
300 func getLargeFileContents(ctx context.Context, client *github.Client, owner, repo, ref, path string) ([]byte, error) {
301 body, res, err := client.Repositories.DownloadContents(ctx, owner, repo, path, &github.RepositoryContentGetOptions{
302 Ref: ref,
303 })
304 if err != nil {
305 return nil, errors.Wrap(err, "failed to read file")
306 }
307 defer func() {
308 _ = body.Close()
309 }()
310
311 if res.StatusCode != http.StatusOK {
312 return nil, errors.Errorf("failed to read file: unexpected status code %d", res.StatusCode)
313 }
314
315 b, err := ioutil.ReadAll(body)
316 if err != nil {
317 return nil, errors.Wrap(err, "failed to read file")
318 }
319 return b, nil
320 }
321
322 func isNotFound(err error) bool {
323 if rerr, ok := err.(*github.ErrorResponse); ok {
324 return rerr.Response.StatusCode == http.StatusNotFound
325 }
326 return false
327 }
328
329 func isTooLargeError(err error) bool {
330 if rerr, ok := err.(*github.ErrorResponse); ok {
331 for _, err := range rerr.Errors {
332 if err.Code == "too_large" {
333 return true
334 }
335 }
336 }
337 return false
338 }
339
View as plain text