diff --git a/cmd/bootstrap.go b/cmd/bootstrap.go index 010c43c..e4e7aa5 100644 --- a/cmd/bootstrap.go +++ b/cmd/bootstrap.go @@ -19,6 +19,7 @@ var bootstrapPromptDemocraticCSI bool var bootstrapPromptOperationalSecrets bool var bootstrapInitializeOpenBaoRecovery bool var bootstrapPublishAppFrom string +var bootstrapMergeBootstrapPR bool var bootstrapCmd = &cobra.Command{ Use: "bootstrap", @@ -36,11 +37,25 @@ func init() { bootstrapCmd.Flags().BoolVar(&bootstrapPromptOperationalSecrets, "prompt-operational-secrets", false, "Prompt for and encrypt operational secrets for --config") bootstrapCmd.Flags().BoolVar(&bootstrapInitializeOpenBaoRecovery, "initialize-openbao-recovery", false, "Create and save a separate OpenBao recovery age identity for --config") bootstrapCmd.Flags().StringVar(&bootstrapPublishAppFrom, "publish-app-from", "", "Push this app checkout's current branch and create a Forgejo delivery PR") + bootstrapCmd.Flags().BoolVar(&bootstrapMergeBootstrapPR, "merge-bootstrap-pr", false, "Merge the generated Flux repository migration PR before bootstrapping") } func runBootstrap(cmd *cobra.Command, args []string) error { var cfg config.Config var err error + if bootstrapMergeBootstrapPR { + if bootstrapConfigPath == "" { + return fmt.Errorf("--merge-bootstrap-pr requires --config") + } + cfg, err = config.Load(bootstrapConfigPath) + if err != nil { + return err + } + manager := forgejo.NewRepoManager(cfg.Git.BaseURL, cfg.Git.Token, cfg.Git.Owner, cfg.Git.Username, "", "", cfg.Flux.Branch, "") + if err := manager.MergePullRequest(cfg.Flux.RepoName, "maidn/bootstrap-"+cfg.ClusterID); err != nil { + return err + } + } if bootstrapPublishAppFrom != "" { if bootstrapConfigPath == "" { return fmt.Errorf("--publish-app-from requires --config") diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 2b26d31..90e12c6 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -10,6 +10,7 @@ import ( "os" "os/exec" "path/filepath" + "reflect" "sort" "strings" "time" @@ -251,6 +252,9 @@ func writeDemocraticCSISecret(path string, csi config.DemocraticCSIConfig, ageKe if err != nil { return err } + if existing, err := decryptSOPSFile(path, ageKeyPath); err == nil && sameYAML(existing, plaintext) { + return nil + } return writeSOPSEncryptedFile(path, ageKeyPath, plaintext) } @@ -289,6 +293,18 @@ func writeSOPSEncryptedFile(path, ageKeyPath string, plaintext []byte) error { return os.WriteFile(path, encrypted, 0600) } +func decryptSOPSFile(path, ageKeyPath string) ([]byte, error) { + command := exec.Command("sops", "--decrypt", "--output-type", "yaml", path) + command.Env = append(os.Environ(), "SOPS_AGE_KEY_FILE="+ageKeyPath) + return command.Output() +} + +func sameYAML(left, right []byte) bool { + var leftValue any + var rightValue any + return yaml.Unmarshal(left, &leftValue) == nil && yaml.Unmarshal(right, &rightValue) == nil && reflect.DeepEqual(leftValue, rightValue) +} + func renderDemocraticCSISecret(csi config.DemocraticCSIConfig) ([]byte, error) { return yaml.Marshal(struct { APIVersion string `yaml:"apiVersion"` diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index 8a55396..22f2cf5 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -82,6 +82,16 @@ func TestWriteDemocraticCSISecretEncryptsValues(t *testing.T) { if strings.Contains(string(encrypted), csi.TrueNASAPIKey) || !strings.Contains(string(encrypted), "sops:") { t.Fatal("Democratic CSI secret was not SOPS encrypted") } + if err := writeDemocraticCSISecret(secretPath, csi, identity); err != nil { + t.Fatal(err) + } + unchanged, err := os.ReadFile(secretPath) + if err != nil { + t.Fatal(err) + } + if string(encrypted) != string(unchanged) { + t.Fatal("Democratic CSI secret was re-encrypted without a configuration change") + } } func TestNewWebhookAuthorization(t *testing.T) { diff --git a/internal/forgejo/repo.go b/internal/forgejo/repo.go index df7bbb8..840b4f0 100644 --- a/internal/forgejo/repo.go +++ b/internal/forgejo/repo.go @@ -41,6 +41,14 @@ type pullRequestRequest struct { Base string `json:"base"` } +type pullRequest struct { + Index int `json:"index"` +} + +type mergePullRequestRequest struct { + Do string `json:"Do"` +} + type hook struct { ID int64 `json:"id"` URL string `json:"url"` @@ -193,6 +201,31 @@ func (rm *RepoManager) CreatePullRequest(repo, title, head, base string) error { return nil } +func (rm *RepoManager) MergePullRequest(repo, head string) error { + values := url.Values{"state": {"open"}, "head": {head}} + endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls?%s", rm.BaseURL, rm.Owner, repo, values.Encode()) + var pullRequests []pullRequest + status, err := rm.apiJSONRequest(http.MethodGet, endpoint, nil, &pullRequests) + if err != nil { + return err + } + if status != http.StatusOK || len(pullRequests) != 1 { + return fmt.Errorf("expected one open Forgejo pull request for branch %q", head) + } + body, err := json.Marshal(mergePullRequestRequest{Do: "merge"}) + if err != nil { + return err + } + status, err = rm.apiRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/merge", rm.BaseURL, rm.Owner, repo, pullRequests[0].Index), body) + if err != nil { + return err + } + if status != http.StatusOK { + return fmt.Errorf("unexpected Forgejo pull request merge status %d", status) + } + return nil +} + func (rm *RepoManager) PushBranch(dir, repoURL, branch string) error { return rm.PushRef(dir, repoURL, "HEAD", branch) } diff --git a/internal/forgejo/repo_test.go b/internal/forgejo/repo_test.go index a9bc43d..8dd8512 100644 --- a/internal/forgejo/repo_test.go +++ b/internal/forgejo/repo_test.go @@ -128,6 +128,32 @@ func TestHasRemoteBranchReturnsFalseForMissingBranch(t *testing.T) { } } +func TestMergePullRequest(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.Method { + case http.MethodGet: + _ = json.NewEncoder(writer).Encode([]pullRequest{{Index: 4}}) + case http.MethodPost: + var body mergePullRequestRequest + if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Do != "merge" || request.URL.Path != "/api/v1/repos/owner/cluster/pulls/4/merge" { + t.Fatalf("unexpected merge request") + } + writer.WriteHeader(http.StatusOK) + default: + t.Fatalf("unexpected method %q", request.Method) + } + })) + defer server.Close() + manager := NewRepoManager(server.URL, "token", "owner", "user", "manifests", "cluster", "main", "maidn/bootstrap-test") + manager.HTTPClient = server.Client() + if err := manager.MergePullRequest("cluster", "maidn/bootstrap-test"); err != nil { + t.Fatal(err) + } +} + func TestRepoExistsReturnsFalseOnNotFound(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { writer.WriteHeader(http.StatusNotFound)