diff --git a/Dockerfile b/Dockerfile index d7fde71f8a..af59dd0867 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,6 @@ ARG TARGETOS TARGETARCH COPY --from=packager /output / ADD out/${TARGETOS:-linux}_${TARGETARCH:-amd64}/sail-operator /sail-operator -ADD resources /var/lib/sail-operator/resources USER 65532:65532 WORKDIR / diff --git a/Makefile.core.mk b/Makefile.core.mk index ce0704793d..2c259ecb5c 100644 --- a/Makefile.core.mk +++ b/Makefile.core.mk @@ -248,7 +248,7 @@ build: build-$(TARGET_ARCH) ## Build the sail-operator binary. .PHONY: run run: gen ## Run a controller from your host. - POD_NAMESPACE=${NAMESPACE} go run ./cmd/main.go --config-file=./hack/config.properties --resource-directory=./resources + POD_NAMESPACE=${NAMESPACE} go run ./cmd/main.go --config-file=./hack/config.properties # docker build -t ${IMAGE} --build-arg GIT_TAG=${GIT_TAG} --build-arg GIT_REVISION=${GIT_REVISION} --build-arg GIT_STATUS=${GIT_STATUS} . .PHONY: docker-build diff --git a/cmd/main.go b/cmd/main.go index 55c3da4c5e..3514b19c3f 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -32,6 +32,7 @@ import ( "github.com/istio-ecosystem/sail-operator/pkg/helm" "github.com/istio-ecosystem/sail-operator/pkg/scheme" "github.com/istio-ecosystem/sail-operator/pkg/version" + "github.com/istio-ecosystem/sail-operator/resources" _ "k8s.io/client-go/plugin/pkg/client/auth" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/healthz" @@ -47,6 +48,7 @@ func main() { var metricsAddr string var probeAddr string var configFile string + var resourceDirectory string var logAPIRequests bool var printVersion bool var leaderElectionEnabled bool @@ -55,7 +57,7 @@ func main() { flag.StringVar(&metricsAddr, "metrics-bind-address", ":8443", "The address the metric endpoint binds to.") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") flag.StringVar(&configFile, "config-file", "/etc/sail-operator/config.properties", "Location of the config file, propagated by k8s downward APIs") - flag.StringVar(&reconcilerCfg.ResourceDirectory, "resource-directory", "/var/lib/sail-operator/resources", "Where to find resources (e.g. charts)") + flag.StringVar(&resourceDirectory, "resource-directory", "", "Where to find resources (e.g. charts). If empty, uses embedded resources.") flag.IntVar(&reconcilerCfg.MaxConcurrentReconciles, "max-concurrent-reconciles", 1, "MaxConcurrentReconciles is the maximum number of concurrent Reconciles which can be run.") flag.BoolVar(&logAPIRequests, "log-api-requests", false, "Whether to log each request sent to the Kubernetes API server") @@ -78,6 +80,13 @@ func main() { ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + if resourceDirectory != "" { + setupLog.Info("using filesystem resources", "directory", resourceDirectory) + reconcilerCfg.ResourceFS = os.DirFS(resourceDirectory) + } else { + setupLog.Info("using embedded resources") + reconcilerCfg.ResourceFS = resources.FS + } reconcilerCfg.OperatorNamespace = os.Getenv("POD_NAMESPACE") if reconcilerCfg.OperatorNamespace == "" { contents, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/namespace") diff --git a/controllers/istio/istio_controller.go b/controllers/istio/istio_controller.go index 04ed538794..1e19d63f75 100644 --- a/controllers/istio/istio_controller.go +++ b/controllers/istio/istio_controller.go @@ -128,7 +128,7 @@ func (r *Reconciler) reconcileActiveRevision(ctx context.Context, istio *v1.Isti values, err := revision.ComputeValues( istio.Spec.Values, istio.Spec.Namespace, version, r.Config.Platform, r.Config.DefaultProfile, istio.Spec.Profile, - r.Config.ResourceDirectory, getActiveRevisionName(istio)) + r.Config.ResourceFS, getActiveRevisionName(istio)) if err != nil { return err } diff --git a/controllers/istio/istio_controller_test.go b/controllers/istio/istio_controller_test.go index b00cbaaffe..766e678da8 100644 --- a/controllers/istio/istio_controller_test.go +++ b/controllers/istio/istio_controller_test.go @@ -17,6 +17,7 @@ package istio import ( "context" "fmt" + "os" "runtime/debug" "strings" "testing" @@ -1091,7 +1092,7 @@ func noWrites(t *testing.T) interceptor.Funcs { func newReconcilerTestConfig(t *testing.T) config.ReconcilerConfig { return config.ReconcilerConfig{ - ResourceDirectory: t.TempDir(), + ResourceFS: os.DirFS(t.TempDir()), Platform: config.PlatformKubernetes, DefaultProfile: "", MaxConcurrentReconciles: 1, diff --git a/controllers/istiocni/istiocni_controller.go b/controllers/istiocni/istiocni_controller.go index 4a5b6e38e1..db9aaef78e 100644 --- a/controllers/istiocni/istiocni_controller.go +++ b/controllers/istiocni/istiocni_controller.go @@ -162,20 +162,21 @@ func (r *Reconciler) installHelmChart(ctx context.Context, cni *v1.IstioCNI) err // apply userValues on top of defaultValues from profiles mergedHelmValues, err := istiovalues.ApplyProfilesAndPlatform( - r.Config.ResourceDirectory, version, r.Config.Platform, r.Config.DefaultProfile, cni.Spec.Profile, helm.FromValues(userValues)) + r.Config.ResourceFS, version, r.Config.Platform, r.Config.DefaultProfile, cni.Spec.Profile, helm.FromValues(userValues)) if err != nil { return fmt.Errorf("failed to apply profile: %w", err) } - _, err = r.ChartManager.UpgradeOrInstallChart(ctx, r.getChartDir(version), mergedHelmValues, cni.Spec.Namespace, cniReleaseName, &ownerReference) + _, err = r.ChartManager.UpgradeOrInstallChart( + ctx, r.Config.ResourceFS, r.getChartPath(version), mergedHelmValues, cni.Spec.Namespace, cniReleaseName, &ownerReference) if err != nil { return fmt.Errorf("failed to install/update Helm chart %q: %w", cniChartName, err) } return nil } -func (r *Reconciler) getChartDir(version string) string { - return path.Join(r.Config.ResourceDirectory, version, "charts", cniChartName) +func (r *Reconciler) getChartPath(version string) string { + return path.Join(version, "charts", cniChartName) } func applyImageDigests(version string, values *v1.CNIValues, config config.OperatorConfig) *v1.CNIValues { diff --git a/controllers/istiocni/istiocni_controller_test.go b/controllers/istiocni/istiocni_controller_test.go index 97920c6473..ee851d4244 100644 --- a/controllers/istiocni/istiocni_controller_test.go +++ b/controllers/istiocni/istiocni_controller_test.go @@ -17,6 +17,7 @@ package istiocni import ( "context" "fmt" + "os" "testing" "github.com/google/go-cmp/cmp" @@ -705,7 +706,7 @@ func normalize(condition v1.IstioCNICondition) v1.IstioCNICondition { func newReconcilerTestConfig(t *testing.T) config.ReconcilerConfig { return config.ReconcilerConfig{ - ResourceDirectory: t.TempDir(), + ResourceFS: os.DirFS(t.TempDir()), Platform: config.PlatformKubernetes, DefaultProfile: "", MaxConcurrentReconciles: 1, diff --git a/controllers/istiorevision/istiorevision_controller.go b/controllers/istiorevision/istiorevision_controller.go index d98b865e48..c1f786e54e 100644 --- a/controllers/istiorevision/istiorevision_controller.go +++ b/controllers/istiorevision/istiorevision_controller.go @@ -176,13 +176,13 @@ func (r *Reconciler) installHelmCharts(ctx context.Context, rev *v1.IstioRevisio } values := helm.FromValues(rev.Spec.Values) - _, err := r.ChartManager.UpgradeOrInstallChart(ctx, r.getChartDir(rev, constants.IstiodChartName), + _, err := r.ChartManager.UpgradeOrInstallChart(ctx, r.Config.ResourceFS, r.getChartPath(rev, constants.IstiodChartName), values, rev.Spec.Namespace, getReleaseName(rev, constants.IstiodChartName), &ownerReference) if err != nil { return fmt.Errorf("failed to install/update Helm chart %q: %w", constants.IstiodChartName, err) } if rev.Name == v1.DefaultRevision { - _, err := r.ChartManager.UpgradeOrInstallChart(ctx, r.getChartDir(rev, constants.BaseChartName), + _, err := r.ChartManager.UpgradeOrInstallChart(ctx, r.Config.ResourceFS, r.getChartPath(rev, constants.BaseChartName), values, r.Config.OperatorNamespace, getReleaseName(rev, constants.BaseChartName), &ownerReference) if err != nil { return fmt.Errorf("failed to install/update Helm chart %q: %w", constants.BaseChartName, err) @@ -195,8 +195,8 @@ func getReleaseName(rev *v1.IstioRevision, chartName string) string { return fmt.Sprintf("%s-%s", rev.Name, chartName) } -func (r *Reconciler) getChartDir(rev *v1.IstioRevision, chartName string) string { - return path.Join(r.Config.ResourceDirectory, rev.Spec.Version, "charts", chartName) +func (r *Reconciler) getChartPath(rev *v1.IstioRevision, chartName string) string { + return path.Join(rev.Spec.Version, "charts", chartName) } func (r *Reconciler) uninstallHelmCharts(ctx context.Context, rev *v1.IstioRevision) error { diff --git a/controllers/istiorevision/istiorevision_controller_test.go b/controllers/istiorevision/istiorevision_controller_test.go index b39882e00d..598f6c4e05 100644 --- a/controllers/istiorevision/istiorevision_controller_test.go +++ b/controllers/istiorevision/istiorevision_controller_test.go @@ -17,6 +17,7 @@ package istiorevision import ( "context" "fmt" + "os" "strings" "testing" @@ -1053,7 +1054,7 @@ func TestIgnoreStatusChangePredicate(t *testing.T) { func newReconcilerTestConfig(t *testing.T) config.ReconcilerConfig { return config.ReconcilerConfig{ - ResourceDirectory: t.TempDir(), + ResourceFS: os.DirFS(t.TempDir()), Platform: config.PlatformKubernetes, DefaultProfile: "", MaxConcurrentReconciles: 1, diff --git a/controllers/istiorevisiontag/istiorevisiontag_controller.go b/controllers/istiorevisiontag/istiorevisiontag_controller.go index 7bf027c245..b03b70e30e 100644 --- a/controllers/istiorevisiontag/istiorevisiontag_controller.go +++ b/controllers/istiorevisiontag/istiorevisiontag_controller.go @@ -198,13 +198,13 @@ func (r *Reconciler) installHelmCharts(ctx context.Context, tag *v1.IstioRevisio return err } - _, err := r.ChartManager.UpgradeOrInstallChart(ctx, r.getChartDir(rev, revisionTagsChartName), + _, err := r.ChartManager.UpgradeOrInstallChart(ctx, r.Config.ResourceFS, r.getChartPath(rev, revisionTagsChartName), values, rev.Spec.Namespace, getReleaseName(tag, revisionTagsChartName), &ownerReference) if err != nil { return fmt.Errorf("failed to install/update Helm chart %q: %w", revisionTagsChartName, err) } if tag.Name == v1.DefaultRevision { - _, err := r.ChartManager.UpgradeOrInstallChart(ctx, r.getChartDir(rev, constants.BaseChartName), + _, err := r.ChartManager.UpgradeOrInstallChart(ctx, r.Config.ResourceFS, r.getChartPath(rev, constants.BaseChartName), values, r.Config.OperatorNamespace, getReleaseName(tag, constants.BaseChartName), &ownerReference) if err != nil { return fmt.Errorf("failed to install/update Helm chart %q: %w", constants.BaseChartName, err) @@ -217,8 +217,8 @@ func getReleaseName(tag *v1.IstioRevisionTag, chartName string) string { return fmt.Sprintf("%s-%s", tag.Name, chartName) } -func (r *Reconciler) getChartDir(tag *v1.IstioRevision, chartName string) string { - return path.Join(r.Config.ResourceDirectory, tag.Spec.Version, "charts", chartName) +func (r *Reconciler) getChartPath(rev *v1.IstioRevision, chartName string) string { + return path.Join(rev.Spec.Version, "charts", chartName) } func (r *Reconciler) uninstallHelmCharts(ctx context.Context, tag *v1.IstioRevisionTag) error { diff --git a/controllers/istiorevisiontag/istiorevisiontag_controller_test.go b/controllers/istiorevisiontag/istiorevisiontag_controller_test.go index 90c2889012..88026c7138 100644 --- a/controllers/istiorevisiontag/istiorevisiontag_controller_test.go +++ b/controllers/istiorevisiontag/istiorevisiontag_controller_test.go @@ -17,6 +17,7 @@ package istiorevisiontag import ( "context" "fmt" + "os" "strings" "testing" @@ -277,7 +278,7 @@ func TestDetermineInUseCondition(t *testing.T) { func newReconcilerTestConfig(t *testing.T) config.ReconcilerConfig { return config.ReconcilerConfig{ - ResourceDirectory: t.TempDir(), + ResourceFS: os.DirFS(t.TempDir()), Platform: config.PlatformKubernetes, DefaultProfile: "", MaxConcurrentReconciles: 1, diff --git a/controllers/webhook/webhook_controller_test.go b/controllers/webhook/webhook_controller_test.go index 966a1033a7..206f250e54 100644 --- a/controllers/webhook/webhook_controller_test.go +++ b/controllers/webhook/webhook_controller_test.go @@ -29,6 +29,7 @@ import ( "net" "net/http" "net/http/httptest" + "os" "testing" "time" @@ -615,7 +616,7 @@ func generateSelfSignedCert(dnsNames ...string) (certPEM []byte, keyPEM []byte, func newReconcilerTestConfig(t *testing.T) config.ReconcilerConfig { return config.ReconcilerConfig{ - ResourceDirectory: t.TempDir(), + ResourceFS: os.DirFS(t.TempDir()), Platform: config.PlatformKubernetes, DefaultProfile: "", MaxConcurrentReconciles: 1, diff --git a/controllers/ztunnel/ztunnel_controller.go b/controllers/ztunnel/ztunnel_controller.go index 284ad4b078..7108029829 100644 --- a/controllers/ztunnel/ztunnel_controller.go +++ b/controllers/ztunnel/ztunnel_controller.go @@ -152,7 +152,7 @@ func (r *Reconciler) installHelmChart(ctx context.Context, ztunnel *v1.ZTunnel) // apply userValues on top of defaultValues from profiles mergedHelmValues, err := istiovalues.ApplyProfilesAndPlatform( - r.Config.ResourceDirectory, version, r.Config.Platform, r.Config.DefaultProfile, defaultProfile, helm.FromValues(userValues)) + r.Config.ResourceFS, version, r.Config.Platform, r.Config.DefaultProfile, defaultProfile, helm.FromValues(userValues)) if err != nil { return fmt.Errorf("failed to apply profile: %w", err) } @@ -166,15 +166,16 @@ func (r *Reconciler) installHelmChart(ctx context.Context, ztunnel *v1.ZTunnel) return fmt.Errorf("failed to apply user overrides: %w", err) } - _, err = r.ChartManager.UpgradeOrInstallChart(ctx, r.getChartDir(version), finalHelmValues, ztunnel.Spec.Namespace, ztunnelChart, &ownerReference) + _, err = r.ChartManager.UpgradeOrInstallChart( + ctx, r.Config.ResourceFS, r.getChartPath(version), finalHelmValues, ztunnel.Spec.Namespace, ztunnelChart, &ownerReference) if err != nil { return fmt.Errorf("failed to install/update Helm chart %q: %w", ztunnelChart, err) } return nil } -func (r *Reconciler) getChartDir(version string) string { - return path.Join(r.Config.ResourceDirectory, version, "charts", ztunnelChart) +func (r *Reconciler) getChartPath(version string) string { + return path.Join(version, "charts", ztunnelChart) } func applyImageDigests(version string, values *v1.ZTunnelValues, config config.OperatorConfig) *v1.ZTunnelValues { diff --git a/controllers/ztunnel/ztunnel_controller_test.go b/controllers/ztunnel/ztunnel_controller_test.go index 8a519e05af..df879ac684 100644 --- a/controllers/ztunnel/ztunnel_controller_test.go +++ b/controllers/ztunnel/ztunnel_controller_test.go @@ -17,6 +17,7 @@ package ztunnel import ( "context" "fmt" + "os" "testing" "time" @@ -581,8 +582,8 @@ func normalize(condition v1.ZTunnelCondition) v1.ZTunnelCondition { func newReconcilerTestConfig(t *testing.T) config.ReconcilerConfig { return config.ReconcilerConfig{ - ResourceDirectory: t.TempDir(), - Platform: config.PlatformKubernetes, - DefaultProfile: "", + ResourceFS: os.DirFS(t.TempDir()), + Platform: config.PlatformKubernetes, + DefaultProfile: "", } } diff --git a/pkg/config/config.go b/pkg/config/config.go index 64bbe85d40..9c402b63c5 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -15,6 +15,7 @@ package config import ( + "io/fs" "strings" "github.com/magiconair/properties" @@ -34,7 +35,7 @@ type IstioImageConfig struct { } type ReconcilerConfig struct { - ResourceDirectory string + ResourceFS fs.FS Platform Platform DefaultProfile string OperatorNamespace string diff --git a/pkg/helm/chartmanager.go b/pkg/helm/chartmanager.go index caf05d44ad..703e310dbd 100644 --- a/pkg/helm/chartmanager.go +++ b/pkg/helm/chartmanager.go @@ -18,9 +18,10 @@ import ( "context" "errors" "fmt" + "io/fs" "helm.sh/helm/v3/pkg/action" - chartLoader "helm.sh/helm/v3/pkg/chart/loader" + "helm.sh/helm/v3/pkg/chart" "helm.sh/helm/v3/pkg/release" "helm.sh/helm/v3/pkg/storage/driver" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -60,19 +61,28 @@ func (h *ChartManager) newActionConfig(ctx context.Context, namespace string) (* return actionConfig, err } -// UpgradeOrInstallChart upgrades a chart in cluster or installs it new if it does not already exist +// UpgradeOrInstallChart upgrades a chart in cluster or installs it new if it does not already exist. +// It loads the chart from an fs.FS (e.g., embed.FS or os.DirFS). func (h *ChartManager) UpgradeOrInstallChart( - ctx context.Context, chartDir string, values Values, + ctx context.Context, resourceFS fs.FS, chartPath string, values Values, namespace, releaseName string, ownerReference *metav1.OwnerReference, ) (*release.Release, error) { - log := logf.FromContext(ctx) - - cfg, err := h.newActionConfig(ctx, namespace) + loadedChart, err := LoadChart(resourceFS, chartPath) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to load chart from fs: %w", err) } - chart, err := chartLoader.Load(chartDir) + return h.upgradeOrInstallChart(ctx, loadedChart, values, namespace, releaseName, ownerReference) +} + +// upgradeOrInstallChart is the internal implementation that works with an already-loaded chart +func (h *ChartManager) upgradeOrInstallChart( + ctx context.Context, chart *chart.Chart, values Values, + namespace, releaseName string, ownerReference *metav1.OwnerReference, +) (*release.Release, error) { + log := logf.FromContext(ctx) + + cfg, err := h.newActionConfig(ctx, namespace) if err != nil { return nil, err } diff --git a/pkg/helm/chartmanager_test.go b/pkg/helm/chartmanager_test.go index 7db37d1800..e93dbd2cf3 100644 --- a/pkg/helm/chartmanager_test.go +++ b/pkg/helm/chartmanager_test.go @@ -17,7 +17,6 @@ package helm import ( "context" "os" - "path/filepath" "testing" "github.com/istio-ecosystem/sail-operator/pkg/test" @@ -36,8 +35,9 @@ import ( var ctx = context.TODO() var ( - relName = "my-release" - chartDir = filepath.Join("testdata", "chart") + relName = "my-release" + chartFS = os.DirFS("testdata") + chartPath = "chart" owner = metav1.OwnerReference{ APIVersion: "v1", @@ -59,50 +59,50 @@ var ( { name: "release exists", setup: func(g *WithT, cl client.Client, helm *ChartManager, ns string) { - install(g, helm, chartDir, ns, relName, owner) + install(g, helm, ns, relName, owner) }, }, { name: "release in failed state with previous revision", setup: func(g *WithT, cl client.Client, helm *ChartManager, ns string) { - install(g, helm, chartDir, ns, relName, owner) - upgrade(g, helm, chartDir, ns, relName, owner) + install(g, helm, ns, relName, owner) + upgrade(g, helm, ns, relName, owner) setReleaseStatus(g, helm, ns, relName, release.StatusFailed) }, }, { name: "release in failed state with no previous revision", setup: func(g *WithT, cl client.Client, helm *ChartManager, ns string) { - install(g, helm, chartDir, ns, relName, owner) + install(g, helm, ns, relName, owner) setReleaseStatus(g, helm, ns, relName, release.StatusFailed) }, }, { name: "release in pending-install state", setup: func(g *WithT, cl client.Client, helm *ChartManager, ns string) { - install(g, helm, chartDir, ns, relName, owner) + install(g, helm, ns, relName, owner) setReleaseStatus(g, helm, ns, relName, release.StatusPendingInstall) }, }, { name: "release in pending-upgrade state", setup: func(g *WithT, cl client.Client, helm *ChartManager, ns string) { - install(g, helm, chartDir, ns, relName, owner) - upgrade(g, helm, chartDir, ns, relName, owner) + install(g, helm, ns, relName, owner) + upgrade(g, helm, ns, relName, owner) setReleaseStatus(g, helm, ns, relName, release.StatusPendingUpgrade) }, }, { name: "release in uninstalling state", setup: func(g *WithT, cl client.Client, helm *ChartManager, ns string) { - install(g, helm, chartDir, ns, relName, owner) + install(g, helm, ns, relName, owner) setReleaseStatus(g, helm, ns, relName, release.StatusUninstalling) }, }, { name: "release in uninstalled state", setup: func(g *WithT, cl client.Client, helm *ChartManager, ns string) { - install(g, helm, chartDir, ns, relName, owner) + install(g, helm, ns, relName, owner) setReleaseStatus(g, helm, ns, relName, release.StatusUninstalled) }, wantErrOnInstall: true, @@ -111,7 +111,7 @@ var ( { name: "release in unknown state", setup: func(g *WithT, cl client.Client, helm *ChartManager, ns string) { - install(g, helm, chartDir, ns, relName, owner) + install(g, helm, ns, relName, owner) setReleaseStatus(g, helm, ns, relName, release.StatusUnknown) }, wantErrOnInstall: true, @@ -119,7 +119,7 @@ var ( { name: "release in superseded state", setup: func(g *WithT, cl client.Client, helm *ChartManager, ns string) { - install(g, helm, chartDir, ns, relName, owner) + install(g, helm, ns, relName, owner) setReleaseStatus(g, helm, ns, relName, release.StatusSuperseded) }, wantErrOnInstall: true, @@ -127,7 +127,7 @@ var ( { name: "release in pending-rollback state", setup: func(g *WithT, cl client.Client, helm *ChartManager, ns string) { - install(g, helm, chartDir, ns, relName, owner) + install(g, helm, ns, relName, owner) setReleaseStatus(g, helm, ns, relName, release.StatusPendingRollback) }, }, @@ -149,7 +149,7 @@ func TestUpgradeOrInstallChart(t *testing.T) { tc.setup(g, cl, helm, ns) } - rel, err := helm.UpgradeOrInstallChart(ctx, chartDir, Values{"value": "my-value"}, ns, relName, &owner) + rel, err := helm.UpgradeOrInstallChart(ctx, chartFS, chartPath, Values{"value": "my-value"}, ns, relName, &owner) if tc.wantErrOnInstall { g.Expect(err).To(HaveOccurred()) @@ -205,16 +205,16 @@ func createNamespace(cl client.Client, ns string) error { }) } -func install(g *WithT, helm *ChartManager, chartDir string, ns string, relName string, owner metav1.OwnerReference) { - upgradeOrInstall(g, helm, chartDir, ns, relName, owner) +func install(g *WithT, helm *ChartManager, ns string, relName string, owner metav1.OwnerReference) { + upgradeOrInstall(g, helm, ns, relName, owner) } -func upgrade(g *WithT, helm *ChartManager, chartDir string, ns string, relName string, owner metav1.OwnerReference) { - upgradeOrInstall(g, helm, chartDir, ns, relName, owner) +func upgrade(g *WithT, helm *ChartManager, ns string, relName string, owner metav1.OwnerReference) { + upgradeOrInstall(g, helm, ns, relName, owner) } -func upgradeOrInstall(g *WithT, helm *ChartManager, chartDir string, ns string, relName string, owner metav1.OwnerReference) { - _, err := helm.UpgradeOrInstallChart(ctx, chartDir, Values{"value": "other-value"}, ns, relName, &owner) +func upgradeOrInstall(g *WithT, helm *ChartManager, ns string, relName string, owner metav1.OwnerReference) { + _, err := helm.UpgradeOrInstallChart(ctx, chartFS, chartPath, Values{"value": "other-value"}, ns, relName, &owner) g.Expect(err).ToNot(HaveOccurred()) } diff --git a/pkg/helm/fsloader.go b/pkg/helm/fsloader.go new file mode 100644 index 0000000000..af95c94bb8 --- /dev/null +++ b/pkg/helm/fsloader.go @@ -0,0 +1,74 @@ +// Copyright Istio Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package helm + +import ( + "fmt" + "io/fs" + "strings" + + "helm.sh/helm/v3/pkg/chart" + chartLoader "helm.sh/helm/v3/pkg/chart/loader" +) + +// LoadChart loads a Helm chart from an fs.FS at the specified path. +// This allows loading charts from embed.FS, os.DirFS, or any other fs.FS implementation. +// +// The chartPath should be the path to the chart directory within the filesystem, +// e.g., "v1.28.2/charts/istiod". +func LoadChart(resourceFS fs.FS, chartPath string) (*chart.Chart, error) { + var files []*chartLoader.BufferedFile + + err := fs.WalkDir(resourceFS, chartPath, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + // Skip directories + if d.IsDir() { + return nil + } + + data, err := fs.ReadFile(resourceFS, path) + if err != nil { + return fmt.Errorf("failed to read file %s: %w", path, err) + } + + // Make path relative to chart root + // e.g., "v1.28.2/charts/istiod/Chart.yaml" -> "Chart.yaml" + relPath := strings.TrimPrefix(path, chartPath) + relPath = strings.TrimPrefix(relPath, "/") + + files = append(files, &chartLoader.BufferedFile{ + Name: relPath, + Data: data, + }) + return nil + }) + if err != nil { + return nil, fmt.Errorf("failed to walk chart directory %s: %w", chartPath, err) + } + + if len(files) == 0 { + return nil, fmt.Errorf("no files found in chart directory %s", chartPath) + } + + loadedChart, err := chartLoader.LoadFiles(files) + if err != nil { + return nil, fmt.Errorf("failed to load chart from files: %w", err) + } + + return loadedChart, nil +} diff --git a/pkg/helm/fsloader_test.go b/pkg/helm/fsloader_test.go new file mode 100644 index 0000000000..35d55785eb --- /dev/null +++ b/pkg/helm/fsloader_test.go @@ -0,0 +1,83 @@ +// Copyright Istio Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package helm + +import ( + "os" + "testing" + "testing/fstest" +) + +func TestLoadChart(t *testing.T) { + testFS := os.DirFS("testdata") + + t.Run("loads chart successfully", func(t *testing.T) { + chart, err := LoadChart(testFS, "chart") + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if chart == nil { + t.Fatal("expected chart to be non-nil") + } + if chart.Name() != "test-chart" { + t.Errorf("expected chart name 'test-chart', got: %s", chart.Name()) + } + if chart.Metadata.Version != "0.1.0" { + t.Errorf("expected chart version '0.1.0', got: %s", chart.Metadata.Version) + } + }) + + t.Run("returns error for non-existent path", func(t *testing.T) { + _, err := LoadChart(testFS, "nonexistent") + if err == nil { + t.Fatal("expected error for non-existent path") + } + }) + + t.Run("returns error for empty directory", func(t *testing.T) { + emptyFS := fstest.MapFS{ + "empty/.gitkeep": &fstest.MapFile{}, // directory marker, but we skip it + } + // Create a truly empty directory by having only a subdirectory + emptyDirFS := fstest.MapFS{ + "emptydir/subdir/.gitkeep": &fstest.MapFile{}, + } + _, err := LoadChart(emptyDirFS, "emptydir/subdir") + if err == nil { + t.Fatal("expected error for empty directory") + } + _ = emptyFS // silence unused variable + }) + + t.Run("loads chart from nested path", func(t *testing.T) { + // Create a mock filesystem with nested chart structure + nestedFS := fstest.MapFS{ + "v1.28.0/charts/istiod/Chart.yaml": &fstest.MapFile{ + Data: []byte("apiVersion: v2\nname: istiod\nversion: 1.28.0\n"), + }, + "v1.28.0/charts/istiod/values.yaml": &fstest.MapFile{ + Data: []byte("# default values\n"), + }, + } + + chart, err := LoadChart(nestedFS, "v1.28.0/charts/istiod") + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if chart.Name() != "istiod" { + t.Errorf("expected chart name 'istiod', got: %s", chart.Name()) + } + }) +} diff --git a/pkg/istiovalues/profiles.go b/pkg/istiovalues/profiles.go index dc8a9d6472..92dc11481b 100644 --- a/pkg/istiovalues/profiles.go +++ b/pkg/istiovalues/profiles.go @@ -16,7 +16,7 @@ package istiovalues import ( "fmt" - "os" + "io/fs" "path" "github.com/istio-ecosystem/sail-operator/pkg/config" @@ -28,11 +28,14 @@ import ( "istio.io/istio/pkg/util/sets" ) +// ApplyProfilesAndPlatform loads profiles from an fs.FS and applies them with platform settings. +// Works with embed.FS, os.DirFS, or any other fs.FS implementation. func ApplyProfilesAndPlatform( - resourceDir string, version string, platform config.Platform, defaultProfile, userProfile string, userValues helm.Values, + resourceFS fs.FS, version string, platform config.Platform, defaultProfile, userProfile string, userValues helm.Values, ) (helm.Values, error) { profile := resolve(defaultProfile, userProfile) - defaultValues, err := getValuesFromProfiles(path.Join(resourceDir, version, "profiles"), profile) + profilesPath := path.Join(version, "profiles") + defaultValues, err := getValuesFromProfiles(resourceFS, profilesPath, profile) if err != nil { return nil, fmt.Errorf("failed to get values from profile %q: %w", profile, err) } @@ -63,7 +66,7 @@ func resolve(defaultProfile, userProfile string) []string { } } -func getValuesFromProfiles(profilesDir string, profiles []string) (helm.Values, error) { +func getValuesFromProfiles(resourceFS fs.FS, profilesDir string, profiles []string) (helm.Values, error) { // start with an empty values map values := helm.Values{} @@ -84,7 +87,7 @@ func getValuesFromProfiles(profilesDir string, profiles []string) (helm.Values, return nil, reconciler.NewValidationError(fmt.Sprintf("invalid profile name %s", profile)) } - profileValues, err := getProfileValues(file) + profileValues, err := getProfileValues(resourceFS, file) if err != nil { return nil, err } @@ -94,16 +97,21 @@ func getValuesFromProfiles(profilesDir string, profiles []string) (helm.Values, return values, nil } -func getProfileValues(file string) (helm.Values, error) { - fileContents, err := os.ReadFile(file) +func getProfileValues(resourceFS fs.FS, file string) (helm.Values, error) { + fileContents, err := fs.ReadFile(resourceFS, file) if err != nil { return nil, fmt.Errorf("failed to read profile file %v: %w", file, err) } + return parseProfileYAML(fileContents, file) +} + +// parseProfileYAML parses the profile YAML content and extracts spec.values +func parseProfileYAML(fileContents []byte, filename string) (helm.Values, error) { var profile map[string]any - err = yaml.Unmarshal(fileContents, &profile) + err := yaml.Unmarshal(fileContents, &profile) if err != nil { - return nil, fmt.Errorf("failed to unmarshal profile YAML %s: %w", file, err) + return nil, fmt.Errorf("failed to unmarshal profile YAML %s: %w", filename, err) } val, found, err := unstructured.NestedFieldNoCopy(profile, "spec", "values") diff --git a/pkg/istiovalues/profiles_test.go b/pkg/istiovalues/profiles_test.go index 426c019eed..3f98273b38 100644 --- a/pkg/istiovalues/profiles_test.go +++ b/pkg/istiovalues/profiles_test.go @@ -105,7 +105,7 @@ spec: for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - actual, err := getValuesFromProfiles(profilesDir, tt.profiles) + actual, err := getValuesFromProfiles(os.DirFS(resourceDir), path.Join(version, "profiles"), tt.profiles) if (err != nil) != tt.expectErr { t.Errorf("applyProfile() error = %v, expectErr %v", err, tt.expectErr) } diff --git a/pkg/revision/dependency.go b/pkg/revision/dependency.go index d03b14b3f6..554faeabc7 100644 --- a/pkg/revision/dependency.go +++ b/pkg/revision/dependency.go @@ -15,18 +15,20 @@ package revision import ( + "io/fs" + v1 "github.com/istio-ecosystem/sail-operator/api/v1" "github.com/istio-ecosystem/sail-operator/pkg/config" ) -type computeValuesFunc func(*v1.Values, string, string, config.Platform, string, string, string, string) (*v1.Values, error) +type computeValuesFunc func(*v1.Values, string, string, config.Platform, string, string, fs.FS, string) (*v1.Values, error) var defaultComputeValues computeValuesFunc = ComputeValues // DependsOnIstioCNI returns true if CNI is enabled in the revision func DependsOnIstioCNI(rev *v1.IstioRevision, cfg config.ReconcilerConfig) bool { values, err := defaultComputeValues(rev.Spec.Values, rev.Spec.Namespace, rev.Spec.Version, - cfg.Platform, cfg.DefaultProfile, "", cfg.ResourceDirectory, rev.Name) + cfg.Platform, cfg.DefaultProfile, "", cfg.ResourceFS, rev.Name) if err != nil || values == nil { return false } @@ -48,7 +50,7 @@ func DependsOnIstioCNI(rev *v1.IstioRevision, cfg config.ReconcilerConfig) bool // DependsOnZTunnel returns true if the revision is configured for ambient mode and requires ZTunnel func DependsOnZTunnel(rev *v1.IstioRevision, cfg config.ReconcilerConfig) bool { values, err := defaultComputeValues(rev.Spec.Values, rev.Spec.Namespace, rev.Spec.Version, - cfg.Platform, cfg.DefaultProfile, "", cfg.ResourceDirectory, rev.Name) + cfg.Platform, cfg.DefaultProfile, "", cfg.ResourceFS, rev.Name) if err != nil || values == nil { return false } diff --git a/pkg/revision/dependency_test.go b/pkg/revision/dependency_test.go index 5653cc91f1..de31356fb4 100644 --- a/pkg/revision/dependency_test.go +++ b/pkg/revision/dependency_test.go @@ -15,6 +15,7 @@ package revision import ( + "io/fs" "testing" v1 "github.com/istio-ecosystem/sail-operator/api/v1" @@ -26,7 +27,7 @@ import ( // mockComputeValues returns the input values without any computation // this simulates what ComputeValues would do but without requiring actual files -func mockComputeValues(values *v1.Values, _, _ string, platform config.Platform, defaultProfile, userProfile, _, _ string) (*v1.Values, error) { +func mockComputeValues(values *v1.Values, _, _ string, platform config.Platform, defaultProfile, userProfile string, _ fs.FS, _ string) (*v1.Values, error) { if values == nil { values = &v1.Values{} } diff --git a/pkg/revision/values.go b/pkg/revision/values.go index 759e84a59c..805d13cee4 100644 --- a/pkg/revision/values.go +++ b/pkg/revision/values.go @@ -16,6 +16,7 @@ package revision import ( "fmt" + "io/fs" v1 "github.com/istio-ecosystem/sail-operator/api/v1" "github.com/istio-ecosystem/sail-operator/pkg/config" @@ -28,9 +29,11 @@ import ( // - applies vendor-specific default values // - applies the user-provided values on top of the default values from the default and user-selected profiles // - applies overrides that are not configurable by the user +// +// The resourceFS parameter accepts any fs.FS implementation (embed.FS, os.DirFS, etc.). func ComputeValues( userValues *v1.Values, namespace string, version string, - platform config.Platform, defaultProfile, userProfile string, resourceDir string, + platform config.Platform, defaultProfile, userProfile string, resourceFS fs.FS, activeRevisionName string, ) (*v1.Values, error) { // apply image digests from configuration, if not already set by user @@ -43,7 +46,7 @@ func ComputeValues( } // apply userValues on top of defaultValues from profiles - mergedHelmValues, err := istiovalues.ApplyProfilesAndPlatform(resourceDir, version, platform, defaultProfile, userProfile, helm.FromValues(userValues)) + mergedHelmValues, err := istiovalues.ApplyProfilesAndPlatform(resourceFS, version, platform, defaultProfile, userProfile, helm.FromValues(userValues)) if err != nil { return nil, fmt.Errorf("failed to apply profile: %w", err) } diff --git a/pkg/revision/values_test.go b/pkg/revision/values_test.go index f55d2edf45..ec3cda777a 100644 --- a/pkg/revision/values_test.go +++ b/pkg/revision/values_test.go @@ -68,7 +68,7 @@ spec: }, } - result, err := ComputeValues(values, namespace, version, config.PlatformOpenShift, "default", "my-profile", resourceDir, revisionName) + result, err := ComputeValues(values, namespace, version, config.PlatformOpenShift, "default", "my-profile", os.DirFS(resourceDir), revisionName) if err != nil { t.Errorf("Expected no error, but got an error: %v", err) } @@ -111,7 +111,7 @@ spec:`)), 0o644)) istiovalues.FipsEnabled = true values := &v1.Values{} result, err := ComputeValues(values, namespace, version, config.PlatformOpenShift, "default", "", - resourceDir, revisionName) + os.DirFS(resourceDir), revisionName) if err != nil { t.Errorf("Expected no error, but got an error: %v", err) } diff --git a/resources/resources.go b/resources/resources.go new file mode 100644 index 0000000000..081c6ea65d --- /dev/null +++ b/resources/resources.go @@ -0,0 +1,69 @@ +// Copyright Istio Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package resources provides embedded Istio Helm charts and profiles. +// +// This package embeds all version directories (v1.28.2, etc.) containing +// Helm charts and profiles. Importing this package will increase the binary +// size significantly (~10MB) as it includes all chart files. +// +// This package is intended for consumers who want to embed the charts +// directly in their binary. +// +// Usage: +// +// import "github.com/istio-ecosystem/sail-operator/resources" +// +// cfg := config.ReconcilerConfig{ +// ResourceFS: resources.FS, +// } +// +// The embedded paths are relative to this directory, e.g.: +// - v1.28.2/charts/istiod/Chart.yaml +// - v1.28.2/profiles/default.yaml +package resources + +import ( + "embed" + "io/fs" +) + +// FS contains the embedded resources directory with all Helm charts and profiles. +// Paths are relative to this directory (e.g., "v1.28.2/charts/istiod"). +// +//go:embed all:v* +var FS embed.FS + +// SubFS creates a sub-filesystem rooted at the specified directory. +// This is useful for stripping prefixes from embedded filesystems. +// +// Example: +// +// // If you have your own embed with a prefix: +// //go:embed my-resources +// var rawFS embed.FS +// fs := resources.SubFS(rawFS, "my-resources") +func SubFS(fsys fs.FS, dir string) (fs.FS, error) { + return fs.Sub(fsys, dir) +} + +// MustSubFS is like SubFS but panics on error. +// Use this when the directory is known to exist. +func MustSubFS(fsys fs.FS, dir string) fs.FS { + sub, err := fs.Sub(fsys, dir) + if err != nil { + panic("failed to create sub-filesystem for " + dir + ": " + err.Error()) + } + return sub +} diff --git a/tests/integration/api/suite_test.go b/tests/integration/api/suite_test.go index 560cef425f..248758cde5 100644 --- a/tests/integration/api/suite_test.go +++ b/tests/integration/api/suite_test.go @@ -18,6 +18,7 @@ package integration import ( "context" + "os" "path" "testing" @@ -86,7 +87,7 @@ var _ = BeforeSuite(func() { Expect(k8sClient.Create(context.TODO(), operatorNs)).To(Succeed()) cfg := config.ReconcilerConfig{ - ResourceDirectory: path.Join(project.RootDir, "resources"), + ResourceFS: os.DirFS(path.Join(project.RootDir, "resources")), Platform: config.PlatformKubernetes, DefaultProfile: "", OperatorNamespace: operatorNs.Name,