Luke Granger-Brown
823eab4775
GitLab doesn't treat $ as literals in file content, which means that it tries to do variable interpolation. This is bad and annoying, because e.g. password hashes tend to contain $, so they get variable-interpolated and thus corrupted. Fix this by escaping $ on input to GitLab.
261 lines
7.3 KiB
Go
261 lines
7.3 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|