// SPDX-FileCopyrightText: 2020 Luke Granger-Brown // // SPDX-License-Identifier: Apache-2.0 package main import ( "context" "encoding/json" "flag" "fmt" "io/ioutil" "net/http" "net/url" "os" "path" "sort" "strings" log "github.com/golang/glog" "github.com/google/go-querystring/query" ) var ( gitlabAccessToken = flag.String("gitlab_access_token", os.Getenv("GITLAB_ACCESS_TOKEN"), "GitLab API Access Token") gitlabEndpoint = flag.String("gitlab_endpoint", "https://hg.lukegb.com", "GitLab API endpoint") gitlabProject = flag.String("gitlab_project", "lukegb/depot", "Project to sync secrets into") manifestVariable = flag.String("manifest_variable", "", "If non-empty, a variable to populate with the path to a [VARIABLE]=[FILENAME] manifest, separated by newlines.") varToFileMap map[string]string ) func init() { flag.Var(stringMapVar{&varToFileMap}, "variable_to_file", "[VARIABLE]=[FILENAME] mapping") } type stringMapVar struct { dst *map[string]string } func (v stringMapVar) String() string { if v.dst == nil || *v.dst == nil { return "nil" } return fmt.Sprintf("%#v", *v.dst) } func (v stringMapVar) Set(s string) error { if *v.dst == nil { *v.dst = make(map[string]string) } bits := strings.SplitN(s, "=", 2) if len(bits) != 2 { return fmt.Errorf("not a KEY=VALUE formatted: %v", s) } (*v.dst)[bits[0]] = bits[1] return nil } // GitLabAPI encapsulates access to the GitLab API. type GitLabAPI struct { Client *http.Client AccessToken string Endpoint string } func (gl *GitLabAPI) url(subpath string) (string, error) { u, err := url.Parse(gl.Endpoint) if err != nil { return "", fmt.Errorf("parsing endpoint %q: %w", gl.Endpoint, err) } pv, err := url.PathUnescape(subpath) if err != nil { return "", fmt.Errorf("unescaping %q: %w", subpath, err) } u.Path = path.Join(u.Path, pv) u.RawPath = path.Join(u.RawPath, subpath) return u.String(), nil } func (gl *GitLabAPI) do(ctx context.Context, req *http.Request) (*http.Response, error) { req = req.WithContext(ctx) if gl.AccessToken != "" { req.Header.Set("Authorization", "Bearer "+gl.AccessToken) } resp, err := gl.Client.Do(req) if err != nil { return nil, err } if resp.StatusCode >= http.StatusBadRequest { resp.Body.Close() return nil, fmt.Errorf("%v %v failed (status %v)", req.Method, req.URL, resp.Status) } return resp, nil } func (gl *GitLabAPI) get(ctx context.Context, path string) (*http.Response, error) { u, err := gl.url(path) if err != nil { return nil, err } req, err := http.NewRequest("GET", u, nil) if err != nil { return nil, fmt.Errorf("building GET request for %q: %w", u, err) } return gl.do(ctx, req) } func (gl *GitLabAPI) doWithForm(ctx context.Context, method, path string, data url.Values) (*http.Response, error) { u, err := gl.url(path) if err != nil { return nil, err } req, err := http.NewRequest(method, u, strings.NewReader(data.Encode())) if err != nil { return nil, fmt.Errorf("building %v request for %q: %w", method, u, err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") return gl.do(ctx, req) } // Ping just tests to see if the connection is successful by retrieving the projects endpoint. // The response data is ignored, but the status code is checked. func (gl *GitLabAPI) Ping(ctx context.Context) error { resp, err := gl.get(ctx, "/api/v4/projects") if err != nil { return err } resp.Body.Close() return nil } type ProjectVariable struct { Key string `json:"key" url:"key"` VariableType string `json:"variable_type" url:"variable_type"` Value string `json:"value" url:"value"` Protected bool `json:"protected" url:"protected"` Masked bool `json:"masked" url:"masked"` } func (v ProjectVariable) EncodedValue() string { return strings.ReplaceAll(v.Value, "$", "$$") } func (v ProjectVariable) Values() url.Values { data, err := query.Values(v) if err != nil { panic("query.Values failed unexpectedly") } data.Set("value", v.EncodedValue()) return data } // Variables gets the list of variables from GitLab. func (gl *GitLabAPI) Variables(ctx context.Context, project string) (map[string]ProjectVariable, error) { resp, err := gl.get(ctx, fmt.Sprintf("/api/v4/projects/%s/variables", url.PathEscape(project))) if err != nil { return nil, err } defer resp.Body.Close() var pvs []ProjectVariable if err := json.NewDecoder(resp.Body).Decode(&pvs); err != nil { return nil, fmt.Errorf("decoding GitLab API response: %w", err) } out := make(map[string]ProjectVariable, len(pvs)) for _, s := range pvs { s.Value = strings.ReplaceAll(s.Value, "$$", "$") out[s.Key] = s } return out, nil } // CreateVariable creates a new project variable. func (gl *GitLabAPI) CreateVariable(ctx context.Context, project string, varData ProjectVariable) error { resp, err := gl.doWithForm(ctx, "POST", fmt.Sprintf("/api/v4/projects/%s/variables", url.PathEscape(project)), varData.Values()) if err != nil { return err } if _, err := ioutil.ReadAll(resp.Body); err != nil { return fmt.Errorf("reading response body to completion: %w", err) } resp.Body.Close() return nil } // UpdateVariable updates a project variable. func (gl *GitLabAPI) UpdateVariable(ctx context.Context, project string, varData ProjectVariable) error { resp, err := gl.doWithForm(ctx, "PUT", fmt.Sprintf("/api/v4/projects/%s/variables/%s", url.PathEscape(project), url.PathEscape(varData.Key)), varData.Values()) if err != nil { return err } if _, err := ioutil.ReadAll(resp.Body); err != nil { return fmt.Errorf("reading response body to completion: %w", err) } resp.Body.Close() return nil } func main() { flag.Parse() defer log.Flush() ctx := context.Background() gitlab := &GitLabAPI{ Client: http.DefaultClient, AccessToken: *gitlabAccessToken, Endpoint: *gitlabEndpoint, } if err := gitlab.Ping(ctx); err != nil { log.Exitf("gitlab.Ping: %v", err) } vars, err := gitlab.Variables(ctx, *gitlabProject) if err != nil { log.Exitf("gitlab.Variables: %v", err) } createOrReplaceVariable := func(varFuture ProjectVariable) error { varCurrent, ok := vars[varFuture.Key] switch { case !ok: log.Infof("%q needs to be created", varFuture.Key) return gitlab.CreateVariable(ctx, *gitlabProject, varFuture) case varCurrent.Value == varFuture.Value: log.Infof("%q up-to-date", varFuture.Key) return nil default: log.Infof("%q needs to be updated", varFuture.Key) return gitlab.UpdateVariable(ctx, *gitlabProject, varFuture) } } for varKey, varSource := range varToFileMap { varFutureData, err := ioutil.ReadFile(varSource) if err != nil { log.Exitf("reading secret source file %q: %v", varSource, err) } if err := createOrReplaceVariable(ProjectVariable{ Key: varKey, VariableType: "file", Value: string(varFutureData), Protected: true, }); err != nil { log.Errorf("createOrReplaceVariable(%q, %q): %v", *gitlabProject, varKey, err) } } if *manifestVariable != "" { var manifestData []string for k, v := range varToFileMap { manifestData = append(manifestData, fmt.Sprintf("%s=%s", k, v)) } sort.Strings(manifestData) if err := createOrReplaceVariable(ProjectVariable{ Key: *manifestVariable, VariableType: "file", Value: strings.Join(manifestData, "\n") + "\n", Protected: true, }); err != nil { log.Errorf("createOrReplaceVariable(%q, %q): %v", *gitlabProject, *manifestVariable, err) } } }