fix: reconcile generated bootstrap state
This commit is contained in:
parent
8ca8e88ca0
commit
d9707a1f85
|
|
@ -19,6 +19,7 @@ var bootstrapPromptDemocraticCSI bool
|
||||||
var bootstrapPromptOperationalSecrets bool
|
var bootstrapPromptOperationalSecrets bool
|
||||||
var bootstrapInitializeOpenBaoRecovery bool
|
var bootstrapInitializeOpenBaoRecovery bool
|
||||||
var bootstrapPublishAppFrom string
|
var bootstrapPublishAppFrom string
|
||||||
|
var bootstrapMergeBootstrapPR bool
|
||||||
|
|
||||||
var bootstrapCmd = &cobra.Command{
|
var bootstrapCmd = &cobra.Command{
|
||||||
Use: "bootstrap",
|
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(&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().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().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 {
|
func runBootstrap(cmd *cobra.Command, args []string) error {
|
||||||
var cfg config.Config
|
var cfg config.Config
|
||||||
var err error
|
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 bootstrapPublishAppFrom != "" {
|
||||||
if bootstrapConfigPath == "" {
|
if bootstrapConfigPath == "" {
|
||||||
return fmt.Errorf("--publish-app-from requires --config")
|
return fmt.Errorf("--publish-app-from requires --config")
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -251,6 +252,9 @@ func writeDemocraticCSISecret(path string, csi config.DemocraticCSIConfig, ageKe
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if existing, err := decryptSOPSFile(path, ageKeyPath); err == nil && sameYAML(existing, plaintext) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return writeSOPSEncryptedFile(path, ageKeyPath, plaintext)
|
return writeSOPSEncryptedFile(path, ageKeyPath, plaintext)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -289,6 +293,18 @@ func writeSOPSEncryptedFile(path, ageKeyPath string, plaintext []byte) error {
|
||||||
return os.WriteFile(path, encrypted, 0600)
|
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) {
|
func renderDemocraticCSISecret(csi config.DemocraticCSIConfig) ([]byte, error) {
|
||||||
return yaml.Marshal(struct {
|
return yaml.Marshal(struct {
|
||||||
APIVersion string `yaml:"apiVersion"`
|
APIVersion string `yaml:"apiVersion"`
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,16 @@ func TestWriteDemocraticCSISecretEncryptsValues(t *testing.T) {
|
||||||
if strings.Contains(string(encrypted), csi.TrueNASAPIKey) || !strings.Contains(string(encrypted), "sops:") {
|
if strings.Contains(string(encrypted), csi.TrueNASAPIKey) || !strings.Contains(string(encrypted), "sops:") {
|
||||||
t.Fatal("Democratic CSI secret was not SOPS encrypted")
|
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) {
|
func TestNewWebhookAuthorization(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,14 @@ type pullRequestRequest struct {
|
||||||
Base string `json:"base"`
|
Base string `json:"base"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type pullRequest struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type mergePullRequestRequest struct {
|
||||||
|
Do string `json:"Do"`
|
||||||
|
}
|
||||||
|
|
||||||
type hook struct {
|
type hook struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
|
|
@ -193,6 +201,31 @@ func (rm *RepoManager) CreatePullRequest(repo, title, head, base string) error {
|
||||||
return nil
|
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 {
|
func (rm *RepoManager) PushBranch(dir, repoURL, branch string) error {
|
||||||
return rm.PushRef(dir, repoURL, "HEAD", branch)
|
return rm.PushRef(dir, repoURL, "HEAD", branch)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestRepoExistsReturnsFalseOnNotFound(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
writer.WriteHeader(http.StatusNotFound)
|
writer.WriteHeader(http.StatusNotFound)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue