fix: reconcile generated bootstrap state

This commit is contained in:
eding 2026-07-26 20:56:34 +02:00
parent 8ca8e88ca0
commit d9707a1f85
5 changed files with 100 additions and 0 deletions

View file

@ -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")

View file

@ -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"`

View file

@ -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) {

View file

@ -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)
}

View file

@ -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)