Compare commits

..

No commits in common. "8016a1063f36db096d03f474eefb9525405606bf" and "719abc370270281c61d20bcd3ea1a84a582038bb" have entirely different histories.

8 changed files with 387 additions and 83 deletions

View file

@ -32,7 +32,7 @@ var appCmd = &cobra.Command{
var appOnboardCmd = &cobra.Command{ var appOnboardCmd = &cobra.Command{
Use: "onboard", Use: "onboard",
Short: "Validate an application checkout and add its centrally-owned delivery contract.", Short: "Validate an application checkout and add its source-owned delivery contract.",
RunE: runAppOnboard, RunE: runAppOnboard,
} }
@ -50,7 +50,7 @@ func init() {
rootCmd.AddCommand(appCmd) rootCmd.AddCommand(appCmd)
appCmd.AddCommand(appOnboardCmd) appCmd.AddCommand(appOnboardCmd)
appOnboardCmd.Flags().StringVar(&onboardConfigPath, "config", "", "Path to private bootstrap config YAML") appOnboardCmd.Flags().StringVar(&onboardConfigPath, "config", "", "Path to private bootstrap config YAML")
appOnboardCmd.Flags().StringVar(&onboardFrom, "from", "", "Clean application checkout to validate") appOnboardCmd.Flags().StringVar(&onboardFrom, "from", "", "Clean application checkout to scaffold")
appOnboardCmd.Flags().StringVar(&onboardAppName, "app-name", "", "Application name override") appOnboardCmd.Flags().StringVar(&onboardAppName, "app-name", "", "Application name override")
appOnboardCmd.Flags().StringVar(&onboardAppRepoURL, "app-repo-url", "", "Application repository URL override") appOnboardCmd.Flags().StringVar(&onboardAppRepoURL, "app-repo-url", "", "Application repository URL override")
appOnboardCmd.Flags().StringVar(&onboardImageRepository, "image-repository", "", "OCI image repository override") appOnboardCmd.Flags().StringVar(&onboardImageRepository, "image-repository", "", "OCI image repository override")

View file

@ -43,15 +43,12 @@ creates the Gateway, Tekton, and Tekton Triggers platform resources using
go run . bootstrap init --config <private-bootstrap-config> --organization <owner> --create-organization go run . bootstrap init --config <private-bootstrap-config> --organization <owner> --create-organization
``` ```
The source-owned onboarding implementation is retired. Central onboarding The source-owned onboarding implementation is being retired. Do not onboard a
creates a reviewed cluster registration only; it never writes `.tekton` or new application until the central delivery renderer is available. See
`.maidn` resources to an application repository. Before merging that [Delivery Ownership](architecture/delivery-ownership.md) for the approved
registration, a platform operator must create and protect the corresponding architecture and migration rules.
`maidn/platform-<app>` package through a reviewed platform PR. See [Delivery
Ownership](architecture/delivery-ownership.md) for the approved architecture
and migration rules.
The onboarding command uses a clean checkout on `delivery.appRepoRef`. The future onboarding command uses a clean checkout on `delivery.appRepoRef`.
The app repository URL must be the canonical source owner, such as The app repository URL must be the canonical source owner, such as
`Maidn/<app>.git`; `test-org-2` is execution state only. Per-app static build `Maidn/<app>.git`; `test-org-2` is execution state only. Per-app static build
values remain command-line overrides rather than private defaults: values remain command-line overrides rather than private defaults:
@ -64,10 +61,10 @@ go run . app onboard --config <private-bootstrap-config> --from <clean-checkout>
``` ```
For a runtime build, set `--build-strategy runtime`; static-only output options For a runtime build, set `--build-strategy runtime`; static-only output options
remain harmless. Central onboarding creates the reviewed cluster-registration remain harmless. Central onboarding will create reviewed platform-branch and
change after the platform package exists. The cluster repository owns cluster-registration changes. The cluster repository will own
`base/tekton/apps/<app>.yaml`; the application repository remains a build input `base/tekton/apps/<app>.yaml`; the application repository will remain a build
only. The generic EventListener dispatches by Forgejo repository name. input only. The generic EventListener dispatches by Forgejo repository name.
Existing source-owned registrations are migrated in separate reviewed cluster Existing source-owned registrations are migrated in separate reviewed cluster
repository PRs. Never overwrite an unmanaged registration. repository PRs. Never overwrite an unmanaged registration.

View file

@ -27,9 +27,9 @@ spec:
- name: environment - name: environment
- name: pr-number - name: pr-number
default: "" default: ""
- name: platform-url - name: app-url
default: "" default: ""
- name: platform-revision - name: app-revision
default: "" default: ""
- name: forgejo-base-url - name: forgejo-base-url
default: "" default: ""
@ -57,10 +57,10 @@ spec:
value: $(params.environment) value: $(params.environment)
- name: PR_NUMBER - name: PR_NUMBER
value: $(params.pr-number) value: $(params.pr-number)
- name: PLATFORM_URL - name: APP_URL
value: $(params.platform-url) value: $(params.app-url)
- name: PLATFORM_REVISION - name: APP_REVISION
value: $(params.platform-revision) value: $(params.app-revision)
- name: FORGEJO_BASE_URL - name: FORGEJO_BASE_URL
value: $(params.forgejo-base-url) value: $(params.forgejo-base-url)
- name: FORGEJO_OWNER - name: FORGEJO_OWNER
@ -102,8 +102,8 @@ spec:
cd /tmp/manifests cd /tmp/manifests
if [ "$ENVIRONMENT" = preview ]; then if [ "$ENVIRONMENT" = preview ]; then
valid_pr_number "$PR_NUMBER" valid_pr_number "$PR_NUMBER"
valid_url "$PLATFORM_URL" valid_url "$APP_URL"
valid_revision "$PLATFORM_REVISION" valid_revision "$APP_REVISION"
app_dir="apps/previews/$APP_NAME-pr-$PR_NUMBER" app_dir="apps/previews/$APP_NAME-pr-$PR_NUMBER"
marker="$app_dir/ownership.yaml" marker="$app_dir/ownership.yaml"
if [ -e "$app_dir" ]; then if [ -e "$app_dir" ]; then
@ -129,11 +129,11 @@ spec:
EOF EOF
cmp -s "$expected_marker" "$marker" || fail cmp -s "$expected_marker" "$marker" || fail
fi fi
git clone "$PLATFORM_URL" /tmp/platform git clone "$APP_URL" /tmp/app
git -C /tmp/platform checkout "$PLATFORM_REVISION" git -C /tmp/app checkout "$APP_REVISION"
[ -f /tmp/platform/preview/values.yaml ] || fail [ -f /tmp/app/preview/values.yaml ] || fail
mkdir -p "$app_dir" mkdir -p "$app_dir"
cp /tmp/platform/preview/values.yaml "$app_dir/values.yaml" cp /tmp/app/preview/values.yaml "$app_dir/values.yaml"
sed -i "s/PLACEHOLDER_PR/$PR_NUMBER/g" "$app_dir/values.yaml" sed -i "s/PLACEHOLDER_PR/$PR_NUMBER/g" "$app_dir/values.yaml"
cat > "$marker" <<EOF cat > "$marker" <<EOF
apiVersion: v1 apiVersion: v1
@ -628,10 +628,6 @@ spec:
default: {{ quote .ForgejoOwner }} default: {{ quote .ForgejoOwner }}
- name: manifests-repo - name: manifests-repo
default: {{ quote .ManifestsRepo }} default: {{ quote .ManifestsRepo }}
- name: platform-url
default: {{ quote .AppRepoURL }}
- name: platform-revision
default: {{ quote .PlatformBranch }}
tasks: tasks:
- name: build-layer - name: build-layer
when: when:
@ -681,10 +677,10 @@ spec:
value: preview value: preview
- name: pr-number - name: pr-number
value: $(params.pr-number) value: $(params.pr-number)
- name: platform-url - name: app-url
value: $(params.platform-url) value: {{ quote .AppRepoURL }}
- name: platform-revision - name: app-revision
value: $(params.platform-revision) value: $(params.git-revision)
- name: update-staging - name: update-staging
runAfter: [build-layer] runAfter: [build-layer]
when: when:

View file

@ -622,7 +622,6 @@ type appDeliveryTemplateConfig struct {
AppRepository string AppRepository string
AppRepoURL string AppRepoURL string
AppRepoRef string AppRepoRef string
PlatformBranch string
ProductionBranch string ProductionBranch string
ImageRepository string ImageRepository string
BuildStrategy string BuildStrategy string
@ -637,6 +636,60 @@ type appDeliveryTemplateConfig struct {
var deliveryAppName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,45}[a-z0-9])?$`) var deliveryAppName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,45}[a-z0-9])?$`)
// GenerateAppDelivery writes the source-owned Tekton delivery contract for an app checkout.
func GenerateAppDelivery(dir string, cfg config.Config) error {
if err := config.ValidateDelivery(cfg); err != nil {
return err
}
content, err := renderAppDelivery(cfg)
if err != nil {
return err
}
files := map[string][]byte{
"kustomization.yaml": []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n - pipeline.yaml\n"),
"pipeline.yaml": content,
}
target := filepath.Join(dir, ".tekton")
info, err := os.Lstat(target)
if err == nil {
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return errors.New("app delivery .tekton path must be a directory")
}
entries, err := os.ReadDir(target)
if err != nil {
return err
}
if len(entries) != len(files) {
return errors.New("app delivery .tekton contains unmanaged files")
}
for name, content := range files {
file := filepath.Join(target, name)
fileInfo, err := os.Lstat(file)
if err != nil || fileInfo.Mode()&os.ModeSymlink != 0 || !fileInfo.Mode().IsRegular() {
return errors.New("app delivery .tekton contains unmanaged files")
}
if err := os.WriteFile(file, content, 0644); err != nil {
return err
}
}
return nil
}
if !os.IsNotExist(err) {
return err
}
temporary, err := os.MkdirTemp(dir, ".maidn-tekton-")
if err != nil {
return err
}
defer os.RemoveAll(temporary)
for name, content := range files {
if err := os.WriteFile(filepath.Join(temporary, name), content, 0644); err != nil {
return err
}
}
return os.Rename(temporary, target)
}
func renderAppDelivery(cfg config.Config) ([]byte, error) { func renderAppDelivery(cfg config.Config) ([]byte, error) {
if !deliveryAppName.MatchString(cfg.Delivery.AppName) { if !deliveryAppName.MatchString(cfg.Delivery.AppName) {
return nil, errors.New("delivery appName must be a lowercase DNS label") return nil, errors.New("delivery appName must be a lowercase DNS label")
@ -654,11 +707,11 @@ func renderAppDelivery(cfg config.Config) ([]byte, error) {
} }
values := appDeliveryTemplateConfig{ values := appDeliveryTemplateConfig{
AppName: cfg.Delivery.AppName, AppRepository: appRepository, AppRepoURL: cfg.Delivery.AppRepoURL, AppName: cfg.Delivery.AppName, AppRepository: appRepository, AppRepoURL: cfg.Delivery.AppRepoURL,
AppRepoRef: cfg.Delivery.AppRepoRef, PlatformBranch: "maidn/platform-" + cfg.Delivery.AppName, ProductionBranch: cfg.Delivery.ProductionBranch, ImageRepository: cfg.Delivery.ImageRepository, AppRepoRef: cfg.Delivery.AppRepoRef, ProductionBranch: cfg.Delivery.ProductionBranch, ImageRepository: cfg.Delivery.ImageRepository,
BuildStrategy: cfg.Delivery.BuildStrategy, BuildOutputDirectory: cfg.Delivery.BuildOutputDirectory, BuildConfiguration: cfg.Delivery.BuildConfiguration, BuildStrategy: cfg.Delivery.BuildStrategy, BuildOutputDirectory: cfg.Delivery.BuildOutputDirectory, BuildConfiguration: cfg.Delivery.BuildConfiguration,
ForgejoBaseURL: origin, ForgejoOwner: cfg.Git.Owner, ManifestsURL: forgejo.CloneURL(origin, cfg.Git.Owner, cfg.Flux.ManifestsRepo), ManifestsRepo: cfg.Flux.ManifestsRepo, ManifestsBranch: cfg.Flux.Branch, ForgejoBaseURL: origin, ForgejoOwner: cfg.Git.Owner, ManifestsURL: forgejo.CloneURL(origin, cfg.Git.Owner, cfg.Flux.ManifestsRepo), ManifestsRepo: cfg.Flux.ManifestsRepo, ManifestsBranch: cfg.Flux.Branch,
} }
for name, value := range map[string]string{"appRepository": values.AppRepository, "appRepoUrl": values.AppRepoURL, "appRepoRef": values.AppRepoRef, "platformBranch": values.PlatformBranch, "productionBranch": values.ProductionBranch, "imageRepository": values.ImageRepository, "buildOutputDirectory": values.BuildOutputDirectory, "buildConfiguration": values.BuildConfiguration, "forgejoBaseUrl": values.ForgejoBaseURL, "forgejoOwner": values.ForgejoOwner, "manifestsUrl": values.ManifestsURL, "manifestsRepo": values.ManifestsRepo, "manifestsBranch": values.ManifestsBranch} { for name, value := range map[string]string{"appRepository": values.AppRepository, "appRepoUrl": values.AppRepoURL, "appRepoRef": values.AppRepoRef, "productionBranch": values.ProductionBranch, "imageRepository": values.ImageRepository, "buildOutputDirectory": values.BuildOutputDirectory, "buildConfiguration": values.BuildConfiguration, "forgejoBaseUrl": values.ForgejoBaseURL, "forgejoOwner": values.ForgejoOwner, "manifestsUrl": values.ManifestsURL, "manifestsRepo": values.ManifestsRepo, "manifestsBranch": values.ManifestsBranch} {
if value == "" || strings.ContainsAny(value, "\r\n") || config.RedactURL(value) != value { if value == "" || strings.ContainsAny(value, "\r\n") || config.RedactURL(value) != value {
return nil, fmt.Errorf("delivery %s cannot be empty or contain credentials", name) return nil, fmt.Errorf("delivery %s cannot be empty or contain credentials", name)
} }

View file

@ -123,12 +123,7 @@ func TestGeneratedDeliveryUsesCombinedStaticBuildArtifactContract(t *testing.T)
t.Fatalf("generated delivery does not contain %q", expected) t.Fatalf("generated delivery does not contain %q", expected)
} }
} }
for _, expected := range []string{"name: platform-url\n default: \"https://git.example.test/apps/web-ui.git\"", "name: platform-revision\n default: \"maidn/platform-web-ui\"", "git clone \"$PLATFORM_URL\" /tmp/platform", "/tmp/platform/preview/values.yaml"} { for _, unexpected := range []string{"maidn-git-clone", "maidn-node-static-build", "maidn-node-static-push", "runAfter: [clone]", "name: source", "workspace: source", "workspace: artifact", "easycsr", "test-org", "git rm -r"} {
if !strings.Contains(string(content), expected) {
t.Fatalf("generated delivery does not contain protected platform input %q", expected)
}
}
for _, unexpected := range []string{"maidn-git-clone", "maidn-node-static-build", "maidn-node-static-push", "runAfter: [clone]", "name: source", "workspace: source", "workspace: artifact", "easycsr", "test-org", "git rm -r", "$APP_URL", "$APP_REVISION", "/tmp/app/preview/values.yaml"} {
if strings.Contains(string(content), unexpected) { if strings.Contains(string(content), unexpected) {
t.Fatalf("generated delivery contains unexpected %q", unexpected) t.Fatalf("generated delivery contains unexpected %q", unexpected)
} }
@ -190,10 +185,14 @@ func TestGeneratedDeliveryInitializesStagingAndPromotesByPullRequest(t *testing.
} }
} }
func TestRenderAppDeliveryRequiresCompleteConfig(t *testing.T) { func TestGenerateAppDeliveryRequiresCompleteConfig(t *testing.T) {
_, err := renderAppDelivery(config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test"}, Delivery: config.DeliveryConfig{AppName: "legacy-app"}}) dir := t.TempDir()
if err == nil || !strings.Contains(err.Error(), "delivery buildStrategy") { err := GenerateAppDelivery(dir, config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test"}, Delivery: config.DeliveryConfig{AppName: "legacy-app"}})
t.Fatalf("renderAppDelivery() error = %v, want incomplete delivery error", err) if err == nil || !strings.Contains(err.Error(), "delivery appName") {
t.Fatalf("GenerateAppDelivery() error = %v, want incomplete delivery error", err)
}
if _, statErr := os.Stat(filepath.Join(dir, ".tekton")); !os.IsNotExist(statErr) {
t.Fatal("GenerateAppDelivery() wrote delivery files before rejecting incomplete config")
} }
} }

View file

@ -7,7 +7,6 @@ import (
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"github.com/Pingu-Studio/MaidnCLI/internal/config" "github.com/Pingu-Studio/MaidnCLI/internal/config"
"github.com/Pingu-Studio/MaidnCLI/internal/forgejo" "github.com/Pingu-Studio/MaidnCLI/internal/forgejo"
@ -19,6 +18,8 @@ type onboardingRepoManager interface {
RemoteBranchRevision(string, string) (string, error) RemoteBranchRevision(string, string) (string, error)
PushRef(string, string, string, string) error PushRef(string, string, string, string) error
EnsureProtectedBranch(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) PublishRepositoryPullRequest(string, string, string, string, func(string) error) (bool, error)
EnsureWebhook(string, string, string) error EnsureWebhook(string, string, string) error
} }
@ -27,7 +28,7 @@ var newOnboardingRepoManager = func(baseURL, token, owner, username, manifestsRe
return forgejo.NewRepoManager(baseURL, token, owner, username, manifestsRepo, fluxRepo, branch, migrationBranch) return forgejo.NewRepoManager(baseURL, token, owner, username, manifestsRepo, fluxRepo, branch, migrationBranch)
} }
// OnboardApp opens a reviewed central cluster-registration pull request. // OnboardApp opens reviewed source-delivery and cluster-registration pull requests.
func OnboardApp(cfg config.Config, sourceDir string) error { func OnboardApp(cfg config.Config, sourceDir string) error {
resolved, err := config.ResolveAppOnboarding(cfg) resolved, err := config.ResolveAppOnboarding(cfg)
if err != nil { if err != nil {
@ -43,12 +44,20 @@ func OnboardApp(cfg config.Config, sourceDir string) error {
if sourceBranch != resolved.Delivery.AppRepoRef { if sourceBranch != resolved.Delivery.AppRepoRef {
return errors.New("--from branch must match 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) owner, repository, err := forgejo.RepositoryFromURL(resolved.Delivery.AppRepoURL)
if err != nil { if err != nil {
return err 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, "") 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 { if _, err := sourceManager.EnsureRepository(repository, "Application source for Maidn CI/CD delivery"); err != nil {
return err return err
} }
if err := publishInitialAppBranches(sourceManager, sourceDir, resolved.Delivery.AppRepoURL, sourceBranch, resolved.Delivery.AppRepoRef, resolved.Delivery.ProductionBranch); err != nil { if err := publishInitialAppBranches(sourceManager, sourceDir, resolved.Delivery.AppRepoURL, sourceBranch, resolved.Delivery.AppRepoRef, resolved.Delivery.ProductionBranch); err != nil {
@ -57,10 +66,25 @@ func OnboardApp(cfg config.Config, sourceDir string) error {
if err := sourceManager.EnsureProtectedBranch(repository, resolved.Delivery.ProductionBranch); err != nil { if err := sourceManager.EnsureProtectedBranch(repository, resolved.Delivery.ProductionBranch); err != nil {
return fmt.Errorf("protect Forgejo production branch: %w", err) 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 registrationBranch := "maidn/register-" + resolved.Delivery.AppName
clusterManager := newOnboardingRepoManager(resolved.Git.BaseURL, resolved.Git.Token, resolved.Git.Owner, resolved.Git.Username, "", "", resolved.Flux.Branch, "") 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 { 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) return registerAppInCluster(dir, resolved, hasAddons || hasRuntimeSecretGrant(resolved))
}); err != nil { }); err != nil {
return fmt.Errorf("register app in cluster repository: %w", err) return fmt.Errorf("register app in cluster repository: %w", err)
} }
@ -83,7 +107,15 @@ func OnboardApp(cfg config.Config, sourceDir string) error {
return nil return nil
} }
// publishInitialAppBranches establishes the immutable source baseline before central registration. 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 { func publishInitialAppBranches(manager onboardingRepoManager, sourceDir, targetURL, sourceBranch, targetBranch, productionBranch string) error {
sourceRevision, err := forgejo.BranchRevision(sourceDir, sourceBranch) sourceRevision, err := forgejo.BranchRevision(sourceDir, sourceBranch)
if err != nil { if err != nil {
@ -125,11 +157,11 @@ func publishInitialAppBranches(manager onboardingRepoManager, sourceDir, targetU
// RegisterAppInCluster writes only the managed Flux registration for one app. // RegisterAppInCluster writes only the managed Flux registration for one app.
func RegisterAppInCluster(dir string, cfg config.Config) error { func RegisterAppInCluster(dir string, cfg config.Config) error {
return registerAppInCluster(dir, cfg) return registerAppInCluster(dir, cfg, hasRuntimeSecretGrant(cfg))
} }
func registerAppInCluster(dir string, cfg config.Config) error { func registerAppInCluster(dir string, cfg config.Config, hasAddons bool) error {
content, err := renderAppRegistration(cfg) content, err := renderAppRegistrationWithAddons(cfg, hasAddons)
if err != nil { if err != nil {
return err return err
} }
@ -187,14 +219,14 @@ func registerAppInCluster(dir string, cfg config.Config) error {
} }
func renderAppRegistration(cfg config.Config) ([]byte, error) { 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 { if err := config.ValidateDelivery(cfg); err != nil {
return nil, err return nil, err
} }
secretAccess, err := renderAppSecretAccess(cfg) branch, err := forgejo.DeliveryBranch(cfg.Delivery.AppName, cfg.Delivery.AppRepoRef)
if err != nil {
return nil, err
}
delivery, err := renderAppDelivery(cfg)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -209,26 +241,68 @@ spec:
secretRef: secretRef:
name: forgejo-flux-credentials name: forgejo-flux-credentials
ref: ref:
branch: maidn/platform-%s branch: %s
`, cfg.Delivery.AppName, cfg.Delivery.AppRepoURL, cfg.Delivery.AppName) ---
if len(secretAccess) != 0 { apiVersion: kustomize.toolkit.fluxcd.io/v1
content += "---\n" + string(secretAccess) 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 append([]byte(content+"---\n"), delivery...), nil return []byte(content), nil
} }
// renderAppSecretAccess renders only central OpenBao references, never secret values. func hasRuntimeSecretGrant(cfg config.Config) bool {
func renderAppSecretAccess(cfg config.Config) ([]byte, error) { for _, grant := range cfg.SecretGrants {
if err := config.ValidateSecretGrants(cfg.SecretGrants); err != nil { if grant.Application == cfg.Delivery.AppName && grant.Consumer == "runtime" {
return nil, err return true
} }
var manifests []string }
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 { for _, grant := range cfg.SecretGrants {
if grant.Application != cfg.Delivery.AppName || grant.Consumer != "runtime" { if grant.Application != cfg.Delivery.AppName || grant.Consumer != "runtime" {
continue continue
} }
name := cfg.Delivery.AppName + "-runtime-" + grant.Environment name := cfg.Delivery.AppName + "-runtime-" + grant.Environment
manifest := fmt.Sprintf(`apiVersion: v1 manifests += fmt.Sprintf(`apiVersion: v1
kind: ServiceAccount kind: ServiceAccount
metadata: metadata:
name: maidn-%s name: maidn-%s
@ -268,11 +342,35 @@ spec:
data: data:
`, name, grant.Environment, name, grant.Environment, name, name, name, grant.Environment, name, name) `, name, grant.Environment, name, grant.Environment, name, name, name, grant.Environment, name, name)
for _, secret := range grant.Secrets { 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 += fmt.Sprintf(" - secretKey: %s\n remoteRef:\n key: apps/%s/%s\n property: value\n", secret, grant.Application, secret)
} }
manifests = append(manifests, manifest) manifests += "---\n"
} }
return []byte(strings.Join(manifests, "---\n")), nil 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) { func readRegularFile(path string) ([]byte, error) {

View file

@ -38,7 +38,45 @@ func onboardingConfig() config.Config {
} }
} }
func TestRegisterAppInClusterRendersCentralDeliveryResources(t *testing.T) { func TestGenerateAppDeliveryReplacesOnlyKnownGeneratedFiles(t *testing.T) {
dir := t.TempDir()
tektonDir := filepath.Join(dir, ".tekton")
if err := os.Mkdir(tektonDir, 0755); err != nil {
t.Fatal(err)
}
for _, name := range []string{"kustomization.yaml", "pipeline.yaml"} {
if err := os.WriteFile(filepath.Join(tektonDir, name), []byte("old generated content\n"), 0644); err != nil {
t.Fatal(err)
}
}
if err := GenerateAppDelivery(dir, onboardingConfig()); err != nil {
t.Fatal(err)
}
pipeline, err := os.ReadFile(filepath.Join(tektonDir, "pipeline.yaml"))
normalized := strings.ReplaceAll(string(pipeline), "\r\n", "\n")
if err != nil || !strings.Contains(normalized, "https://git.example.test/test-org-2/web-ui.git") || !strings.Contains(normalized, "name: HOME\n value: /tekton/home") || !strings.Contains(normalized, "grep -qxF \" namespace: staging\"") || !strings.Contains(normalized, "namespace: production") || !strings.Contains(normalized, "name: web-ui-wait-delivery") || !strings.Contains(normalized, "name: web-ui-report-delivery") || !strings.Contains(normalized, "forgejo-delivery-status") || !strings.Contains(normalized, "$(context.pipelineRun.name)") || !strings.Contains(normalized, "Promotion PR opened or updated") || strings.Contains(normalized, "taskRunTemplate:") {
t.Fatalf("target-specific pipeline = %q, %v", pipeline, err)
}
decoder := yaml.NewDecoder(bytes.NewReader(pipeline))
for {
var document yaml.Node
err := decoder.Decode(&document)
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("generated pipeline YAML: %v", err)
}
}
if err := os.WriteFile(filepath.Join(tektonDir, "custom.yaml"), []byte("custom: true\n"), 0644); err != nil {
t.Fatal(err)
}
if err := GenerateAppDelivery(dir, onboardingConfig()); err == nil || !strings.Contains(err.Error(), "unmanaged") {
t.Fatalf("custom .tekton content was accepted: %v", err)
}
}
func TestRegisterAppInClusterRendersManagedFluxSource(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
tektonDir := filepath.Join(dir, "base", "tekton") tektonDir := filepath.Join(dir, "base", "tekton")
if err := os.MkdirAll(tektonDir, 0755); err != nil { if err := os.MkdirAll(tektonDir, 0755); err != nil {
@ -51,7 +89,7 @@ func TestRegisterAppInClusterRendersCentralDeliveryResources(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
registration, err := os.ReadFile(filepath.Join(tektonDir, "apps", "web-ui.yaml")) registration, err := os.ReadFile(filepath.Join(tektonDir, "apps", "web-ui.yaml"))
if err != nil || !strings.Contains(string(registration), "branch: maidn/platform-web-ui") || !strings.Contains(string(registration), "secretRef:\n name: forgejo-flux-credentials") || !strings.Contains(string(registration), "kind: Task") || !strings.Contains(string(registration), "name: web-ui-update-manifest") || !strings.Contains(string(registration), "kind: Pipeline") || !strings.Contains(string(registration), "name: web-ui\n") || strings.Contains(string(registration), "maidn/delivery-") || strings.Contains(string(registration), "path: ./.tekton") || strings.Contains(string(registration), "apiVersion: kustomize.toolkit.fluxcd.io") { if err != nil || !strings.Contains(string(registration), "branch: maidn/delivery-web-ui") || !strings.Contains(string(registration), "secretRef:\n name: forgejo-flux-credentials") || !strings.Contains(string(registration), "dependsOn:\n - name: tekton-catalog") || !strings.Contains(string(registration), "path: ./.tekton") {
t.Fatalf("registration = %q, %v", registration, err) t.Fatalf("registration = %q, %v", registration, err)
} }
for path, resource := range map[string]string{filepath.Join(tektonDir, "kustomization.yaml"): "apps", filepath.Join(tektonDir, "apps", "kustomization.yaml"): "web-ui.yaml"} { for path, resource := range map[string]string{filepath.Join(tektonDir, "kustomization.yaml"): "apps", filepath.Join(tektonDir, "apps", "kustomization.yaml"): "web-ui.yaml"} {
@ -85,15 +123,56 @@ func TestRegisterAppInClusterRendersCentralDeliveryResources(t *testing.T) {
} }
} }
func TestRenderAppRegistrationIncludesOnlyDeclaredRuntimeSecretAccess(t *testing.T) { func TestGenerateAppSecretAccessRendersOnlyDeclaredRuntimeSecrets(t *testing.T) {
dir := t.TempDir()
cfg := onboardingConfig() cfg := onboardingConfig()
cfg.SecretGrants = []config.SecretGrant{{Application: "web-ui", Consumer: "runtime", Environment: "staging", Secrets: []string{"api-key"}, Shared: []string{"payments"}}} cfg.SecretGrants = []config.SecretGrant{{Application: "web-ui", Consumer: "runtime", Environment: "staging", Secrets: []string{"api-key"}, Shared: []string{"payments"}}}
if err := os.Mkdir(filepath.Join(dir, ".maidn"), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, ".maidn", "kustomization.yaml"), []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n - database.yaml\n"), 0644); err != nil {
t.Fatal(err)
}
if err := GenerateAppSecretAccess(dir, cfg); err != nil {
t.Fatal(err)
}
access, err := os.ReadFile(filepath.Join(dir, ".maidn", "secret-access.yaml"))
if err != nil || !strings.Contains(string(access), "namespace: staging") || !strings.Contains(string(access), "key: apps/web-ui/api-key") || strings.Contains(string(access), "shared/payments") {
t.Fatalf("secret access = %q, %v", access, err)
}
kustomization, err := os.ReadFile(filepath.Join(dir, ".maidn", "kustomization.yaml"))
if err != nil || !strings.Contains(string(kustomization), "database.yaml") || !strings.Contains(string(kustomization), "secret-access.yaml") {
t.Fatalf("secret Kustomization = %q, %v", kustomization, err)
}
registration, err := renderAppRegistration(cfg) registration, err := renderAppRegistration(cfg)
if err != nil || !strings.Contains(string(registration), "namespace: staging") || !strings.Contains(string(registration), "key: apps/web-ui/api-key") || strings.Contains(string(registration), "shared/payments") || strings.Contains(string(registration), ".maidn") { if err != nil || !strings.Contains(string(registration), "name: web-ui-addons") || !strings.Contains(string(registration), "path: ./.maidn") {
t.Fatalf("secret registration = %q, %v", registration, err) t.Fatalf("secret registration = %q, %v", registration, err)
} }
} }
func TestRenderAppRegistrationIncludesSourceAddons(t *testing.T) {
registration, err := renderAppRegistrationWithAddons(onboardingConfig(), true)
if err != nil || !strings.Contains(string(registration), "name: web-ui-addons") || !strings.Contains(string(registration), "path: ./.maidn") || !strings.Contains(string(registration), "external-secrets-config") {
t.Fatalf("addon registration = %q, %v", registration, err)
}
}
func TestHasMaidnAddons(t *testing.T) {
dir := t.TempDir()
if hasAddons, err := hasMaidnAddons(dir); err != nil || hasAddons {
t.Fatalf("absent add-ons = %t, %v", hasAddons, err)
}
if err := os.Mkdir(filepath.Join(dir, ".maidn"), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, ".maidn", "kustomization.yaml"), []byte("apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n"), 0644); err != nil {
t.Fatal(err)
}
if hasAddons, err := hasMaidnAddons(dir); err != nil || !hasAddons {
t.Fatalf("present add-ons = %t, %v", hasAddons, err)
}
}
func TestPublishInitialAppBranchesCreatesAndPreservesProduction(t *testing.T) { func TestPublishInitialAppBranchesCreatesAndPreservesProduction(t *testing.T) {
source := filepath.Join(t.TempDir(), "source") source := filepath.Join(t.TempDir(), "source")
target := filepath.Join(t.TempDir(), "target.git") target := filepath.Join(t.TempDir(), "target.git")
@ -182,6 +261,16 @@ func (m *onboardingManagerFake) EnsureProtectedBranch(repo, branch string) error
return nil return nil
} }
func (m *onboardingManagerFake) PublishDeliveryBranch(_, _, targetURL, branch string, _ func(string) error) (bool, error) {
m.calls = append(m.calls, "delivery "+targetURL+":"+branch)
return false, nil
}
func (m *onboardingManagerFake) EnsurePullRequest(repo, _, branch, _ string) error {
m.calls = append(m.calls, "ensure-pr "+m.owner+"/"+repo+":"+branch)
return nil
}
func (m *onboardingManagerFake) HasOpenPullRequest(repo, branch string) (bool, error) { func (m *onboardingManagerFake) HasOpenPullRequest(repo, branch string) (bool, error) {
m.calls = append(m.calls, "open-pr "+m.owner+"/"+repo+":"+branch) m.calls = append(m.calls, "open-pr "+m.owner+"/"+repo+":"+branch)
return false, nil return false, nil
@ -207,7 +296,7 @@ func (m *onboardingManagerFake) TriggerWebhookTest(repo, _, branch string) error
return nil return nil
} }
func TestOnboardAppUsesCanonicalSourceAndCentralClusterManagers(t *testing.T) { func TestOnboardAppUsesCanonicalSourceAndExecutionClusterManagers(t *testing.T) {
source := filepath.Join(t.TempDir(), "source") source := filepath.Join(t.TempDir(), "source")
if err := os.Mkdir(source, 0755); err != nil { if err := os.Mkdir(source, 0755); err != nil {
t.Fatal(err) t.Fatal(err)
@ -255,13 +344,14 @@ func TestOnboardAppUsesCanonicalSourceAndCentralClusterManagers(t *testing.T) {
"remote https://git.example.test/Maidn/maidn-e2e-web.git:main", "remote https://git.example.test/Maidn/maidn-e2e-web.git:main",
"remote https://git.example.test/Maidn/maidn-e2e-web.git:production", "remote https://git.example.test/Maidn/maidn-e2e-web.git:production",
"protect Maidn/maidn-e2e-web:production", "protect Maidn/maidn-e2e-web:production",
"delivery https://git.example.test/Maidn/maidn-e2e-web.git:maidn/delivery-web-ui",
} { } {
if !strings.Contains(sourceCalls, want) { if !strings.Contains(sourceCalls, want) {
t.Fatalf("source manager calls = %q, missing %q", sourceCalls, want) t.Fatalf("source manager calls = %q, missing %q", sourceCalls, want)
} }
} }
if strings.Contains(sourceCalls, "delivery") || strings.Contains(sourceCalls, "ensure-pr") || strings.Contains(sourceCalls, "merge-pr") { if strings.Contains(sourceCalls, "merge-pr") {
t.Fatalf("source manager published a delivery change: %q", sourceCalls) t.Fatalf("source delivery PR was merged without review: %q", sourceCalls)
} }
if got := strings.Join(clusterManager.calls, "\n"); got != "register test-org-2/cluster:maidn/register-web-ui" { if got := strings.Join(clusterManager.calls, "\n"); got != "register test-org-2/cluster:maidn/register-web-ui" {
t.Fatalf("cluster manager calls = %q", got) t.Fatalf("cluster manager calls = %q", got)

View file

@ -508,6 +508,77 @@ func (rm *RepoManager) PushRef(dir, repoURL, sourceRef, targetBranch string) err
return runGit(dir, environment, "push", repoURL, sourceRef+":refs/heads/"+targetBranch) return runGit(dir, environment, "push", repoURL, sourceRef+":refs/heads/"+targetBranch)
} }
// DeliveryBranch returns the dedicated branch that carries generated delivery content.
func DeliveryBranch(appName, baseBranch string) (string, error) {
branch := "maidn/delivery-" + appName
if appName == "" || branch == baseBranch {
return "", fmt.Errorf("delivery branch and configured base branch must differ")
}
return branch, nil
}
// PublishDeliveryBranch generates and commits delivery content in a temporary clone.
func (rm *RepoManager) PublishDeliveryBranch(sourceDir, sourceBranch, repoURL, deliveryBranch string, generate func(string) error) (bool, error) {
if sourceDir == "" || sourceBranch == "" || deliveryBranch == "" || deliveryBranch == rm.Branch {
return false, fmt.Errorf("delivery source branch and dedicated delivery branch are required and must differ from the base branch")
}
hasBranch, err := rm.HasRemoteBranch(repoURL, deliveryBranch)
if err != nil {
return false, err
}
temporary, err := os.MkdirTemp("", "maidn-delivery-*")
if err != nil {
return false, err
}
defer os.RemoveAll(temporary)
cleanupAskPass, environment, err := rm.gitEnvironment()
if err != nil {
return false, err
}
defer cleanupAskPass()
if hasBranch {
if err := runGit("", environment, "clone", "--branch", deliveryBranch, repoURL, temporary); err != nil {
return false, err
}
} else if err := runGit("", os.Environ(), "clone", "--no-local", "--branch", sourceBranch, sourceDir, temporary); err != nil {
return false, err
}
if err := runGit(temporary, environment, "checkout", "-B", deliveryBranch); err != nil {
return false, err
}
if err := generate(temporary); err != nil {
return false, err
}
if err := runGit(temporary, environment, "add", ".tekton"); err != nil {
return false, err
}
if _, err := os.Stat(filepath.Join(temporary, ".maidn")); err == nil {
if err := runGit(temporary, environment, "add", ".maidn"); err != nil {
return false, err
}
} else if !os.IsNotExist(err) {
return false, err
}
changed, err := gitDiffQuiet(temporary, environment, "--cached")
if err != nil {
return false, err
}
if changed {
for _, args := range [][]string{{"config", "user.name", "Maidn"}, {"config", "user.email", "maidn@free-maidn.com"}, {"commit", "-m", "feat: add Maidn delivery pipeline"}} {
if err := runGit(temporary, environment, args...); err != nil {
return false, err
}
}
}
if !changed {
return false, nil
}
if err := rm.PushBranch(temporary, repoURL, deliveryBranch); err != nil {
return false, err
}
return true, nil
}
// PublishRepositoryPullRequest applies a managed change on a dedicated branch. // PublishRepositoryPullRequest applies a managed change on a dedicated branch.
// An existing branch is accepted only when it has exactly one open pull request. // An existing branch is accepted only when it has exactly one open pull request.
func (rm *RepoManager) PublishRepositoryPullRequest(repo, title, branch, base string, change func(string) error) (bool, error) { func (rm *RepoManager) PublishRepositoryPullRequest(repo, title, branch, base string, change func(string) error) (bool, error) {