343 lines
12 KiB
Go
343 lines
12 KiB
Go
package bootstrap
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
|
"github.com/Pingu-Studio/MaidnCLI/internal/forgejo"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
type onboardingRepoManager interface {
|
|
EnsureRepository(string, string) (bool, error)
|
|
RemoteBranchRevision(string, string) (string, error)
|
|
PushRef(string, string, string, string) error
|
|
EnsureProtectedBranch(string, string) error
|
|
PublishRepositoryPullRequest(string, string, string, string, func(string) error) (bool, error)
|
|
EnsureWebhook(string, string, string) error
|
|
}
|
|
|
|
var newOnboardingRepoManager = func(baseURL, token, owner, username, manifestsRepo, fluxRepo, branch, migrationBranch string) onboardingRepoManager {
|
|
return forgejo.NewRepoManager(baseURL, token, owner, username, manifestsRepo, fluxRepo, branch, migrationBranch)
|
|
}
|
|
|
|
// OnboardApp opens a reviewed central cluster-registration pull request.
|
|
func OnboardApp(cfg config.Config, sourceDir string) error {
|
|
resolved, err := config.ResolveAppOnboarding(cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := forgejo.EnsureCleanCheckout(sourceDir); err != nil {
|
|
return err
|
|
}
|
|
sourceBranch, err := forgejo.CurrentBranch(sourceDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if sourceBranch != resolved.Delivery.AppRepoRef {
|
|
return errors.New("--from branch must match delivery appRepoRef")
|
|
}
|
|
owner, repository, err := forgejo.RepositoryFromURL(resolved.Delivery.AppRepoURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sourceManager := newOnboardingRepoManager(resolved.Git.BaseURL, resolved.Git.Token, owner, resolved.Git.Username, "", "", resolved.Delivery.AppRepoRef, "")
|
|
if _, err := sourceManager.EnsureRepository(repository, "Application build input for Maidn CI/CD"); err != nil {
|
|
return err
|
|
}
|
|
if err := publishInitialAppBranches(sourceManager, sourceDir, resolved.Delivery.AppRepoURL, sourceBranch, resolved.Delivery.AppRepoRef, resolved.Delivery.ProductionBranch); err != nil {
|
|
return err
|
|
}
|
|
if err := sourceManager.EnsureProtectedBranch(repository, resolved.Delivery.ProductionBranch); err != nil {
|
|
return fmt.Errorf("protect Forgejo production branch: %w", err)
|
|
}
|
|
registrationBranch := "maidn/register-" + resolved.Delivery.AppName
|
|
clusterManager := newOnboardingRepoManager(resolved.Git.BaseURL, resolved.Git.Token, resolved.Git.Owner, resolved.Git.Username, "", "", resolved.Flux.Branch, "")
|
|
if _, err := clusterManager.PublishRepositoryPullRequest(resolved.Flux.RepoName, "feat: register "+resolved.Delivery.AppName+" delivery", registrationBranch, resolved.Flux.Branch, func(dir string) error {
|
|
return registerAppInCluster(dir, resolved)
|
|
}); err != nil {
|
|
return fmt.Errorf("register app in cluster repository: %w", err)
|
|
}
|
|
secrets, err := readOperationalSecrets(resolved.SOPS.OperationalSecretsPath, resolved.SOPS.AgeKeyPath)
|
|
if err != nil {
|
|
return fmt.Errorf("read encrypted webhook authorization: %w", err)
|
|
}
|
|
authorization := secrets["cicd/forgejo-webhook"]["authorization"]
|
|
if authorization == "" {
|
|
return errors.New("operational SOPS secrets requires cicd/forgejo-webhook.authorization")
|
|
}
|
|
generatedDir := filepath.Join(resolved.Git.CloneParent, resolved.Talos.RepoDirName, resolved.Talos.GeneratedDir)
|
|
if err := waitForWebhookTargets(generatedDir, resolved); err != nil {
|
|
return err
|
|
}
|
|
webhookURL := "https://tekton." + resolved.Flux.ClusterDomain + "/"
|
|
if err := sourceManager.EnsureWebhook(repository, webhookURL, authorization); err != nil {
|
|
return fmt.Errorf("register Forgejo webhook: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// publishInitialAppBranches establishes the immutable source baseline before central registration.
|
|
func publishInitialAppBranches(manager onboardingRepoManager, sourceDir, targetURL, sourceBranch, targetBranch, productionBranch string) error {
|
|
sourceRevision, err := forgejo.BranchRevision(sourceDir, sourceBranch)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
mainRevision, err := manager.RemoteBranchRevision(targetURL, targetBranch)
|
|
if err != nil {
|
|
return fmt.Errorf("read target base branch: %w", err)
|
|
}
|
|
createdMain := mainRevision == ""
|
|
if mainRevision == "" {
|
|
if err := manager.PushRef(sourceDir, targetURL, sourceRevision, targetBranch); err != nil {
|
|
return fmt.Errorf("publish source base branch: %w", err)
|
|
}
|
|
}
|
|
if productionRevision, err := manager.RemoteBranchRevision(targetURL, productionBranch); err != nil {
|
|
return fmt.Errorf("read target production branch: %w", err)
|
|
} else if productionRevision == "" {
|
|
if err := manager.PushRef(sourceDir, targetURL, sourceRevision, productionBranch); err != nil {
|
|
return fmt.Errorf("create production from source base branch: %w", err)
|
|
}
|
|
}
|
|
if createdMain {
|
|
mainRevision, err = manager.RemoteBranchRevision(targetURL, targetBranch)
|
|
if err != nil {
|
|
return fmt.Errorf("verify target base branch: %w", err)
|
|
}
|
|
if mainRevision != sourceRevision {
|
|
return errors.New("target base branch does not match the validated source ref")
|
|
}
|
|
}
|
|
if productionRevision, err := manager.RemoteBranchRevision(targetURL, productionBranch); err != nil {
|
|
return fmt.Errorf("verify target production branch: %w", err)
|
|
} else if productionRevision == "" {
|
|
return errors.New("target production branch was not created")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RegisterAppInCluster writes only the managed Flux registration for one app.
|
|
func RegisterAppInCluster(dir string, cfg config.Config) error {
|
|
return registerAppInCluster(dir, cfg)
|
|
}
|
|
|
|
func registerAppInCluster(dir string, cfg config.Config) error {
|
|
content, err := renderAppRegistration(cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tektonDir := filepath.Join(dir, "base", "tekton")
|
|
rootPath := filepath.Join(tektonDir, "kustomization.yaml")
|
|
root, err := readRegularFile(rootPath)
|
|
if err != nil {
|
|
return fmt.Errorf("read Tekton Kustomization: %w", err)
|
|
}
|
|
updatedRoot, err := addKustomizationResource(root, "apps")
|
|
if err != nil {
|
|
return fmt.Errorf("Tekton Kustomization: %w", err)
|
|
}
|
|
appsDir := filepath.Join(tektonDir, "apps")
|
|
if info, statErr := os.Lstat(appsDir); statErr == nil && (info.Mode()&os.ModeSymlink != 0 || !info.IsDir()) {
|
|
return errors.New("Tekton apps path must be a directory")
|
|
} else if statErr != nil && !os.IsNotExist(statErr) {
|
|
return statErr
|
|
}
|
|
appsPath := filepath.Join(appsDir, "kustomization.yaml")
|
|
apps, err := os.ReadFile(appsPath)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
if err == nil {
|
|
if info, statErr := os.Lstat(appsPath); statErr != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
|
return errors.New("Tekton apps Kustomization must be a regular file")
|
|
}
|
|
} else {
|
|
apps = []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n")
|
|
}
|
|
updatedApps, err := addKustomizationResource(apps, cfg.Delivery.AppName+".yaml")
|
|
if err != nil {
|
|
return fmt.Errorf("Tekton apps Kustomization: %w", err)
|
|
}
|
|
registrationPath := filepath.Join(appsDir, cfg.Delivery.AppName+".yaml")
|
|
if existing, readErr := os.ReadFile(registrationPath); readErr == nil {
|
|
info, statErr := os.Lstat(registrationPath)
|
|
if statErr != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || !bytes.Equal(bytes.ReplaceAll(existing, []byte("\r\n"), []byte("\n")), content) {
|
|
return errors.New("Tekton app registration conflicts with unmanaged content")
|
|
}
|
|
} else if !os.IsNotExist(readErr) {
|
|
return readErr
|
|
}
|
|
if err := os.MkdirAll(appsDir, 0755); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(rootPath, updatedRoot, 0644); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(appsPath, updatedApps, 0644); err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(registrationPath, content, 0644)
|
|
}
|
|
|
|
func renderAppRegistration(cfg config.Config) ([]byte, error) {
|
|
if err := config.ValidateDelivery(cfg); err != nil {
|
|
return nil, err
|
|
}
|
|
secretAccess, err := renderAppSecretAccess(cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
delivery, err := renderAppDelivery(cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
content := fmt.Sprintf(`apiVersion: source.toolkit.fluxcd.io/v1
|
|
kind: GitRepository
|
|
metadata:
|
|
name: %s
|
|
namespace: flux-system
|
|
spec:
|
|
interval: 1m
|
|
url: %s
|
|
secretRef:
|
|
name: forgejo-flux-credentials
|
|
ref:
|
|
branch: maidn/platform-%s
|
|
`, cfg.Delivery.AppName, cfg.Delivery.AppRepoURL, cfg.Delivery.AppName)
|
|
if len(secretAccess) != 0 {
|
|
content += "---\n" + string(secretAccess)
|
|
}
|
|
return append([]byte(content+"---\n"), delivery...), nil
|
|
}
|
|
|
|
// renderAppSecretAccess renders only central OpenBao references, never secret values.
|
|
func renderAppSecretAccess(cfg config.Config) ([]byte, error) {
|
|
if err := config.ValidateSecretGrants(cfg.SecretGrants); err != nil {
|
|
return nil, err
|
|
}
|
|
var manifests []string
|
|
for _, grant := range cfg.SecretGrants {
|
|
if grant.Application != cfg.Delivery.AppName || grant.Consumer != "runtime" {
|
|
continue
|
|
}
|
|
name := cfg.Delivery.AppName + "-runtime-" + grant.Environment
|
|
manifest := fmt.Sprintf(`apiVersion: v1
|
|
kind: ServiceAccount
|
|
metadata:
|
|
name: maidn-%s
|
|
namespace: %s
|
|
---
|
|
apiVersion: external-secrets.io/v1
|
|
kind: SecretStore
|
|
metadata:
|
|
name: openbao-%s
|
|
namespace: %s
|
|
spec:
|
|
provider:
|
|
vault:
|
|
server: http://openbao.openbao.svc:8200
|
|
path: secret
|
|
version: v2
|
|
auth:
|
|
kubernetes:
|
|
mountPath: kubernetes
|
|
role: maidn-%s
|
|
serviceAccountRef:
|
|
name: maidn-%s
|
|
---
|
|
apiVersion: external-secrets.io/v1
|
|
kind: ExternalSecret
|
|
metadata:
|
|
name: %s
|
|
namespace: %s
|
|
spec:
|
|
refreshInterval: 1h
|
|
secretStoreRef:
|
|
kind: SecretStore
|
|
name: openbao-%s
|
|
target:
|
|
name: %s
|
|
creationPolicy: Owner
|
|
data:
|
|
`, name, grant.Environment, name, grant.Environment, name, name, name, grant.Environment, name, name)
|
|
for _, secret := range grant.Secrets {
|
|
manifest += fmt.Sprintf(" - secretKey: %s\n remoteRef:\n key: apps/%s/%s\n property: value\n", secret, grant.Application, secret)
|
|
}
|
|
manifests = append(manifests, manifest)
|
|
}
|
|
return []byte(strings.Join(manifests, "---\n")), nil
|
|
}
|
|
|
|
func readRegularFile(path string) ([]byte, error) {
|
|
info, err := os.Lstat(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
|
return nil, errors.New("must be a regular file")
|
|
}
|
|
return os.ReadFile(path)
|
|
}
|
|
|
|
func addKustomizationResource(content []byte, resource string) ([]byte, error) {
|
|
decoder := yaml.NewDecoder(bytes.NewReader(content))
|
|
var document yaml.Node
|
|
if err := decoder.Decode(&document); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := decoder.Decode(&yaml.Node{}); !errors.Is(err, io.EOF) {
|
|
return nil, errors.New("must contain one YAML document")
|
|
}
|
|
if document.Kind != yaml.DocumentNode || len(document.Content) != 1 || document.Content[0].Kind != yaml.MappingNode {
|
|
return nil, errors.New("must be a Kustomization mapping")
|
|
}
|
|
root := document.Content[0]
|
|
apiVersion, err := yamlMappingValue(root, "apiVersion")
|
|
if err != nil || apiVersion == nil || apiVersion.Value != "kustomize.config.k8s.io/v1beta1" {
|
|
return nil, errors.New("must be a Kustomization")
|
|
}
|
|
kind, err := yamlMappingValue(root, "kind")
|
|
if err != nil || kind == nil || kind.Value != "Kustomization" {
|
|
return nil, errors.New("must be a Kustomization")
|
|
}
|
|
resources, err := yamlMappingValue(root, "resources")
|
|
if err != nil || resources == nil {
|
|
return nil, errors.New("must define a resources list")
|
|
}
|
|
if resources.Kind == yaml.ScalarNode && resources.Tag == "!!null" {
|
|
resources.Kind, resources.Tag, resources.Value = yaml.SequenceNode, "!!seq", ""
|
|
}
|
|
if resources.Kind != yaml.SequenceNode {
|
|
return nil, errors.New("must define a resources list")
|
|
}
|
|
for _, item := range resources.Content {
|
|
if item.Kind != yaml.ScalarNode || item.Value == "" {
|
|
return nil, errors.New("resources must contain non-empty scalar values")
|
|
}
|
|
if item.Value == resource {
|
|
var rendered bytes.Buffer
|
|
encoder := yaml.NewEncoder(&rendered)
|
|
encoder.SetIndent(2)
|
|
if err := encoder.Encode(&document); err != nil {
|
|
return nil, err
|
|
}
|
|
return rendered.Bytes(), nil
|
|
}
|
|
}
|
|
resources.Content = append(resources.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: resource})
|
|
var rendered bytes.Buffer
|
|
encoder := yaml.NewEncoder(&rendered)
|
|
encoder.SetIndent(2)
|
|
if err := encoder.Encode(&document); err != nil {
|
|
return nil, err
|
|
}
|
|
return rendered.Bytes(), nil
|
|
}
|