feat: render central app delivery resources
This commit is contained in:
parent
719abc3702
commit
fd1a88b1b3
|
|
@ -32,7 +32,7 @@ var appCmd = &cobra.Command{
|
|||
|
||||
var appOnboardCmd = &cobra.Command{
|
||||
Use: "onboard",
|
||||
Short: "Validate an application checkout and add its source-owned delivery contract.",
|
||||
Short: "Validate an application checkout and add its centrally-owned delivery contract.",
|
||||
RunE: runAppOnboard,
|
||||
}
|
||||
|
||||
|
|
@ -50,7 +50,7 @@ func init() {
|
|||
rootCmd.AddCommand(appCmd)
|
||||
appCmd.AddCommand(appOnboardCmd)
|
||||
appOnboardCmd.Flags().StringVar(&onboardConfigPath, "config", "", "Path to private bootstrap config YAML")
|
||||
appOnboardCmd.Flags().StringVar(&onboardFrom, "from", "", "Clean application checkout to scaffold")
|
||||
appOnboardCmd.Flags().StringVar(&onboardFrom, "from", "", "Clean application checkout to validate")
|
||||
appOnboardCmd.Flags().StringVar(&onboardAppName, "app-name", "", "Application name override")
|
||||
appOnboardCmd.Flags().StringVar(&onboardAppRepoURL, "app-repo-url", "", "Application repository URL override")
|
||||
appOnboardCmd.Flags().StringVar(&onboardImageRepository, "image-repository", "", "OCI image repository override")
|
||||
|
|
|
|||
|
|
@ -43,12 +43,15 @@ creates the Gateway, Tekton, and Tekton Triggers platform resources using
|
|||
go run . bootstrap init --config <private-bootstrap-config> --organization <owner> --create-organization
|
||||
```
|
||||
|
||||
The source-owned onboarding implementation is being retired. Do not onboard a
|
||||
new application until the central delivery renderer is available. See
|
||||
[Delivery Ownership](architecture/delivery-ownership.md) for the approved
|
||||
architecture and migration rules.
|
||||
The source-owned onboarding implementation is retired. Central onboarding
|
||||
creates a reviewed cluster registration only; it never writes `.tekton` or
|
||||
`.maidn` resources to an application repository. Before merging that
|
||||
registration, a platform operator must create and protect the corresponding
|
||||
`maidn/platform-<app>` package through a reviewed platform PR. See [Delivery
|
||||
Ownership](architecture/delivery-ownership.md) for the approved architecture
|
||||
and migration rules.
|
||||
|
||||
The future onboarding command uses a clean checkout on `delivery.appRepoRef`.
|
||||
The onboarding command uses a clean checkout on `delivery.appRepoRef`.
|
||||
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
|
||||
values remain command-line overrides rather than private defaults:
|
||||
|
|
@ -61,10 +64,10 @@ go run . app onboard --config <private-bootstrap-config> --from <clean-checkout>
|
|||
```
|
||||
|
||||
For a runtime build, set `--build-strategy runtime`; static-only output options
|
||||
remain harmless. Central onboarding will create reviewed platform-branch and
|
||||
cluster-registration changes. The cluster repository will own
|
||||
`base/tekton/apps/<app>.yaml`; the application repository will remain a build
|
||||
input only. The generic EventListener dispatches by Forgejo repository name.
|
||||
remain harmless. Central onboarding creates the reviewed cluster-registration
|
||||
change after the platform package exists. The cluster repository owns
|
||||
`base/tekton/apps/<app>.yaml`; the application repository remains a build input
|
||||
only. The generic EventListener dispatches by Forgejo repository name.
|
||||
|
||||
Existing source-owned registrations are migrated in separate reviewed cluster
|
||||
repository PRs. Never overwrite an unmanaged registration.
|
||||
|
|
|
|||
|
|
@ -27,9 +27,9 @@ spec:
|
|||
- name: environment
|
||||
- name: pr-number
|
||||
default: ""
|
||||
- name: app-url
|
||||
- name: platform-url
|
||||
default: ""
|
||||
- name: app-revision
|
||||
- name: platform-revision
|
||||
default: ""
|
||||
- name: forgejo-base-url
|
||||
default: ""
|
||||
|
|
@ -57,10 +57,10 @@ spec:
|
|||
value: $(params.environment)
|
||||
- name: PR_NUMBER
|
||||
value: $(params.pr-number)
|
||||
- name: APP_URL
|
||||
value: $(params.app-url)
|
||||
- name: APP_REVISION
|
||||
value: $(params.app-revision)
|
||||
- name: PLATFORM_URL
|
||||
value: $(params.platform-url)
|
||||
- name: PLATFORM_REVISION
|
||||
value: $(params.platform-revision)
|
||||
- name: FORGEJO_BASE_URL
|
||||
value: $(params.forgejo-base-url)
|
||||
- name: FORGEJO_OWNER
|
||||
|
|
@ -102,8 +102,8 @@ spec:
|
|||
cd /tmp/manifests
|
||||
if [ "$ENVIRONMENT" = preview ]; then
|
||||
valid_pr_number "$PR_NUMBER"
|
||||
valid_url "$APP_URL"
|
||||
valid_revision "$APP_REVISION"
|
||||
valid_url "$PLATFORM_URL"
|
||||
valid_revision "$PLATFORM_REVISION"
|
||||
app_dir="apps/previews/$APP_NAME-pr-$PR_NUMBER"
|
||||
marker="$app_dir/ownership.yaml"
|
||||
if [ -e "$app_dir" ]; then
|
||||
|
|
@ -129,11 +129,11 @@ spec:
|
|||
EOF
|
||||
cmp -s "$expected_marker" "$marker" || fail
|
||||
fi
|
||||
git clone "$APP_URL" /tmp/app
|
||||
git -C /tmp/app checkout "$APP_REVISION"
|
||||
[ -f /tmp/app/preview/values.yaml ] || fail
|
||||
git clone "$PLATFORM_URL" /tmp/platform
|
||||
git -C /tmp/platform checkout "$PLATFORM_REVISION"
|
||||
[ -f /tmp/platform/preview/values.yaml ] || fail
|
||||
mkdir -p "$app_dir"
|
||||
cp /tmp/app/preview/values.yaml "$app_dir/values.yaml"
|
||||
cp /tmp/platform/preview/values.yaml "$app_dir/values.yaml"
|
||||
sed -i "s/PLACEHOLDER_PR/$PR_NUMBER/g" "$app_dir/values.yaml"
|
||||
cat > "$marker" <<EOF
|
||||
apiVersion: v1
|
||||
|
|
@ -628,6 +628,10 @@ spec:
|
|||
default: {{ quote .ForgejoOwner }}
|
||||
- name: manifests-repo
|
||||
default: {{ quote .ManifestsRepo }}
|
||||
- name: platform-url
|
||||
default: {{ quote .AppRepoURL }}
|
||||
- name: platform-revision
|
||||
default: {{ quote .PlatformBranch }}
|
||||
tasks:
|
||||
- name: build-layer
|
||||
when:
|
||||
|
|
@ -677,10 +681,10 @@ spec:
|
|||
value: preview
|
||||
- name: pr-number
|
||||
value: $(params.pr-number)
|
||||
- name: app-url
|
||||
value: {{ quote .AppRepoURL }}
|
||||
- name: app-revision
|
||||
value: $(params.git-revision)
|
||||
- name: platform-url
|
||||
value: $(params.platform-url)
|
||||
- name: platform-revision
|
||||
value: $(params.platform-revision)
|
||||
- name: update-staging
|
||||
runAfter: [build-layer]
|
||||
when:
|
||||
|
|
|
|||
|
|
@ -622,6 +622,7 @@ type appDeliveryTemplateConfig struct {
|
|||
AppRepository string
|
||||
AppRepoURL string
|
||||
AppRepoRef string
|
||||
PlatformBranch string
|
||||
ProductionBranch string
|
||||
ImageRepository string
|
||||
BuildStrategy string
|
||||
|
|
@ -636,60 +637,6 @@ type appDeliveryTemplateConfig struct {
|
|||
|
||||
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) {
|
||||
if !deliveryAppName.MatchString(cfg.Delivery.AppName) {
|
||||
return nil, errors.New("delivery appName must be a lowercase DNS label")
|
||||
|
|
@ -707,11 +654,11 @@ func renderAppDelivery(cfg config.Config) ([]byte, error) {
|
|||
}
|
||||
values := appDeliveryTemplateConfig{
|
||||
AppName: cfg.Delivery.AppName, AppRepository: appRepository, AppRepoURL: cfg.Delivery.AppRepoURL,
|
||||
AppRepoRef: cfg.Delivery.AppRepoRef, ProductionBranch: cfg.Delivery.ProductionBranch, ImageRepository: cfg.Delivery.ImageRepository,
|
||||
AppRepoRef: cfg.Delivery.AppRepoRef, PlatformBranch: "maidn/platform-" + cfg.Delivery.AppName, ProductionBranch: cfg.Delivery.ProductionBranch, ImageRepository: cfg.Delivery.ImageRepository,
|
||||
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,
|
||||
}
|
||||
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} {
|
||||
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} {
|
||||
if value == "" || strings.ContainsAny(value, "\r\n") || config.RedactURL(value) != value {
|
||||
return nil, fmt.Errorf("delivery %s cannot be empty or contain credentials", name)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,7 +123,12 @@ func TestGeneratedDeliveryUsesCombinedStaticBuildArtifactContract(t *testing.T)
|
|||
t.Fatalf("generated delivery does not contain %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"} {
|
||||
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"} {
|
||||
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) {
|
||||
t.Fatalf("generated delivery contains unexpected %q", unexpected)
|
||||
}
|
||||
|
|
@ -132,8 +137,8 @@ func TestGeneratedDeliveryUsesCombinedStaticBuildArtifactContract(t *testing.T)
|
|||
|
||||
func TestGeneratedDeliveryUsesRuntimeImageWithoutStaticParameters(t *testing.T) {
|
||||
cfg := config.Config{
|
||||
Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "platform"},
|
||||
Flux: config.FluxConfig{Branch: "main", ManifestsRepo: "manifests"},
|
||||
Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "platform"},
|
||||
Flux: config.FluxConfig{Branch: "main", ManifestsRepo: "manifests"},
|
||||
Delivery: config.DeliveryConfig{AppName: "web-ui", AppRepoURL: "https://git.example.test/apps/web-ui.git", AppRepoRef: "main", ProductionBranch: "production", ImageRepository: "registry.example.test/apps/web-ui", BuildStrategy: "runtime", BuildOutputDirectory: "dist/web-ui", BuildConfiguration: "production"},
|
||||
}
|
||||
content, err := renderAppDelivery(cfg)
|
||||
|
|
@ -185,14 +190,10 @@ func TestGeneratedDeliveryInitializesStagingAndPromotesByPullRequest(t *testing.
|
|||
}
|
||||
}
|
||||
|
||||
func TestGenerateAppDeliveryRequiresCompleteConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := GenerateAppDelivery(dir, config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test"}, Delivery: config.DeliveryConfig{AppName: "legacy-app"}})
|
||||
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")
|
||||
func TestRenderAppDeliveryRequiresCompleteConfig(t *testing.T) {
|
||||
_, err := renderAppDelivery(config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test"}, Delivery: config.DeliveryConfig{AppName: "legacy-app"}})
|
||||
if err == nil || !strings.Contains(err.Error(), "delivery buildStrategy") {
|
||||
t.Fatalf("renderAppDelivery() error = %v, want incomplete delivery error", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
||||
"github.com/Pingu-Studio/MaidnCLI/internal/forgejo"
|
||||
|
|
@ -18,8 +19,6 @@ type onboardingRepoManager interface {
|
|||
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
|
||||
}
|
||||
|
|
@ -28,7 +27,7 @@ var newOnboardingRepoManager = func(baseURL, token, owner, username, manifestsRe
|
|||
return forgejo.NewRepoManager(baseURL, token, owner, username, manifestsRepo, fluxRepo, branch, migrationBranch)
|
||||
}
|
||||
|
||||
// OnboardApp opens reviewed source-delivery and cluster-registration pull requests.
|
||||
// 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 {
|
||||
|
|
@ -44,20 +43,12 @@ func OnboardApp(cfg config.Config, sourceDir string) error {
|
|||
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 {
|
||||
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 {
|
||||
|
|
@ -66,25 +57,10 @@ func OnboardApp(cfg config.Config, sourceDir string) error {
|
|||
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))
|
||||
return registerAppInCluster(dir, resolved)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("register app in cluster repository: %w", err)
|
||||
}
|
||||
|
|
@ -107,15 +83,7 @@ func OnboardApp(cfg config.Config, sourceDir string) error {
|
|||
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.
|
||||
// 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 {
|
||||
|
|
@ -157,11 +125,11 @@ func publishInitialAppBranches(manager onboardingRepoManager, sourceDir, targetU
|
|||
|
||||
// RegisterAppInCluster writes only the managed Flux registration for one app.
|
||||
func RegisterAppInCluster(dir string, cfg config.Config) error {
|
||||
return registerAppInCluster(dir, cfg, hasRuntimeSecretGrant(cfg))
|
||||
return registerAppInCluster(dir, cfg)
|
||||
}
|
||||
|
||||
func registerAppInCluster(dir string, cfg config.Config, hasAddons bool) error {
|
||||
content, err := renderAppRegistrationWithAddons(cfg, hasAddons)
|
||||
func registerAppInCluster(dir string, cfg config.Config) error {
|
||||
content, err := renderAppRegistration(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -219,14 +187,14 @@ func registerAppInCluster(dir string, cfg config.Config, hasAddons bool) 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 {
|
||||
return nil, err
|
||||
}
|
||||
branch, err := forgejo.DeliveryBranch(cfg.Delivery.AppName, cfg.Delivery.AppRepoRef)
|
||||
secretAccess, err := renderAppSecretAccess(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
delivery, err := renderAppDelivery(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -241,68 +209,26 @@ spec:
|
|||
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)
|
||||
branch: maidn/platform-%s
|
||||
`, cfg.Delivery.AppName, cfg.Delivery.AppRepoURL, cfg.Delivery.AppName)
|
||||
if len(secretAccess) != 0 {
|
||||
content += "---\n" + string(secretAccess)
|
||||
}
|
||||
return []byte(content), nil
|
||||
return append([]byte(content+"---\n"), delivery...), 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 {
|
||||
// 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 err
|
||||
return nil, err
|
||||
}
|
||||
var manifests string
|
||||
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
|
||||
manifest := fmt.Sprintf(`apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: maidn-%s
|
||||
|
|
@ -342,35 +268,11 @@ spec:
|
|||
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)
|
||||
manifest += fmt.Sprintf(" - secretKey: %s\n remoteRef:\n key: apps/%s/%s\n property: value\n", secret, grant.Application, secret)
|
||||
}
|
||||
manifests += "---\n"
|
||||
manifests = append(manifests, manifest)
|
||||
}
|
||||
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)
|
||||
return []byte(strings.Join(manifests, "---\n")), nil
|
||||
}
|
||||
|
||||
func readRegularFile(path string) ([]byte, error) {
|
||||
|
|
|
|||
|
|
@ -38,45 +38,7 @@ func onboardingConfig() config.Config {
|
|||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
func TestRegisterAppInClusterRendersCentralDeliveryResources(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
tektonDir := filepath.Join(dir, "base", "tekton")
|
||||
if err := os.MkdirAll(tektonDir, 0755); err != nil {
|
||||
|
|
@ -89,7 +51,7 @@ func TestRegisterAppInClusterRendersManagedFluxSource(t *testing.T) {
|
|||
t.Fatal(err)
|
||||
}
|
||||
registration, err := os.ReadFile(filepath.Join(tektonDir, "apps", "web-ui.yaml"))
|
||||
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") {
|
||||
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") {
|
||||
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"} {
|
||||
|
|
@ -123,56 +85,15 @@ func TestRegisterAppInClusterRendersManagedFluxSource(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGenerateAppSecretAccessRendersOnlyDeclaredRuntimeSecrets(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
func TestRenderAppRegistrationIncludesOnlyDeclaredRuntimeSecretAccess(t *testing.T) {
|
||||
cfg := onboardingConfig()
|
||||
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)
|
||||
if err != nil || !strings.Contains(string(registration), "name: web-ui-addons") || !strings.Contains(string(registration), "path: ./.maidn") {
|
||||
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") {
|
||||
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) {
|
||||
source := filepath.Join(t.TempDir(), "source")
|
||||
target := filepath.Join(t.TempDir(), "target.git")
|
||||
|
|
@ -261,16 +182,6 @@ func (m *onboardingManagerFake) EnsureProtectedBranch(repo, branch string) error
|
|||
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) {
|
||||
m.calls = append(m.calls, "open-pr "+m.owner+"/"+repo+":"+branch)
|
||||
return false, nil
|
||||
|
|
@ -296,7 +207,7 @@ func (m *onboardingManagerFake) TriggerWebhookTest(repo, _, branch string) error
|
|||
return nil
|
||||
}
|
||||
|
||||
func TestOnboardAppUsesCanonicalSourceAndExecutionClusterManagers(t *testing.T) {
|
||||
func TestOnboardAppUsesCanonicalSourceAndCentralClusterManagers(t *testing.T) {
|
||||
source := filepath.Join(t.TempDir(), "source")
|
||||
if err := os.Mkdir(source, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
|
|
@ -344,14 +255,13 @@ func TestOnboardAppUsesCanonicalSourceAndExecutionClusterManagers(t *testing.T)
|
|||
"remote https://git.example.test/Maidn/maidn-e2e-web.git:main",
|
||||
"remote https://git.example.test/Maidn/maidn-e2e-web.git: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) {
|
||||
t.Fatalf("source manager calls = %q, missing %q", sourceCalls, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(sourceCalls, "merge-pr") {
|
||||
t.Fatalf("source delivery PR was merged without review: %q", sourceCalls)
|
||||
if strings.Contains(sourceCalls, "delivery") || strings.Contains(sourceCalls, "ensure-pr") || strings.Contains(sourceCalls, "merge-pr") {
|
||||
t.Fatalf("source manager published a delivery change: %q", sourceCalls)
|
||||
}
|
||||
if got := strings.Join(clusterManager.calls, "\n"); got != "register test-org-2/cluster:maidn/register-web-ui" {
|
||||
t.Fatalf("cluster manager calls = %q", got)
|
||||
|
|
|
|||
|
|
@ -508,77 +508,6 @@ func (rm *RepoManager) PushRef(dir, repoURL, sourceRef, targetBranch string) err
|
|||
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.
|
||||
// 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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue