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" ) type onboardingRepoManager interface { EnsureRepository(string, string) (bool, error) RemoteBranchRevision(string, string) (string, error) PushRef(string, string, string, string) error EnsureProtectedBranch(string, string) error PublishDeliveryBranch(string, string, string, string, func(string) error) (bool, error) EnsurePullRequest(string, string, 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 reviewed source-delivery and cluster-registration pull requests. 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") } hasAddons, err := hasMaidnAddons(sourceDir) if err != nil { return err } owner, repository, err := forgejo.RepositoryFromURL(resolved.Delivery.AppRepoURL) if err != nil { return err } deliveryBranch, err := forgejo.DeliveryBranch(resolved.Delivery.AppName, resolved.Delivery.AppRepoRef) 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 source for Maidn CI/CD delivery"); 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) } changed, err := sourceManager.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 := sourceManager.EnsurePullRequest(repository, "feat: migrate delivery to Tekton", deliveryBranch, resolved.Delivery.AppRepoRef); err != nil { return 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, hasAddons || hasRuntimeSecretGrant(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 } func hasMaidnAddons(dir string) (bool, error) { _, err := readRegularFile(filepath.Join(dir, ".maidn", "kustomization.yaml")) if errors.Is(err, os.ErrNotExist) { return false, nil } return err == nil, err } // publishInitialAppBranches establishes the immutable source baseline before delivery setup. 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, hasRuntimeSecretGrant(cfg)) } func registerAppInCluster(dir string, cfg config.Config, hasAddons bool) error { content, err := renderAppRegistrationWithAddons(cfg, hasAddons) 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) { return renderAppRegistrationWithAddons(cfg, hasRuntimeSecretGrant(cfg)) } func renderAppRegistrationWithAddons(cfg config.Config, hasAddons bool) ([]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 hasAddons { content += fmt.Sprintf(`--- apiVersion: kustomize.toolkit.fluxcd.io/v1 kind: Kustomization metadata: name: %s-addons 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 } kustomization := filepath.Join(maidnDir, "kustomization.yaml") content := []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n - secret-access.yaml\n") if _, err := os.Lstat(kustomization); err == nil { content, err = readRegularFile(kustomization) if err != nil { return err } content, err = addKustomizationResource(content, "secret-access.yaml") if err != nil { return err } } else if !os.IsNotExist(err) { return err } if err := os.WriteFile(kustomization, content, 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 }