this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(toolbox): generate and push apps to OCI

+570 -98
+5 -7
Makefile
··· 32 32 --host kube-1 33 33 34 34 apps: 35 - # TODO multiple env 36 - @temporal workflow start \ 37 - --workflow-id apps-manual \ 38 - --task-queue cloudlab \ 39 - --type Apps \ 40 - --input '{ "url": "/usr/local/src/cloudlab", "revision": "master", "registry": "registry.127.0.0.1.sslip.io", "cluster": "local" }' 41 - @temporal workflow result --workflow-id apps-manual 35 + toolbox apps \ 36 + --env ${env} \ 37 + --path apps \ 38 + --hosts-file infra/_modules/nixos/hosts.json \ 39 + --host kube-1 42 40 43 41 test: 44 42 cd test && CLOUDLAB_ENV=${env} go test
+2 -1
README.md
··· 77 77 - However, the runtime doesn’t have access to Git - all manifests are pulled from an OCI registry 78 78 - Apps aka SaaS: 79 79 - Strict and standardized 80 - - Uses the rendered manifests pattern, essentially `helm template && oras push` 80 + - Generated from `apps/$NAMESPACE/$APP/$ENV.yaml` 81 + - Published to the cluster as a Flux OCI artifact 81 82 82 83 ## Estimated cost 83 84
+26
platform/staging/apps.yaml
··· 1 + apiVersion: source.toolkit.fluxcd.io/v1 2 + kind: OCIRepository 3 + metadata: 4 + name: apps 5 + namespace: flux-system 6 + spec: 7 + interval: 30s 8 + url: oci://registry.registry.svc.cluster.local:5000/apps 9 + insecure: true 10 + ref: 11 + tag: staging 12 + --- 13 + apiVersion: kustomize.toolkit.fluxcd.io/v1 14 + kind: Kustomization 15 + metadata: 16 + name: apps 17 + namespace: flux-system 18 + spec: 19 + interval: 1m 20 + dependsOn: 21 + - name: platform 22 + path: . 23 + prune: true 24 + sourceRef: 25 + kind: OCIRepository 26 + name: apps
+74
toolbox/cmd/apps.go
··· 1 + package cmd 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "os/exec" 7 + "path/filepath" 8 + 9 + "github.com/charmbracelet/log" 10 + "github.com/spf13/cobra" 11 + 12 + appbundle "github.com/khuedoan/cloudlab/toolbox/internal/apps" 13 + ) 14 + 15 + const appsRepository = "apps" 16 + 17 + var ( 18 + appsEnv string 19 + appsPath string 20 + ) 21 + 22 + func init() { 23 + appsCmd.Flags().StringVar(&appsEnv, "env", "", "Environment to generate (for example: staging)") 24 + appsCmd.Flags().StringVar(&appsPath, "path", "apps", "Path to the app values tree") 25 + _ = appsCmd.MarkFlagRequired("env") 26 + } 27 + 28 + var appsCmd = &cobra.Command{ 29 + Use: "apps", 30 + Args: cobra.NoArgs, 31 + Short: "Generate and push the apps manifest bundle", 32 + PreRunE: func(_ *cobra.Command, _ []string) error { 33 + if err := validateClusterFlags(); err != nil { 34 + return err 35 + } 36 + if _, err := exec.LookPath("flux"); err != nil { 37 + return fmt.Errorf("find flux CLI: %w", err) 38 + } 39 + return nil 40 + }, 41 + RunE: runApps, 42 + } 43 + 44 + func runApps(cmd *cobra.Command, _ []string) error { 45 + sourcePath, err := filepath.Abs(appsPath) 46 + if err != nil { 47 + return fmt.Errorf("resolve path %q: %w", appsPath, err) 48 + } 49 + 50 + releases, err := appbundle.Discover(sourcePath, appsEnv) 51 + if err != nil { 52 + return fmt.Errorf("discover apps: %w", err) 53 + } 54 + 55 + bundleDir, err := os.MkdirTemp("", "toolbox-apps-*") 56 + if err != nil { 57 + return fmt.Errorf("create temp dir: %w", err) 58 + } 59 + defer os.RemoveAll(bundleDir) 60 + 61 + if err := appbundle.WriteBundle(bundleDir, releases); err != nil { 62 + return fmt.Errorf("write bundle: %w", err) 63 + } 64 + 65 + log.Infof("generated %d app HelmRelease(s) for %s", len(releases), appsEnv) 66 + 67 + return pushArtifact(cmd.Context(), bundleDir, artifactRef{ 68 + Repository: appsRepository, 69 + Tag: appsEnv, 70 + Source: appsRepository, 71 + Revision: appsEnv, 72 + Kustomization: appsRepository, 73 + }) 74 + }
+109
toolbox/cmd/flux.go
··· 1 + package cmd 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "os/exec" 7 + "strings" 8 + "time" 9 + 10 + "github.com/charmbracelet/log" 11 + 12 + "github.com/khuedoan/cloudlab/toolbox/internal/cluster" 13 + ) 14 + 15 + const ( 16 + registryNamespace = "registry" 17 + registryService = "svc/registry" 18 + registryPort = 5000 19 + fluxNamespace = "flux-system" 20 + ) 21 + 22 + type artifactRef struct { 23 + Repository string 24 + Tag string 25 + Source string 26 + Revision string 27 + Kustomization string 28 + } 29 + 30 + func pushArtifact(ctx context.Context, manifestPath string, artifact artifactRef) error { 31 + connectCtx, cancel := context.WithTimeout(ctx, connectTimeout) 32 + defer cancel() 33 + 34 + hostAddr, err := cluster.LoadHost(hostsFile, host) 35 + if err != nil { 36 + return fmt.Errorf("load host: %w", err) 37 + } 38 + 39 + conn, err := cluster.Connect(cluster.SSHConfig{ 40 + Host: hostAddr, 41 + User: sshUser, 42 + KeyPath: sshKey, 43 + KnownHostsPath: sshKnownHosts, 44 + Timeout: connectTimeout, 45 + }) 46 + if err != nil { 47 + return fmt.Errorf("connect to cluster: %w", err) 48 + } 49 + defer conn.Close() 50 + 51 + tunnel, err := conn.Forward(connectCtx, cluster.ServiceConfig{ 52 + Namespace: registryNamespace, 53 + Name: registryService, 54 + Port: registryPort, 55 + }) 56 + if err != nil { 57 + return fmt.Errorf("forward registry: %w", err) 58 + } 59 + 60 + artifactURL := fmt.Sprintf("oci://%s/%s:%s", tunnel.LocalAddr, artifact.Repository, artifact.Tag) 61 + args := []string{ 62 + "push", 63 + "artifact", 64 + artifactURL, 65 + "--path", manifestPath, 66 + "--source", artifact.Source, 67 + "--revision", artifact.Revision, 68 + "--insecure-registry", 69 + } 70 + 71 + log.Infof("pushing %s from %s", artifactURL, manifestPath) 72 + 73 + output, err := fluxOutput(ctx, args...) 74 + if err != nil { 75 + return fmt.Errorf("push artifact: %w\n%s", err, strings.TrimSpace(string(output))) 76 + } 77 + 78 + if trimmed := strings.TrimSpace(string(output)); trimmed != "" { 79 + log.Info(trimmed) 80 + } 81 + 82 + requestedAt := time.Now().UTC().Format(time.RFC3339Nano) 83 + log.Infof("triggering Flux sync for %s/%s", fluxNamespace, artifact.Kustomization) 84 + 85 + output, err = conn.RunCommandContext( 86 + ctx, 87 + fmt.Sprintf( 88 + "kubectl annotate --overwrite -n %s ocirepository.source.toolkit.fluxcd.io/%s reconcile.fluxcd.io/requestedAt=%q && kubectl annotate --overwrite -n %s kustomization.kustomize.toolkit.fluxcd.io/%s reconcile.fluxcd.io/requestedAt=%q", 89 + fluxNamespace, artifact.Source, 90 + requestedAt, 91 + fluxNamespace, artifact.Kustomization, 92 + requestedAt, 93 + ), 94 + ) 95 + if err != nil { 96 + return fmt.Errorf("trigger flux sync: %w", err) 97 + } 98 + 99 + if trimmed := strings.TrimSpace(string(output)); trimmed != "" { 100 + log.Info(trimmed) 101 + } 102 + 103 + return nil 104 + } 105 + 106 + func fluxOutput(ctx context.Context, args ...string) ([]byte, error) { 107 + fluxCmd := exec.CommandContext(ctx, "flux", args...) 108 + return fluxCmd.CombinedOutput() 109 + }
+10 -90
toolbox/cmd/gitops.go
··· 1 1 package cmd 2 2 3 3 import ( 4 - "context" 5 4 "fmt" 6 5 "os/exec" 7 6 "path/filepath" 8 - "strings" 9 - "time" 10 7 11 - "github.com/charmbracelet/log" 12 8 "github.com/spf13/cobra" 13 - 14 - "github.com/khuedoan/cloudlab/toolbox/internal/cluster" 15 9 ) 16 10 17 11 const ( 18 - registryNamespace = "registry" 19 - registryService = "svc/registry" 20 - registryPort = 5000 21 - gitopsRepository = "platform" 22 - gitopsTag = "latest" 23 - fluxNamespace = "flux-system" 24 - fluxSource = "platform" 25 - fluxKustomization = "platform" 12 + gitopsRepository = "platform" 13 + gitopsTag = "latest" 14 + gitopsSource = "platform" 15 + gitopsBundle = "platform" 26 16 ) 27 17 28 18 var ( ··· 55 45 return fmt.Errorf("resolve path %q: %w", gitopsPath, err) 56 46 } 57 47 58 - connectCtx, cancel := context.WithTimeout(cmd.Context(), connectTimeout) 59 - defer cancel() 60 - 61 - hostAddr, err := cluster.LoadHost(hostsFile, host) 62 - if err != nil { 63 - return fmt.Errorf("load host: %w", err) 64 - } 65 - 66 - conn, err := cluster.Connect(cluster.SSHConfig{ 67 - Host: hostAddr, 68 - User: sshUser, 69 - KeyPath: sshKey, 70 - KnownHostsPath: sshKnownHosts, 71 - Timeout: connectTimeout, 48 + return pushArtifact(cmd.Context(), manifestPath, artifactRef{ 49 + Repository: gitopsRepository, 50 + Tag: gitopsTag, 51 + Source: gitopsSource, 52 + Revision: gitopsTag, 53 + Kustomization: gitopsBundle, 72 54 }) 73 - if err != nil { 74 - return fmt.Errorf("connect to cluster: %w", err) 75 - } 76 - defer conn.Close() 77 - 78 - tunnel, err := conn.Forward(connectCtx, cluster.ServiceConfig{ 79 - Namespace: registryNamespace, 80 - Name: registryService, 81 - Port: registryPort, 82 - }) 83 - if err != nil { 84 - return fmt.Errorf("forward registry: %w", err) 85 - } 86 - 87 - artifactURL := fmt.Sprintf("oci://%s/%s:%s", tunnel.LocalAddr, gitopsRepository, gitopsTag) 88 - args = []string{ 89 - "push", 90 - "artifact", 91 - artifactURL, 92 - "--path", manifestPath, 93 - // TODO should be actual source and revision 94 - "--source", "platform", 95 - "--revision", "latest", 96 - "--insecure-registry", 97 - } 98 - 99 - log.Infof("pushing %s from %s", artifactURL, manifestPath) 100 - 101 - output, err := fluxOutput(cmd.Context(), args...) 102 - if err != nil { 103 - return fmt.Errorf("push artifact: %w\n%s", err, strings.TrimSpace(string(output))) 104 - } 105 - 106 - if trimmed := strings.TrimSpace(string(output)); trimmed != "" { 107 - log.Info(trimmed) 108 - } 109 - 110 - requestedAt := time.Now().UTC().Format(time.RFC3339Nano) 111 - log.Infof("triggering Flux sync for %s/%s", fluxNamespace, fluxKustomization) 112 - 113 - output, err = conn.RunCommandContext( 114 - cmd.Context(), 115 - fmt.Sprintf( 116 - "kubectl annotate --overwrite -n %s ocirepository.source.toolkit.fluxcd.io/%s reconcile.fluxcd.io/requestedAt=%q && kubectl annotate --overwrite -n %s kustomization.kustomize.toolkit.fluxcd.io/%s reconcile.fluxcd.io/requestedAt=%q", 117 - fluxNamespace, fluxSource, requestedAt, 118 - fluxNamespace, fluxKustomization, requestedAt, 119 - ), 120 - ) 121 - if err != nil { 122 - return fmt.Errorf("trigger flux sync: %w", err) 123 - } 124 - 125 - if trimmed := strings.TrimSpace(string(output)); trimmed != "" { 126 - log.Info(trimmed) 127 - } 128 - 129 - return nil 130 - } 131 - 132 - func fluxOutput(ctx context.Context, args ...string) ([]byte, error) { 133 - fluxCmd := exec.CommandContext(ctx, "flux", args...) 134 - return fluxCmd.CombinedOutput() 135 55 }
+1
toolbox/cmd/root.go
··· 27 27 rootCmd.PersistentFlags().StringVar(&sshKnownHosts, "ssh-known-hosts", defaultKnownHostsFile(), "Path to SSH known_hosts file") 28 28 29 29 rootCmd.AddCommand(gitopsCmd) 30 + rootCmd.AddCommand(appsCmd) 30 31 rootCmd.AddCommand(secretsCmd) 31 32 rootCmd.AddCommand(vendorCmd) 32 33 }
+187
toolbox/internal/apps/bundle.go
··· 1 + package apps 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "path/filepath" 7 + "strings" 8 + 9 + "gopkg.in/yaml.v3" 10 + ) 11 + 12 + const ( 13 + chartName = "app-template" 14 + chartVersion = "4.6.0" 15 + fluxNamespace = "flux-system" 16 + helmRepositoryKind = "HelmRepository" 17 + helmRepositoryName = "app-template" 18 + helmReleaseKind = "HelmRelease" 19 + helmReleaseVersion = "helm.toolkit.fluxcd.io/v2" 20 + kustomizationKind = "Kustomization" 21 + kustomizationFile = "kustomization.yaml" 22 + kustomizationVersion = "kustomize.config.k8s.io/v1beta1" 23 + reconcileInterval = "3m" 24 + ) 25 + 26 + type Release struct { 27 + APIVersion string `yaml:"apiVersion"` 28 + Kind string `yaml:"kind"` 29 + Metadata Metadata `yaml:"metadata"` 30 + Spec ReleaseSpec `yaml:"spec"` 31 + } 32 + 33 + type Metadata struct { 34 + Name string `yaml:"name"` 35 + Namespace string `yaml:"namespace,omitempty"` 36 + } 37 + 38 + type ReleaseSpec struct { 39 + Interval string `yaml:"interval"` 40 + ReleaseName string `yaml:"releaseName"` 41 + TargetNamespace string `yaml:"targetNamespace"` 42 + Install InstallSpec `yaml:"install"` 43 + Chart Chart `yaml:"chart"` 44 + Values map[string]any `yaml:"values,omitempty"` 45 + } 46 + 47 + type InstallSpec struct { 48 + CreateNamespace bool `yaml:"createNamespace"` 49 + } 50 + 51 + type Chart struct { 52 + Spec ChartSpec `yaml:"spec"` 53 + } 54 + 55 + type ChartSpec struct { 56 + Chart string `yaml:"chart"` 57 + Version string `yaml:"version"` 58 + SourceRef SourceRef `yaml:"sourceRef"` 59 + } 60 + 61 + type SourceRef struct { 62 + Kind string `yaml:"kind"` 63 + Name string `yaml:"name"` 64 + } 65 + 66 + type kustomization struct { 67 + APIVersion string `yaml:"apiVersion"` 68 + Kind string `yaml:"kind"` 69 + Resources []string `yaml:"resources"` 70 + } 71 + 72 + func Discover(rootDir, env string) ([]Release, error) { 73 + pattern := filepath.Join(rootDir, "*", "*", env+".yaml") 74 + files, err := filepath.Glob(pattern) 75 + if err != nil { 76 + return nil, fmt.Errorf("glob app values: %w", err) 77 + } 78 + 79 + releases := make([]Release, 0, len(files)) 80 + for _, path := range files { 81 + release, err := loadRelease(rootDir, path) 82 + if err != nil { 83 + return nil, err 84 + } 85 + releases = append(releases, release) 86 + } 87 + 88 + return releases, nil 89 + } 90 + 91 + func WriteBundle(outputDir string, releases []Release) error { 92 + if err := os.MkdirAll(outputDir, 0o755); err != nil { 93 + return fmt.Errorf("create bundle dir: %w", err) 94 + } 95 + 96 + resources := make([]string, 0, len(releases)) 97 + for _, release := range releases { 98 + filename := release.Metadata.Name + ".yaml" 99 + path := filepath.Join(outputDir, filename) 100 + 101 + data, err := yaml.Marshal(release) 102 + if err != nil { 103 + return fmt.Errorf("marshal %s: %w", release.Metadata.Name, err) 104 + } 105 + 106 + if err := os.WriteFile(path, data, 0o644); err != nil { 107 + return fmt.Errorf("write %s: %w", path, err) 108 + } 109 + 110 + resources = append(resources, filename) 111 + } 112 + 113 + data, err := yaml.Marshal(kustomization{ 114 + APIVersion: kustomizationVersion, 115 + Kind: kustomizationKind, 116 + Resources: resources, 117 + }) 118 + if err != nil { 119 + return fmt.Errorf("marshal kustomization: %w", err) 120 + } 121 + 122 + path := filepath.Join(outputDir, kustomizationFile) 123 + if err := os.WriteFile(path, data, 0o644); err != nil { 124 + return fmt.Errorf("write %s: %w", path, err) 125 + } 126 + 127 + return nil 128 + } 129 + 130 + func loadRelease(rootDir, path string) (Release, error) { 131 + relPath, err := filepath.Rel(rootDir, path) 132 + if err != nil { 133 + return Release{}, fmt.Errorf("resolve relative path for %s: %w", path, err) 134 + } 135 + 136 + parts := splitPath(relPath) 137 + if len(parts) != 3 { 138 + return Release{}, fmt.Errorf("expected apps/$NAMESPACE/$APP/$ENV.yaml, got %s", relPath) 139 + } 140 + 141 + valuesData, err := os.ReadFile(path) 142 + if err != nil { 143 + return Release{}, fmt.Errorf("read %s: %w", path, err) 144 + } 145 + 146 + values := map[string]any{} 147 + if len(valuesData) > 0 { 148 + if err := yaml.Unmarshal(valuesData, &values); err != nil { 149 + return Release{}, fmt.Errorf("decode %s: %w", path, err) 150 + } 151 + } 152 + 153 + namespace := parts[0] 154 + app := parts[1] 155 + 156 + return Release{ 157 + APIVersion: helmReleaseVersion, 158 + Kind: helmReleaseKind, 159 + Metadata: Metadata{ 160 + Name: namespace + "-" + app, 161 + Namespace: fluxNamespace, 162 + }, 163 + Spec: ReleaseSpec{ 164 + Interval: reconcileInterval, 165 + ReleaseName: app, 166 + TargetNamespace: namespace, 167 + Install: InstallSpec{ 168 + CreateNamespace: true, 169 + }, 170 + Chart: Chart{ 171 + Spec: ChartSpec{ 172 + Chart: chartName, 173 + Version: chartVersion, 174 + SourceRef: SourceRef{ 175 + Kind: helmRepositoryKind, 176 + Name: helmRepositoryName, 177 + }, 178 + }, 179 + }, 180 + Values: values, 181 + }, 182 + }, nil 183 + } 184 + 185 + func splitPath(path string) []string { 186 + return strings.Split(filepath.ToSlash(path), "/") 187 + }
+156
toolbox/internal/apps/bundle_test.go
··· 1 + package apps 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "slices" 7 + "testing" 8 + 9 + "gopkg.in/yaml.v3" 10 + ) 11 + 12 + func TestDiscoverFiltersByEnvironment(t *testing.T) { 13 + rootDir := t.TempDir() 14 + 15 + writeFile(t, filepath.Join(rootDir, "khuedoan", "blog", "staging.yaml"), "controllers:\n main:\n replicas: 2\n") 16 + writeFile(t, filepath.Join(rootDir, "finance", "actualbudget", "staging.yaml"), "service:\n main:\n controller: main\n") 17 + writeFile(t, filepath.Join(rootDir, "test", "example", "production.yaml"), "ignored: true\n") 18 + 19 + releases, err := Discover(rootDir, "staging") 20 + if err != nil { 21 + t.Fatalf("discover staging apps: %v", err) 22 + } 23 + 24 + if len(releases) != 2 { 25 + t.Fatalf("expected 2 releases, got %d", len(releases)) 26 + } 27 + 28 + gotNames := []string{releases[0].Metadata.Name, releases[1].Metadata.Name} 29 + wantNames := []string{"finance-actualbudget", "khuedoan-blog"} 30 + if !slices.Equal(gotNames, wantNames) { 31 + t.Fatalf("expected release names %v, got %v", wantNames, gotNames) 32 + } 33 + 34 + if releases[0].Spec.TargetNamespace != "finance" { 35 + t.Fatalf("expected finance namespace, got %q", releases[0].Spec.TargetNamespace) 36 + } 37 + 38 + controllers, ok := releases[1].Spec.Values["controllers"].(map[string]any) 39 + if !ok { 40 + t.Fatalf("expected controllers map, got %T", releases[1].Spec.Values["controllers"]) 41 + } 42 + 43 + main, ok := controllers["main"].(map[string]any) 44 + if !ok { 45 + t.Fatalf("expected main controller map, got %T", controllers["main"]) 46 + } 47 + 48 + if main["replicas"] != 2 { 49 + t.Fatalf("expected replicas 2, got %#v", main["replicas"]) 50 + } 51 + } 52 + 53 + func TestWriteBundleCreatesKustomizationAndResources(t *testing.T) { 54 + outputDir := t.TempDir() 55 + releases := []Release{ 56 + { 57 + APIVersion: helmReleaseVersion, 58 + Kind: helmReleaseKind, 59 + Metadata: Metadata{ 60 + Name: "khuedoan-blog", 61 + Namespace: fluxNamespace, 62 + }, 63 + Spec: ReleaseSpec{ 64 + Interval: reconcileInterval, 65 + ReleaseName: "blog", 66 + TargetNamespace: "khuedoan", 67 + Install: InstallSpec{ 68 + CreateNamespace: true, 69 + }, 70 + Chart: Chart{ 71 + Spec: ChartSpec{ 72 + Chart: chartName, 73 + Version: chartVersion, 74 + SourceRef: SourceRef{ 75 + Kind: helmRepositoryKind, 76 + Name: helmRepositoryName, 77 + }, 78 + }, 79 + }, 80 + Values: map[string]any{ 81 + "service": map[string]any{ 82 + "main": map[string]any{ 83 + "controller": "main", 84 + }, 85 + }, 86 + }, 87 + }, 88 + }, 89 + } 90 + 91 + if err := WriteBundle(outputDir, releases); err != nil { 92 + t.Fatalf("write bundle: %v", err) 93 + } 94 + 95 + kustomizationData, err := os.ReadFile(filepath.Join(outputDir, kustomizationFile)) 96 + if err != nil { 97 + t.Fatalf("read kustomization: %v", err) 98 + } 99 + 100 + var manifest kustomization 101 + if err := yaml.Unmarshal(kustomizationData, &manifest); err != nil { 102 + t.Fatalf("decode kustomization: %v", err) 103 + } 104 + 105 + if !slices.Equal(manifest.Resources, []string{"khuedoan-blog.yaml"}) { 106 + t.Fatalf("expected resources [khuedoan-blog.yaml], got %v", manifest.Resources) 107 + } 108 + 109 + resourceData, err := os.ReadFile(filepath.Join(outputDir, "khuedoan-blog.yaml")) 110 + if err != nil { 111 + t.Fatalf("read release manifest: %v", err) 112 + } 113 + 114 + var release Release 115 + if err := yaml.Unmarshal(resourceData, &release); err != nil { 116 + t.Fatalf("decode release manifest: %v", err) 117 + } 118 + 119 + if release.Spec.ReleaseName != "blog" { 120 + t.Fatalf("expected release name blog, got %q", release.Spec.ReleaseName) 121 + } 122 + } 123 + 124 + func TestWriteBundleSupportsNoApps(t *testing.T) { 125 + outputDir := t.TempDir() 126 + 127 + if err := WriteBundle(outputDir, nil); err != nil { 128 + t.Fatalf("write empty bundle: %v", err) 129 + } 130 + 131 + kustomizationData, err := os.ReadFile(filepath.Join(outputDir, kustomizationFile)) 132 + if err != nil { 133 + t.Fatalf("read kustomization: %v", err) 134 + } 135 + 136 + var manifest kustomization 137 + if err := yaml.Unmarshal(kustomizationData, &manifest); err != nil { 138 + t.Fatalf("decode kustomization: %v", err) 139 + } 140 + 141 + if len(manifest.Resources) != 0 { 142 + t.Fatalf("expected no resources, got %v", manifest.Resources) 143 + } 144 + } 145 + 146 + func writeFile(t *testing.T, path, content string) { 147 + t.Helper() 148 + 149 + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { 150 + t.Fatalf("create dir for %s: %v", path, err) 151 + } 152 + 153 + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { 154 + t.Fatalf("write %s: %v", path, err) 155 + } 156 + }