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 HasOpenPullRequest(string, string) (bool, error) MergePullRequest(string, string) error PublishRepositoryPullRequest(string, string, string, string, func(string) error) (bool, error) EnsureWebhook(string, string, string) error TriggerWebhookTest(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 imports one clean checkout into its source 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 } 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 } } if open, err := sourceManager.HasOpenPullRequest(repository, deliveryBranch); err != nil { return err } else if open { if err := sourceManager.MergePullRequest(repository, deliveryBranch); 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) }); 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) } if err := sourceManager.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 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 { 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 } 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 }