Compare commits

..

No commits in common. "7d8679840a450f5f9fe8b3eba3f544e64dcf2f51" and "ecd2fece594f92e6f9a538473e178aaa42eebc4b" have entirely different histories.

7 changed files with 6 additions and 349 deletions

View file

@ -18,8 +18,6 @@ var bootstrapYes bool
var bootstrapPromptDemocraticCSI bool var bootstrapPromptDemocraticCSI bool
var bootstrapPromptOperationalSecrets bool var bootstrapPromptOperationalSecrets bool
var bootstrapInitializeOpenBaoRecovery bool var bootstrapInitializeOpenBaoRecovery bool
var bootstrapInitializeOpenBao bool
var bootstrapCreateForgejoRegistryToken bool
var bootstrapPublishAppFrom string var bootstrapPublishAppFrom string
var bootstrapMergeBootstrapPR bool var bootstrapMergeBootstrapPR bool
var bootstrapManageNetworkBridges bool var bootstrapManageNetworkBridges bool
@ -39,8 +37,6 @@ func init() {
bootstrapCmd.Flags().BoolVar(&bootstrapPromptDemocraticCSI, "prompt-democratic-csi", false, "Prompt for and save Democratic CSI settings in --config") bootstrapCmd.Flags().BoolVar(&bootstrapPromptDemocraticCSI, "prompt-democratic-csi", false, "Prompt for and save Democratic CSI settings in --config")
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().BoolVar(&bootstrapInitializeOpenBao, "initialize-openbao", false, "Initialize OpenBao and seed encrypted operational secrets for --config")
bootstrapCmd.Flags().BoolVar(&bootstrapCreateForgejoRegistryToken, "create-forgejo-registry-token", false, "Create a least-privilege Forgejo package registry token and seed it through OpenBao")
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") bootstrapCmd.Flags().BoolVar(&bootstrapMergeBootstrapPR, "merge-bootstrap-pr", false, "Merge the generated Flux repository migration PR before bootstrapping")
bootstrapCmd.Flags().BoolVar(&bootstrapManageNetworkBridges, "manage-network-bridges", false, "Persist Terraform management for existing Talos network bridges") bootstrapCmd.Flags().BoolVar(&bootstrapManageNetworkBridges, "manage-network-bridges", false, "Persist Terraform management for existing Talos network bridges")
@ -49,26 +45,6 @@ func init() {
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 bootstrapCreateForgejoRegistryToken {
if bootstrapConfigPath == "" {
return fmt.Errorf("--create-forgejo-registry-token requires --config")
}
cfg, err = config.Load(bootstrapConfigPath)
if err != nil {
return err
}
return createForgejoRegistryToken(cfg)
}
if bootstrapInitializeOpenBao {
if bootstrapConfigPath == "" {
return fmt.Errorf("--initialize-openbao requires --config")
}
cfg, err = config.Load(bootstrapConfigPath)
if err != nil {
return err
}
return bootstrap.InitializeOpenBao(cfg)
}
if bootstrapMergeBootstrapPR { if bootstrapMergeBootstrapPR {
if bootstrapConfigPath == "" { if bootstrapConfigPath == "" {
return fmt.Errorf("--merge-bootstrap-pr requires --config") return fmt.Errorf("--merge-bootstrap-pr requires --config")
@ -174,28 +150,3 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
runner := bootstrap.Runner{Config: cfg, Mode: bootstrap.Mode(bootstrapMode), ConfirmRebuild: bootstrapYes} runner := bootstrap.Runner{Config: cfg, Mode: bootstrap.Mode(bootstrapMode), ConfirmRebuild: bootstrapYes}
return runner.Run() return runner.Run()
} }
func createForgejoRegistryToken(cfg config.Config) error {
if _, err := bootstrap.ReadOperationalSecrets(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath); err != nil {
return err
}
password, otp, name, err := ui.PromptForgejoRegistryToken()
if err != nil {
return err
}
token, err := forgejo.CreateRegistryToken(cfg.Git.BaseURL, cfg.Git.Username, password, otp, name)
if err != nil {
return fmt.Errorf("create Forgejo registry token: %w", err)
}
dockerConfig, err := bootstrap.ForgejoRegistryDockerConfig(cfg.Delivery.ImageRepository, cfg.Git.Username, token)
if err != nil {
return err
}
if err := bootstrap.UpsertOperationalSecret(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, "cicd/forgejo-registry", "dockerconfigjson", dockerConfig); err != nil {
return fmt.Errorf("Forgejo registry token was created but could not be saved; revoke the new token in Forgejo and retry: %w", err)
}
if err := bootstrap.InitializeOpenBao(cfg); err != nil {
return fmt.Errorf("Forgejo registry token was saved to encrypted operational secrets but OpenBao seeding failed; rerun bootstrap --config %q --initialize-openbao: %w", bootstrapConfigPath, err)
}
return nil
}

View file

@ -1,16 +0,0 @@
package cmd
import "testing"
func TestCreateForgejoRegistryTokenRequiresConfig(t *testing.T) {
originalConfigPath, originalCreate := bootstrapConfigPath, bootstrapCreateForgejoRegistryToken
defer func() {
bootstrapConfigPath = originalConfigPath
bootstrapCreateForgejoRegistryToken = originalCreate
}()
bootstrapConfigPath = ""
bootstrapCreateForgejoRegistryToken = true
if err := runBootstrap(nil, nil); err == nil {
t.Fatal("--create-forgejo-registry-token accepted a missing --config")
}
}

View file

@ -4,7 +4,6 @@ import (
"bytes" "bytes"
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@ -37,12 +36,6 @@ type Runner struct {
ConfirmRebuild bool ConfirmRebuild bool
} }
type operationalSecrets struct {
Secrets map[string]map[string]string `yaml:"secrets"`
}
var initializeOpenBao = openbao.Initialize
func (r Runner) Run() error { func (r Runner) Run() error {
resolved, err := config.Resolve(r.Config) resolved, err := config.Resolve(r.Config)
if err != nil { if err != nil {
@ -266,74 +259,13 @@ func writeDemocraticCSISecret(path string, csi config.DemocraticCSIConfig, ageKe
} }
func WriteOperationalSecrets(path, ageKeyPath string, secrets map[string]map[string]string) error { func WriteOperationalSecrets(path, ageKeyPath string, secrets map[string]map[string]string) error {
plaintext, err := yaml.Marshal(operationalSecrets{Secrets: secrets}) plaintext, err := yaml.Marshal(struct {
Secrets map[string]map[string]string `yaml:"secrets"`
}{Secrets: secrets})
if err != nil { if err != nil {
return err return err
} }
return writeSOPSEncrypted(path, ageKeyPath, plaintext, "^(secrets)$") return writeSOPSEncryptedFile(path, ageKeyPath, plaintext)
}
func UpsertOperationalSecret(path, ageKeyPath, secretPath, key, value string) error {
secrets, err := ReadOperationalSecrets(path, ageKeyPath)
if err != nil {
return err
}
values := secrets[secretPath]
if values == nil {
values = map[string]string{}
secrets[secretPath] = values
}
values[key] = value
return WriteOperationalSecrets(path, ageKeyPath, secrets)
}
func ReadOperationalSecrets(path, ageKeyPath string) (map[string]map[string]string, error) {
plaintext, err := decryptSOPSFile(path, ageKeyPath)
if err != nil {
return nil, fmt.Errorf("decrypt operational SOPS secrets: %w", err)
}
decoder := yaml.NewDecoder(bytes.NewReader(plaintext))
decoder.KnownFields(true)
var document operationalSecrets
if err := decoder.Decode(&document); err != nil {
return nil, fmt.Errorf("parse operational SOPS secrets: %w", err)
}
if err := decoder.Decode(&operationalSecrets{}); !errors.Is(err, io.EOF) {
return nil, errors.New("operational SOPS secrets must contain one YAML document")
}
if document.Secrets == nil {
return nil, errors.New("operational SOPS secrets requires a secrets mapping")
}
for secretPath, values := range document.Secrets {
if values == nil {
return nil, fmt.Errorf("operational SOPS secret %q must be a mapping", secretPath)
}
}
return document.Secrets, nil
}
func ForgejoRegistryDockerConfig(imageRepository, username, token string) (string, error) {
registryHost := strings.Split(strings.TrimSpace(imageRepository), "/")[0]
if registryHost == "" || username == "" || token == "" {
return "", errors.New("delivery registry host, Forgejo username, and token are required")
}
config, err := json.Marshal(map[string]map[string]map[string]string{
"auths": {
registryHost: {"auth": base64.StdEncoding.EncodeToString([]byte(username + ":" + token))},
},
})
if err != nil {
return "", err
}
return string(config), nil
}
func InitializeOpenBao(cfg config.Config) error {
kubeconfig := filepath.Join(cfg.Git.CloneParent, cfg.Talos.RepoDirName, cfg.Talos.GeneratedDir, "kubeconfig")
if _, err := initializeOpenBao(kubeconfig, cfg.SOPS.RecoveryRecipient, cfg.SOPS.RecoveryIdentityPath, cfg.SOPS.RecoveryBundlePath, cfg.SOPS.AgeKeyPath, cfg.SOPS.OperationalSecretsPath); err != nil {
return fmt.Errorf("initialize OpenBao: %w", err)
}
return nil
} }
func NewWebhookAuthorization() (string, error) { func NewWebhookAuthorization() (string, error) {
@ -345,10 +277,6 @@ func NewWebhookAuthorization() (string, error) {
} }
func writeSOPSEncryptedFile(path, ageKeyPath string, plaintext []byte) error { func writeSOPSEncryptedFile(path, ageKeyPath string, plaintext []byte) error {
return writeSOPSEncrypted(path, ageKeyPath, plaintext, "^(data|stringData)$")
}
func writeSOPSEncrypted(path, ageKeyPath string, plaintext []byte, encryptedRegex string) error {
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return err return err
} }
@ -356,11 +284,11 @@ func writeSOPSEncrypted(path, ageKeyPath string, plaintext []byte, encryptedRege
if err != nil { if err != nil {
return fmt.Errorf("derive SOPS age recipient: %w", err) return fmt.Errorf("derive SOPS age recipient: %w", err)
} }
command := exec.Command("sops", "encrypt", "--age", strings.TrimSpace(string(recipient)), "--encrypted-regex", encryptedRegex, "--input-type", "yaml", "--output-type", "yaml", "--filename-override", filepath.Base(path)) command := exec.Command("sops", "encrypt", "--age", strings.TrimSpace(string(recipient)), "--encrypted-regex", "^(data|stringData)$", "--input-type", "yaml", "--output-type", "yaml", "--filename-override", filepath.Base(path))
command.Stdin = bytes.NewReader(plaintext) command.Stdin = bytes.NewReader(plaintext)
encrypted, err := command.Output() encrypted, err := command.Output()
if err != nil { if err != nil {
return fmt.Errorf("encrypt SOPS file: %w", err) return fmt.Errorf("encrypt Democratic CSI secret: %w", err)
} }
return os.WriteFile(path, encrypted, 0600) return os.WriteFile(path, encrypted, 0600)
} }

View file

@ -1,9 +1,6 @@
package bootstrap package bootstrap
import ( import (
"encoding/base64"
"encoding/json"
"errors"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@ -107,93 +104,6 @@ func TestNewWebhookAuthorization(t *testing.T) {
} }
} }
func TestForgejoRegistryDockerConfig(t *testing.T) {
dockerConfig, err := ForgejoRegistryDockerConfig("registry.example.test/team/app", "registry-user", "registry-token")
if err != nil {
t.Fatal(err)
}
var document struct {
Auths map[string]struct {
Auth string `json:"auth"`
} `json:"auths"`
}
if err := json.Unmarshal([]byte(dockerConfig), &document); err != nil {
t.Fatal(err)
}
if document.Auths["registry.example.test"].Auth != base64.StdEncoding.EncodeToString([]byte("registry-user:registry-token")) {
t.Fatal("Forgejo registry Docker config has the wrong credentials")
}
}
func TestUpsertOperationalSecretPreservesExistingSecrets(t *testing.T) {
if _, err := exec.LookPath("age-keygen"); err != nil {
t.Skip("age-keygen is required for bootstrap encryption")
}
if _, err := exec.LookPath("sops"); err != nil {
t.Skip("sops is required for bootstrap encryption")
}
dir := t.TempDir()
identity := filepath.Join(dir, "age-key.txt")
if err := exec.Command("age-keygen", "-o", identity).Run(); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "operational-secrets.sops.yaml")
if err := WriteOperationalSecrets(path, identity, map[string]map[string]string{
"cicd/forgejo": {"token": "existing-token"},
"platform/cloudflare": {"api-token": "existing-api-token"},
"cicd/forgejo-registry": {"dockerconfigjson": "old-config"},
}); err != nil {
t.Fatal(err)
}
if err := UpsertOperationalSecret(path, identity, "cicd/forgejo-registry", "dockerconfigjson", "new-config"); err != nil {
t.Fatal(err)
}
encrypted, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(encrypted), "existing-token") || strings.Contains(string(encrypted), "new-config") || !strings.Contains(string(encrypted), "sops:") {
t.Fatal("operational secret upsert was not SOPS encrypted")
}
secrets, err := ReadOperationalSecrets(path, identity)
if err != nil {
t.Fatal(err)
}
if secrets["cicd/forgejo"]["token"] != "existing-token" || secrets["platform/cloudflare"]["api-token"] != "existing-api-token" || secrets["cicd/forgejo-registry"]["dockerconfigjson"] != "new-config" {
t.Fatal("operational secret upsert did not preserve unrelated secrets")
}
}
func TestInitializeOpenBaoUsesGeneratedKubeconfig(t *testing.T) {
original := initializeOpenBao
defer func() { initializeOpenBao = original }()
var kubeconfig string
initializeOpenBao = func(path, recipient, identityPath, bundlePath, ageKeyPath, operationalSecretsPath string) (map[string]map[string]string, error) {
kubeconfig = path
if recipient != "recipient" || identityPath != "identity" || bundlePath != "bundle" || ageKeyPath != "age" || operationalSecretsPath != "secrets" {
t.Fatal("InitializeOpenBao passed incorrect configured paths")
}
return nil, nil
}
cfg := config.Config{
Git: config.GitConfig{CloneParent: "checkout"},
Talos: config.TalosConfig{RepoDirName: "talos", GeneratedDir: "generated"},
SOPS: config.SOPSConfig{RecoveryRecipient: "recipient", RecoveryIdentityPath: "identity", RecoveryBundlePath: "bundle", AgeKeyPath: "age", OperationalSecretsPath: "secrets"},
}
if err := InitializeOpenBao(cfg); err != nil {
t.Fatal(err)
}
if kubeconfig != filepath.Join("checkout", "talos", "generated", "kubeconfig") {
t.Fatal("InitializeOpenBao did not use the configured generated kubeconfig")
}
initializeOpenBao = func(string, string, string, string, string, string) (map[string]map[string]string, error) {
return nil, errors.New("unavailable")
}
if err := InitializeOpenBao(cfg); err == nil {
t.Fatal("InitializeOpenBao accepted a seeding error")
}
}
func TestCopyDirSkipsGitDirectory(t *testing.T) { func TestCopyDirSkipsGitDirectory(t *testing.T) {
source := t.TempDir() source := t.TempDir()
destination := t.TempDir() destination := t.TempDir()

View file

@ -67,15 +67,6 @@ type APIError struct {
Status string Status string
} }
type createTokenRequest struct {
Name string `json:"name"`
Scopes []string `json:"scopes"`
}
type accessToken struct {
SHA1 string `json:"sha1"`
}
func (e *APIError) Error() string { func (e *APIError) Error() string {
return fmt.Sprintf("forgejo returned %s", e.Status) return fmt.Sprintf("forgejo returned %s", e.Status)
} }
@ -94,47 +85,6 @@ func NewRepoManager(baseURL, token, owner, username, manifestsRepo, fluxRepo, br
} }
} }
// CreateRegistryToken creates the package-registry token that Forgejo returns once.
func CreateRegistryToken(baseURL, username, password, otp, name string) (string, error) {
return createRegistryToken(&http.Client{Timeout: 15 * time.Second}, baseURL, username, password, otp, name)
}
func createRegistryToken(client *http.Client, baseURL, username, password, otp, name string) (string, error) {
if strings.TrimSpace(baseURL) == "" || username == "" || password == "" || strings.TrimSpace(name) == "" {
return "", fmt.Errorf("Forgejo base URL, username, password, and token name are required")
}
body, err := json.Marshal(createTokenRequest{Name: name, Scopes: []string{"read:package", "write:package"}})
if err != nil {
return "", err
}
endpoint := fmt.Sprintf("%s/api/v1/users/%s/tokens", strings.TrimRight(baseURL, "/"), url.PathEscape(username))
request, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return "", err
}
request.SetBasicAuth(username, password)
request.Header.Set("Content-Type", "application/json")
if otp != "" {
request.Header.Set("X-Forgejo-OTP", otp)
}
response, err := client.Do(request)
if err != nil {
return "", err
}
defer response.Body.Close()
if response.StatusCode != http.StatusCreated {
return "", &APIError{StatusCode: response.StatusCode, Status: response.Status}
}
var token accessToken
if err := json.NewDecoder(response.Body).Decode(&token); err != nil {
return "", fmt.Errorf("parse Forgejo registry token response: %w", err)
}
if token.SHA1 == "" {
return "", fmt.Errorf("Forgejo did not return a registry token")
}
return token.SHA1, nil
}
func (rm *RepoManager) InitializeAll(createRepo func(string) error, createFlux func(string) error) error { func (rm *RepoManager) InitializeAll(createRepo func(string) error, createFlux func(string) error) error {
if err := rm.ensureRepo(rm.ManifestsRepoName, "Centralized deployment manifests for Flux CD", createRepo); err != nil { if err := rm.ensureRepo(rm.ManifestsRepoName, "Centralized deployment manifests for Flux CD", createRepo); err != nil {
return err return err

View file

@ -4,41 +4,9 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"reflect"
"testing" "testing"
) )
func TestCreateRegistryTokenUsesBasicAuthAndPackageScopes(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost || request.URL.Path != "/api/v1/users/registry-user/tokens" {
t.Fatal("unexpected Forgejo token request")
}
username, password, ok := request.BasicAuth()
if !ok || username != "registry-user" || password != "password" {
t.Fatal("Forgejo token request did not use the supplied basic authentication")
}
if request.Header.Get("X-Forgejo-OTP") != "123456" || request.Header.Get("Content-Type") != "application/json" {
t.Fatal("Forgejo token request headers are incorrect")
}
var body createTokenRequest
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.Name != "maidn-registry" || !reflect.DeepEqual(body.Scopes, []string{"read:package", "write:package"}) {
t.Fatal("Forgejo token request did not use the least-privilege package scopes")
}
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(http.StatusCreated)
_, _ = writer.Write([]byte(`{"sha1":"registry-token"}`))
}))
defer server.Close()
token, err := createRegistryToken(server.Client(), server.URL, "registry-user", "password", "123456", "maidn-registry")
if err != nil || token != "registry-token" {
t.Fatal("CreateRegistryToken() did not return the Forgejo token")
}
}
func TestRepoExistsOnlyCreatesOnNotFound(t *testing.T) { func TestRepoExistsOnlyCreatesOnNotFound(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.StatusUnauthorized) writer.WriteHeader(http.StatusUnauthorized)

View file

@ -85,19 +85,6 @@ func GetUserInputForGithubAuth() (username, token string, err error) {
return return
} }
// PromptForgejoRegistryToken collects credentials used only to create a registry token.
func PromptForgejoRegistryToken() (password, otp, name string, err error) {
password, err = promptHiddenRequired("Forgejo account password")
if err != nil {
return "", "", "", err
}
if otp, err = promptHiddenOptional("Forgejo OTP (optional)"); err != nil {
return "", "", "", err
}
name = prompt(bufio.NewReader(os.Stdin), "Forgejo registry token name", "maidn-registry")
return password, otp, name, nil
}
func promptForToken(reader *bufio.Reader, prompt string) (string, error) { func promptForToken(reader *bufio.Reader, prompt string) (string, error) {
token := promptSecret(reader, prompt, "") token := promptSecret(reader, prompt, "")
if token == "" { if token == "" {
@ -142,24 +129,3 @@ func promptSecret(reader *bufio.Reader, prompt, fallback string) string {
} }
return secret return secret
} }
func promptHiddenRequired(prompt string) (string, error) {
value, err := promptHiddenOptional(prompt)
if err != nil {
return "", err
}
if value == "" {
return "", fmt.Errorf("%s cannot be empty", strings.ToLower(prompt))
}
return value, nil
}
func promptHiddenOptional(prompt string) (string, error) {
fmt.Printf("%s: ", prompt)
value, err := term.ReadPassword(int(syscall.Stdin))
fmt.Println()
if err != nil {
return "", err
}
return strings.TrimSpace(string(value)), nil
}