maidn-cli/internal/bootstrap/onboard.go

405 lines
13 KiB
Go

package bootstrap
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"github.com/Pingu-Studio/MaidnCLI/internal/config"
"github.com/Pingu-Studio/MaidnCLI/internal/forgejo"
"gopkg.in/yaml.v3"
)
// OnboardApp imports one clean checkout into the configured Forgejo owner,
// publishes its delivery branch, registers it with Flux, then adds its webhook.
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
}
if owner != resolved.Git.Owner {
return errors.New("delivery appRepoUrl owner must match git owner for app onboarding")
}
deliveryBranch, err := forgejo.DeliveryBranch(resolved.Delivery.AppName, resolved.Delivery.AppRepoRef)
if err != nil {
return err
}
manager := forgejo.NewRepoManager(resolved.Git.BaseURL, resolved.Git.Token, resolved.Git.Owner, resolved.Git.Username, "", "", resolved.Delivery.AppRepoRef, "")
if _, err := manager.EnsureRepository(repository, "Application source for Maidn CI/CD delivery"); err != nil {
return err
}
if err := publishInitialAppBranches(manager, sourceDir, resolved.Delivery.AppRepoURL, sourceBranch, resolved.Delivery.AppRepoRef, resolved.Delivery.ProductionBranch); err != nil {
return err
}
if err := manager.EnsureProtectedBranch(repository, resolved.Delivery.ProductionBranch); err != nil {
return fmt.Errorf("protect Forgejo production branch: %w", err)
}
changed, err := manager.PublishDeliveryBranch(sourceDir, sourceBranch, resolved.Delivery.AppRepoURL, deliveryBranch, func(dir string) error {
if err := GenerateAppDelivery(dir, resolved); err != nil {
return err
}
return GenerateAppSecretAccess(dir, resolved)
})
if err != nil {
return err
}
if changed {
if err := manager.EnsurePullRequest(repository, "feat: migrate delivery to Tekton", deliveryBranch, resolved.Delivery.AppRepoRef); err != nil {
return err
}
}
if open, err := manager.HasOpenPullRequest(repository, deliveryBranch); err != nil {
return err
} else if open {
if err := manager.MergePullRequest(repository, deliveryBranch); err != nil {
return err
}
}
registrationBranch := "maidn/register-" + resolved.Delivery.AppName
if _, err := manager.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 := manager.EnsureWebhook(repository, webhookURL, authorization); err != nil {
return fmt.Errorf("register Forgejo webhook: %w", err)
}
if err := manager.TriggerWebhookTest(repository, webhookURL, resolved.Delivery.AppRepoRef); err != nil {
return fmt.Errorf("trigger Forgejo webhook test: %w", err)
}
return nil
}
// publishInitialAppBranches establishes the immutable source baseline before delivery setup.
func publishInitialAppBranches(manager *forgejo.RepoManager, 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 {
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
}
branch, err := forgejo.DeliveryBranch(cfg.Delivery.AppName, cfg.Delivery.AppRepoRef)
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: %s
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: %s
namespace: flux-system
spec:
interval: 5m
path: ./.tekton
prune: true
wait: true
targetNamespace: tekton-pipelines
dependsOn:
- name: tekton-catalog
sourceRef:
kind: GitRepository
name: %s
`, cfg.Delivery.AppName, cfg.Delivery.AppRepoURL, branch, cfg.Delivery.AppName, cfg.Delivery.AppName)
if hasRuntimeSecretGrant(cfg) {
content += fmt.Sprintf(`---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: %s-secrets
namespace: flux-system
spec:
interval: 5m
path: ./.maidn
prune: true
wait: true
dependsOn:
- name: external-secrets-config
sourceRef:
kind: GitRepository
name: %s
`, cfg.Delivery.AppName, cfg.Delivery.AppName)
}
return []byte(content), nil
}
func hasRuntimeSecretGrant(cfg config.Config) bool {
for _, grant := range cfg.SecretGrants {
if grant.Application == cfg.Delivery.AppName && grant.Consumer == "runtime" {
return true
}
}
return false
}
// GenerateAppSecretAccess renders only OpenBao references, never secret values.
func GenerateAppSecretAccess(dir string, cfg config.Config) error {
if err := config.ValidateSecretGrants(cfg.SecretGrants); err != nil {
return 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
manifests += 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 {
manifests += fmt.Sprintf(" - secretKey: %s\n remoteRef:\n key: apps/%s/%s\n property: value\n", secret, grant.Application, secret)
}
manifests += "---\n"
}
if manifests == "" {
return nil
}
maidnDir := filepath.Join(dir, ".maidn")
if err := os.MkdirAll(maidnDir, 0755); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(maidnDir, "kustomization.yaml"), []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n - secret-access.yaml\n"), 0644); err != nil {
return err
}
return os.WriteFile(filepath.Join(maidnDir, "secret-access.yaml"), []byte(manifests), 0644)
}
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
}