diff --git a/api/controllers/linux/install/controller.go b/api/controllers/linux/install/controller.go index 08368bc6fe..81b1724cfe 100644 --- a/api/controllers/linux/install/controller.go +++ b/api/controllers/linux/install/controller.go @@ -17,6 +17,7 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/metrics" "github.com/replicatedhq/embedded-cluster/pkg/release" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" + kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" "github.com/sirupsen/logrus" ) @@ -52,6 +53,7 @@ type InstallController struct { tlsConfig types.TLSConfig license []byte airgapBundle string + airgapInfo *kotsv1beta1.Airgap configValues string endUserConfig *ecv1beta1.Config @@ -126,6 +128,12 @@ func WithAirgapBundle(airgapBundle string) InstallControllerOption { } } +func WithAirgapInfo(airgapInfo *kotsv1beta1.Airgap) InstallControllerOption { + return func(c *InstallController) { + c.airgapInfo = airgapInfo + } +} + func WithConfigValues(configValues string) InstallControllerOption { return func(c *InstallController) { c.configValues = configValues @@ -218,6 +226,7 @@ func NewInstallController(opts ...InstallControllerOption) (*InstallController, infra.WithTLSConfig(controller.tlsConfig), infra.WithLicense(controller.license), infra.WithAirgapBundle(controller.airgapBundle), + infra.WithAirgapInfo(controller.airgapInfo), infra.WithConfigValues(controller.configValues), infra.WithReleaseData(controller.releaseData), infra.WithEndUserConfig(controller.endUserConfig), diff --git a/api/controllers/linux/install/hostpreflight.go b/api/controllers/linux/install/hostpreflight.go index cc4e1e1743..ba044fdf48 100644 --- a/api/controllers/linux/install/hostpreflight.go +++ b/api/controllers/linux/install/hostpreflight.go @@ -8,6 +8,7 @@ import ( "github.com/replicatedhq/embedded-cluster/api/internal/managers/preflight" "github.com/replicatedhq/embedded-cluster/api/internal/utils" "github.com/replicatedhq/embedded-cluster/api/types" + "github.com/replicatedhq/embedded-cluster/pkg-new/preflights" "github.com/replicatedhq/embedded-cluster/pkg/netutils" ) @@ -33,14 +34,21 @@ func (c *InstallController) RunHostPreflights(ctx context.Context, opts RunHostP // Get the configured custom domains ecDomains := utils.GetDomains(c.releaseData) + // Calculate airgap storage space requirement (2x uncompressed size for controller nodes) + var controllerAirgapStorageSpace string + if c.airgapInfo != nil { + controllerAirgapStorageSpace = preflights.CalculateAirgapStorageSpace(c.airgapInfo.Spec.UncompressedSize, true) + } + // Prepare host preflights hpf, err := c.hostPreflightManager.PrepareHostPreflights(ctx, c.rc, preflight.PrepareHostPreflightOptions{ - ReplicatedAppURL: netutils.MaybeAddHTTPS(ecDomains.ReplicatedAppDomain), - ProxyRegistryURL: netutils.MaybeAddHTTPS(ecDomains.ProxyRegistryDomain), - HostPreflightSpec: c.releaseData.HostPreflights, - EmbeddedClusterConfig: c.releaseData.EmbeddedClusterConfig, - IsAirgap: c.airgapBundle != "", - IsUI: opts.IsUI, + ReplicatedAppURL: netutils.MaybeAddHTTPS(ecDomains.ReplicatedAppDomain), + ProxyRegistryURL: netutils.MaybeAddHTTPS(ecDomains.ProxyRegistryDomain), + HostPreflightSpec: c.releaseData.HostPreflights, + EmbeddedClusterConfig: c.releaseData.EmbeddedClusterConfig, + IsAirgap: c.airgapBundle != "", + IsUI: opts.IsUI, + ControllerAirgapStorageSpace: controllerAirgapStorageSpace, }) if err != nil { return fmt.Errorf("failed to prepare host preflights: %w", err) diff --git a/api/internal/handlers/linux/linux.go b/api/internal/handlers/linux/linux.go index 976c5f6169..a938038a74 100644 --- a/api/internal/handlers/linux/linux.go +++ b/api/internal/handlers/linux/linux.go @@ -76,6 +76,7 @@ func New(cfg types.APIConfig, opts ...Option) (*Handler, error) { install.WithTLSConfig(h.cfg.TLSConfig), install.WithLicense(h.cfg.License), install.WithAirgapBundle(h.cfg.AirgapBundle), + install.WithAirgapInfo(h.cfg.AirgapInfo), install.WithConfigValues(h.cfg.ConfigValues), install.WithEndUserConfig(h.cfg.EndUserConfig), install.WithAllowIgnoreHostPreflights(h.cfg.AllowIgnoreHostPreflights), diff --git a/api/internal/managers/infra/install.go b/api/internal/managers/infra/install.go index 17aae15e67..85cc3cba65 100644 --- a/api/internal/managers/infra/install.go +++ b/api/internal/managers/infra/install.go @@ -222,15 +222,22 @@ func (m *infraManager) recordInstallation(ctx context.Context, kcli client.Clien // get the configured custom domains ecDomains := utils.GetDomains(m.releaseData) + // extract airgap uncompressed size if airgap info is provided + var airgapUncompressedSize int64 + if m.airgapInfo != nil { + airgapUncompressedSize = m.airgapInfo.Spec.UncompressedSize + } + // record the installation logFn("recording installation") in, err := kubeutils.RecordInstallation(ctx, kcli, kubeutils.RecordInstallationOptions{ - IsAirgap: m.airgapBundle != "", - License: license, - ConfigSpec: m.getECConfigSpec(), - MetricsBaseURL: netutils.MaybeAddHTTPS(ecDomains.ReplicatedAppDomain), - RuntimeConfig: rc.Get(), - EndUserConfig: m.endUserConfig, + IsAirgap: m.airgapBundle != "", + License: license, + ConfigSpec: m.getECConfigSpec(), + MetricsBaseURL: netutils.MaybeAddHTTPS(ecDomains.ReplicatedAppDomain), + RuntimeConfig: rc.Get(), + EndUserConfig: m.endUserConfig, + AirgapUncompressedSize: airgapUncompressedSize, }) if err != nil { return nil, fmt.Errorf("record installation: %w", err) diff --git a/api/internal/managers/infra/manager.go b/api/internal/managers/infra/manager.go index 4d8103a33a..622144f27a 100644 --- a/api/internal/managers/infra/manager.go +++ b/api/internal/managers/infra/manager.go @@ -13,6 +13,7 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/helm" "github.com/replicatedhq/embedded-cluster/pkg/release" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" + kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" "github.com/sirupsen/logrus" "k8s.io/client-go/metadata" "sigs.k8s.io/controller-runtime/pkg/client" @@ -33,6 +34,7 @@ type infraManager struct { tlsConfig types.TLSConfig license []byte airgapBundle string + airgapInfo *kotsv1beta1.Airgap configValues string releaseData *release.ReleaseData endUserConfig *ecv1beta1.Config @@ -84,6 +86,12 @@ func WithAirgapBundle(airgapBundle string) InfraManagerOption { } } +func WithAirgapInfo(airgapInfo *kotsv1beta1.Airgap) InfraManagerOption { + return func(c *infraManager) { + c.airgapInfo = airgapInfo + } +} + func WithConfigValues(configValues string) InfraManagerOption { return func(c *infraManager) { c.configValues = configValues diff --git a/api/internal/managers/preflight/hostpreflight.go b/api/internal/managers/preflight/hostpreflight.go index e0496fccd8..5c51326c43 100644 --- a/api/internal/managers/preflight/hostpreflight.go +++ b/api/internal/managers/preflight/hostpreflight.go @@ -15,14 +15,16 @@ import ( ) type PrepareHostPreflightOptions struct { - ReplicatedAppURL string - ProxyRegistryURL string - HostPreflightSpec *troubleshootv1beta2.HostPreflightSpec - EmbeddedClusterConfig *ecv1beta1.Config - TCPConnectionsRequired []string - IsAirgap bool - IsJoin bool - IsUI bool + ReplicatedAppURL string + ProxyRegistryURL string + HostPreflightSpec *troubleshootv1beta2.HostPreflightSpec + EmbeddedClusterConfig *ecv1beta1.Config + TCPConnectionsRequired []string + IsAirgap bool + IsJoin bool + IsUI bool + ControllerAirgapStorageSpace string + WorkerAirgapStorageSpace string } type RunHostPreflightOptions struct { @@ -38,22 +40,24 @@ func (m *hostPreflightManager) PrepareHostPreflights(ctx context.Context, rc run // Use the shared Prepare function to prepare host preflights prepareOpts := preflights.PrepareOptions{ - HostPreflightSpec: opts.HostPreflightSpec, - ReplicatedAppURL: opts.ReplicatedAppURL, - ProxyRegistryURL: opts.ProxyRegistryURL, - AdminConsolePort: rc.AdminConsolePort(), - LocalArtifactMirrorPort: rc.LocalArtifactMirrorPort(), - DataDir: rc.EmbeddedClusterHomeDirectory(), - K0sDataDir: rc.EmbeddedClusterK0sSubDir(), - OpenEBSDataDir: rc.EmbeddedClusterOpenEBSLocalSubDir(), - Proxy: rc.ProxySpec(), - PodCIDR: rc.PodCIDR(), - ServiceCIDR: rc.ServiceCIDR(), - NodeIP: nodeIP, - IsAirgap: opts.IsAirgap, - TCPConnectionsRequired: opts.TCPConnectionsRequired, - IsJoin: opts.IsJoin, - IsUI: opts.IsUI, + HostPreflightSpec: opts.HostPreflightSpec, + ReplicatedAppURL: opts.ReplicatedAppURL, + ProxyRegistryURL: opts.ProxyRegistryURL, + AdminConsolePort: rc.AdminConsolePort(), + LocalArtifactMirrorPort: rc.LocalArtifactMirrorPort(), + DataDir: rc.EmbeddedClusterHomeDirectory(), + K0sDataDir: rc.EmbeddedClusterK0sSubDir(), + OpenEBSDataDir: rc.EmbeddedClusterOpenEBSLocalSubDir(), + Proxy: rc.ProxySpec(), + PodCIDR: rc.PodCIDR(), + ServiceCIDR: rc.ServiceCIDR(), + NodeIP: nodeIP, + IsAirgap: opts.IsAirgap, + TCPConnectionsRequired: opts.TCPConnectionsRequired, + IsJoin: opts.IsJoin, + IsUI: opts.IsUI, + ControllerAirgapStorageSpace: opts.ControllerAirgapStorageSpace, + WorkerAirgapStorageSpace: opts.WorkerAirgapStorageSpace, } if cidr := rc.GlobalCIDR(); cidr != "" { prepareOpts.GlobalCIDR = &cidr diff --git a/api/types/api.go b/api/types/api.go index 93b10c6170..4f19d7d139 100644 --- a/api/types/api.go +++ b/api/types/api.go @@ -4,6 +4,7 @@ import ( ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/release" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" + kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" ) // APIConfig holds the configuration for the API server @@ -13,6 +14,7 @@ type APIConfig struct { TLSConfig TLSConfig License []byte AirgapBundle string + AirgapInfo *kotsv1beta1.Airgap ConfigValues string ReleaseData *release.ReleaseData EndUserConfig *ecv1beta1.Config diff --git a/cmd/installer/cli/api.go b/cmd/installer/cli/api.go index 5640fa5d00..a7b43050d8 100644 --- a/cmd/installer/cli/api.go +++ b/cmd/installer/cli/api.go @@ -23,6 +23,7 @@ import ( "github.com/replicatedhq/embedded-cluster/pkg/release" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" "github.com/replicatedhq/embedded-cluster/web" + kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" "github.com/sirupsen/logrus" ) @@ -37,6 +38,7 @@ type apiOptions struct { ManagerPort int License []byte AirgapBundle string + AirgapInfo *kotsv1beta1.Airgap ConfigValues string ReleaseData *release.ReleaseData EndUserConfig *ecv1beta1.Config @@ -87,6 +89,7 @@ func serveAPI(ctx context.Context, listener net.Listener, cert tls.Certificate, TLSConfig: opts.TLSConfig, License: opts.License, AirgapBundle: opts.AirgapBundle, + AirgapInfo: opts.AirgapInfo, ConfigValues: opts.ConfigValues, ReleaseData: opts.ReleaseData, EndUserConfig: opts.EndUserConfig, diff --git a/cmd/installer/cli/install.go b/cmd/installer/cli/install.go index e884b9a6e5..e47fc522dd 100644 --- a/cmd/installer/cli/install.go +++ b/cmd/installer/cli/install.go @@ -57,6 +57,7 @@ type InstallCmdFlags struct { adminConsolePassword string adminConsolePort int airgapBundle string + airgapInfo *kotsv1beta1.Airgap isAirgap bool licenseFile string assumeYes bool @@ -414,6 +415,13 @@ func preRunInstallCommon(cmd *cobra.Command, flags *InstallCmdFlags, rc runtimec } flags.isAirgap = flags.airgapBundle != "" + if flags.airgapBundle != "" { + var err error + flags.airgapInfo, err = airgap.AirgapInfoFromPath(flags.airgapBundle) + if err != nil { + return fmt.Errorf("failed to get airgap info: %w", err) + } + } proxy, err := proxyConfigFromCmd(cmd, flags.assumeYes) if err != nil { @@ -615,6 +623,7 @@ func runManagerExperienceInstall(ctx context.Context, flags InstallCmdFlags, rc ManagerPort: flags.managerPort, License: flags.licenseBytes, AirgapBundle: flags.airgapBundle, + AirgapInfo: flags.airgapInfo, ConfigValues: flags.configValues, ReleaseData: release.GetReleaseData(), EndUserConfig: eucfg, @@ -792,9 +801,9 @@ func verifyAndPrompt(ctx context.Context, name string, flags InstallCmdFlags, pr if err != nil { return err } - if flags.isAirgap { + if flags.airgapInfo != nil { logrus.Debugf("checking airgap bundle matches binary") - if err := checkAirgapMatches(flags.airgapBundle); err != nil { + if err := checkAirgapMatches(flags.airgapInfo); err != nil { return err // we want the user to see the error message without a prefix } } @@ -1103,23 +1112,15 @@ func installExtensions(ctx context.Context, hcli helm.Client) error { return nil } -func checkAirgapMatches(airgapBundle string) error { +func checkAirgapMatches(airgapInfo *kotsv1beta1.Airgap) error { rel := release.GetChannelRelease() if rel == nil { return fmt.Errorf("airgap bundle provided but no release was found in binary, please rerun without the airgap-bundle flag") } - // read file from path - rawfile, err := os.Open(airgapBundle) - if err != nil { - return fmt.Errorf("failed to open airgap file: %w", err) - } - defer rawfile.Close() - - appSlug, channelID, airgapVersion, err := airgap.ChannelReleaseMetadata(rawfile) - if err != nil { - return fmt.Errorf("failed to get airgap bundle versions: %w", err) - } + appSlug := airgapInfo.Spec.AppSlug + channelID := airgapInfo.Spec.ChannelID + airgapVersion := airgapInfo.Spec.VersionLabel // Check if the airgap bundle matches the application version data if rel.AppSlug != appSlug { @@ -1272,14 +1273,21 @@ func recordInstallation( return nil, fmt.Errorf("process overrides file: %w", err) } + // extract airgap uncompressed size if airgap info is provided + var airgapUncompressedSize int64 + if flags.airgapInfo != nil { + airgapUncompressedSize = flags.airgapInfo.Spec.UncompressedSize + } + // record the installation installation, err := kubeutils.RecordInstallation(ctx, kcli, kubeutils.RecordInstallationOptions{ - IsAirgap: flags.isAirgap, - License: license, - ConfigSpec: cfgspec, - MetricsBaseURL: replicatedAppURL(), - RuntimeConfig: rc.Get(), - EndUserConfig: eucfg, + IsAirgap: flags.isAirgap, + License: license, + ConfigSpec: cfgspec, + MetricsBaseURL: replicatedAppURL(), + RuntimeConfig: rc.Get(), + EndUserConfig: eucfg, + AirgapUncompressedSize: airgapUncompressedSize, }) if err != nil { return nil, fmt.Errorf("record installation: %w", err) diff --git a/cmd/installer/cli/install_runpreflights.go b/cmd/installer/cli/install_runpreflights.go index 8cf75d8f49..d1c80fd6c1 100644 --- a/cmd/installer/cli/install_runpreflights.go +++ b/cmd/installer/cli/install_runpreflights.go @@ -99,20 +99,27 @@ func runInstallPreflights(ctx context.Context, flags InstallCmdFlags, rc runtime return fmt.Errorf("unable to find first valid address: %w", err) } + // Calculate airgap storage space requirement (2x uncompressed size for controller nodes) + var controllerAirgapStorageSpace string + if flags.airgapInfo != nil { + controllerAirgapStorageSpace = preflights.CalculateAirgapStorageSpace(flags.airgapInfo.Spec.UncompressedSize, true) + } + opts := preflights.PrepareOptions{ - HostPreflightSpec: release.GetHostPreflights(), - ReplicatedAppURL: replicatedAppURL, - ProxyRegistryURL: proxyRegistryURL, - AdminConsolePort: rc.AdminConsolePort(), - LocalArtifactMirrorPort: rc.LocalArtifactMirrorPort(), - DataDir: rc.EmbeddedClusterHomeDirectory(), - K0sDataDir: rc.EmbeddedClusterK0sSubDir(), - OpenEBSDataDir: rc.EmbeddedClusterOpenEBSLocalSubDir(), - Proxy: rc.ProxySpec(), - PodCIDR: rc.PodCIDR(), - ServiceCIDR: rc.ServiceCIDR(), - NodeIP: nodeIP, - IsAirgap: flags.isAirgap, + HostPreflightSpec: release.GetHostPreflights(), + ReplicatedAppURL: replicatedAppURL, + ProxyRegistryURL: proxyRegistryURL, + AdminConsolePort: rc.AdminConsolePort(), + LocalArtifactMirrorPort: rc.LocalArtifactMirrorPort(), + DataDir: rc.EmbeddedClusterHomeDirectory(), + K0sDataDir: rc.EmbeddedClusterK0sSubDir(), + OpenEBSDataDir: rc.EmbeddedClusterOpenEBSLocalSubDir(), + Proxy: rc.ProxySpec(), + PodCIDR: rc.PodCIDR(), + ServiceCIDR: rc.ServiceCIDR(), + NodeIP: nodeIP, + IsAirgap: flags.isAirgap, + ControllerAirgapStorageSpace: controllerAirgapStorageSpace, } if globalCIDR := rc.GlobalCIDR(); globalCIDR != "" { opts.GlobalCIDR = &globalCIDR diff --git a/cmd/installer/cli/join_runpreflights.go b/cmd/installer/cli/join_runpreflights.go index a8d774b45b..9bdff2be26 100644 --- a/cmd/installer/cli/join_runpreflights.go +++ b/cmd/installer/cli/join_runpreflights.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "github.com/replicatedhq/embedded-cluster/kinds/types/join" newconfig "github.com/replicatedhq/embedded-cluster/pkg-new/config" @@ -104,22 +105,39 @@ func runJoinPreflights(ctx context.Context, jcmd *join.JoinCommandResponse, flag domains := domains.GetDomains(jcmd.InstallationSpec.Config, release.GetChannelRelease()) + // Calculate airgap storage space requirement based on node type + var controllerAirgapStorageSpace string + var workerAirgapStorageSpace string + if jcmd.InstallationSpec.AirGap && jcmd.InstallationSpec.AirgapUncompressedSize > 0 { + // Determine if this is a controller node by checking the join command + isController := strings.Contains(jcmd.K0sJoinCommand, "controller") + if isController { + logrus.Debug("Node type determined from join command: controller") + controllerAirgapStorageSpace = preflights.CalculateAirgapStorageSpace(jcmd.InstallationSpec.AirgapUncompressedSize, true) + } else { + logrus.Debug("Node type determined from join command: worker") + workerAirgapStorageSpace = preflights.CalculateAirgapStorageSpace(jcmd.InstallationSpec.AirgapUncompressedSize, false) + } + } + hpf, err := preflights.Prepare(ctx, preflights.PrepareOptions{ - HostPreflightSpec: release.GetHostPreflights(), - ReplicatedAppURL: netutils.MaybeAddHTTPS(domains.ReplicatedAppDomain), - ProxyRegistryURL: netutils.MaybeAddHTTPS(domains.ProxyRegistryDomain), - AdminConsolePort: rc.AdminConsolePort(), - LocalArtifactMirrorPort: rc.LocalArtifactMirrorPort(), - DataDir: rc.EmbeddedClusterHomeDirectory(), - K0sDataDir: rc.EmbeddedClusterK0sSubDir(), - OpenEBSDataDir: rc.EmbeddedClusterOpenEBSLocalSubDir(), - Proxy: rc.ProxySpec(), - PodCIDR: cidrCfg.PodCIDR, - ServiceCIDR: cidrCfg.ServiceCIDR, - NodeIP: nodeIP, - IsAirgap: jcmd.InstallationSpec.AirGap, - TCPConnectionsRequired: jcmd.TCPConnectionsRequired, - IsJoin: true, + HostPreflightSpec: release.GetHostPreflights(), + ReplicatedAppURL: netutils.MaybeAddHTTPS(domains.ReplicatedAppDomain), + ProxyRegistryURL: netutils.MaybeAddHTTPS(domains.ProxyRegistryDomain), + AdminConsolePort: rc.AdminConsolePort(), + LocalArtifactMirrorPort: rc.LocalArtifactMirrorPort(), + DataDir: rc.EmbeddedClusterHomeDirectory(), + K0sDataDir: rc.EmbeddedClusterK0sSubDir(), + OpenEBSDataDir: rc.EmbeddedClusterOpenEBSLocalSubDir(), + Proxy: rc.ProxySpec(), + PodCIDR: cidrCfg.PodCIDR, + ServiceCIDR: cidrCfg.ServiceCIDR, + NodeIP: nodeIP, + IsAirgap: jcmd.InstallationSpec.AirGap, + TCPConnectionsRequired: jcmd.TCPConnectionsRequired, + IsJoin: true, + ControllerAirgapStorageSpace: controllerAirgapStorageSpace, + WorkerAirgapStorageSpace: workerAirgapStorageSpace, }) if err != nil { return err diff --git a/cmd/installer/cli/restore.go b/cmd/installer/cli/restore.go index 590967335f..5d8dc51f82 100644 --- a/cmd/installer/cli/restore.go +++ b/cmd/installer/cli/restore.go @@ -131,9 +131,9 @@ func runRestore(ctx context.Context, name string, flags InstallCmdFlags, rc runt return err } - if flags.isAirgap { + if flags.airgapInfo != nil { logrus.Debugf("checking airgap bundle matches binary") - if err := checkAirgapMatches(flags.airgapBundle); err != nil { + if err := checkAirgapMatches(flags.airgapInfo); err != nil { return err // we want the user to see the error message without a prefix } } diff --git a/cmd/installer/cli/update.go b/cmd/installer/cli/update.go index ec16ef7abf..3404f62724 100644 --- a/cmd/installer/cli/update.go +++ b/cmd/installer/cli/update.go @@ -7,6 +7,7 @@ import ( "github.com/replicatedhq/embedded-cluster/cmd/installer/kotscli" "github.com/replicatedhq/embedded-cluster/pkg-new/constants" + "github.com/replicatedhq/embedded-cluster/pkg/airgap" "github.com/replicatedhq/embedded-cluster/pkg/dryrun" "github.com/replicatedhq/embedded-cluster/pkg/release" "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" @@ -45,7 +46,14 @@ func UpdateCmd(ctx context.Context, name string) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { if airgapBundle != "" { logrus.Debugf("checking airgap bundle matches binary") - if err := checkAirgapMatches(airgapBundle); err != nil { + + // read file from path + airgapInfo, err := airgap.AirgapInfoFromPath(airgapBundle) + if err != nil { + return fmt.Errorf("failed to get airgap bundle versions: %w", err) + } + + if err := checkAirgapMatches(airgapInfo); err != nil { return err // we want the user to see the error message without a prefix } } diff --git a/go.mod b/go.mod index c3947a3328..810c6b3811 100644 --- a/go.mod +++ b/go.mod @@ -32,7 +32,7 @@ require ( github.com/onsi/gomega v1.37.0 github.com/replicatedhq/embedded-cluster/kinds v0.0.0 github.com/replicatedhq/embedded-cluster/utils v0.0.0 - github.com/replicatedhq/kotskinds v0.0.0-20250411153224-089dbeb7ba2a + github.com/replicatedhq/kotskinds v0.0.0-20250609144916-baa60600998c github.com/replicatedhq/troubleshoot v0.119.1 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.9.1 diff --git a/go.sum b/go.sum index 19061fd91e..5601fb64cf 100644 --- a/go.sum +++ b/go.sum @@ -1464,8 +1464,8 @@ github.com/redis/go-redis/extra/redisotel/v9 v9.0.5/go.mod h1:WZjPDy7VNzn77AAfnA github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/replicatedhq/kotskinds v0.0.0-20250411153224-089dbeb7ba2a h1:aNZ7qcuEmPGIUIIfxF7c0sdKR2+zL2vc5r2V8j8a49I= -github.com/replicatedhq/kotskinds v0.0.0-20250411153224-089dbeb7ba2a/go.mod h1:QjhIUu3+OmHZ09u09j3FCoTt8F3BYtQglS+OLmftu9I= +github.com/replicatedhq/kotskinds v0.0.0-20250609144916-baa60600998c h1:lnCL/wYi2BFTnxOP/lmo9WJwVPG3fk/plgJ/9NrMFw4= +github.com/replicatedhq/kotskinds v0.0.0-20250609144916-baa60600998c/go.mod h1:QjhIUu3+OmHZ09u09j3FCoTt8F3BYtQglS+OLmftu9I= github.com/replicatedhq/troubleshoot v0.119.1 h1:AroLbVdtkPMv32va4z2lNdOcShFbT357oPZeECP2aOA= github.com/replicatedhq/troubleshoot v0.119.1/go.mod h1:50okSHbWEqlbNQY1kCilXKphjGPBQ6JewezFOu47l8c= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= diff --git a/kinds/apis/v1beta1/installation_types.go b/kinds/apis/v1beta1/installation_types.go index 7f842a12f9..2193e38c35 100644 --- a/kinds/apis/v1beta1/installation_types.go +++ b/kinds/apis/v1beta1/installation_types.go @@ -157,6 +157,8 @@ type InstallationSpec struct { HighAvailability bool `json:"highAvailability,omitempty"` // AirGap indicates if the installation is airgapped. AirGap bool `json:"airGap,omitempty"` + // AirgapUncompressedSize holds the size of the uncompressed airgap bundle in bytes. + AirgapUncompressedSize int64 `json:"airgapUncompressedSize,omitempty"` // EndUserK0sConfigOverrides holds the end user k0s config overrides // used at installation time. EndUserK0sConfigOverrides string `json:"endUserK0sConfigOverrides,omitempty"` diff --git a/operator/charts/embedded-cluster-operator/charts/crds/templates/resources.yaml b/operator/charts/embedded-cluster-operator/charts/crds/templates/resources.yaml index e8b53ae965..4934bb3613 100644 --- a/operator/charts/embedded-cluster-operator/charts/crds/templates/resources.yaml +++ b/operator/charts/embedded-cluster-operator/charts/crds/templates/resources.yaml @@ -313,6 +313,10 @@ spec: airGap: description: AirGap indicates if the installation is airgapped. type: boolean + airgapUncompressedSize: + description: AirgapUncompressedSize holds the size of the uncompressed airgap bundle in bytes. + format: int64 + type: integer artifacts: description: Artifacts holds the location of the airgap bundle. properties: diff --git a/operator/config/crd/bases/embeddedcluster.replicated.com_installations.yaml b/operator/config/crd/bases/embeddedcluster.replicated.com_installations.yaml index b52e1086ca..ef4e9c17dd 100644 --- a/operator/config/crd/bases/embeddedcluster.replicated.com_installations.yaml +++ b/operator/config/crd/bases/embeddedcluster.replicated.com_installations.yaml @@ -67,6 +67,11 @@ spec: airGap: description: AirGap indicates if the installation is airgapped. type: boolean + airgapUncompressedSize: + description: AirgapUncompressedSize holds the size of the uncompressed + airgap bundle in bytes. + format: int64 + type: integer artifacts: description: Artifacts holds the location of the airgap bundle. properties: diff --git a/pkg-new/preflights/host-preflight.yaml b/pkg-new/preflights/host-preflight.yaml index a3750c70b4..1894c23099 100644 --- a/pkg-new/preflights/host-preflight.yaml +++ b/pkg-new/preflights/host-preflight.yaml @@ -235,6 +235,42 @@ spec: {{- end }} - pass: message: The filesystem at {{ .DataDir }} has sufficient space + - diskUsage: + checkName: Airgap Storage Space + collectorName: embedded-cluster-path-usage + exclude: '{{ eq .ControllerAirgapStorageSpace "" }}' + outcomes: + - fail: + when: 'available < {{ .ControllerAirgapStorageSpace }}' + message: >- + {{ if .IsUI -}} + The filesystem at {{ .DataDir }} has less than {{ .ControllerAirgapStorageSpace }} of available storage space needed to process the air gap bundle. + Controller nodes require 2x the extracted bundle size for processing. + Ensure sufficient space is available, or go back to the Setup page and choose a different data directory. + {{- else -}} + The filesystem at {{ .DataDir }} has less than {{ .ControllerAirgapStorageSpace }} of available storage space needed to process the air gap bundle. + Controller nodes require 2x the extracted bundle size for processing. + Ensure sufficient space is available, or use --data-dir to specify an alternative data directory. + {{- end }} + - pass: + message: The filesystem at {{ .DataDir }} has sufficient available space for airgap bundle processing + - diskUsage: + checkName: Worker Airgap Storage Space + collectorName: embedded-cluster-path-usage + exclude: '{{ eq .WorkerAirgapStorageSpace "" }}' + outcomes: + - fail: + when: 'available < {{ .WorkerAirgapStorageSpace }}' + message: >- + {{ if .IsUI -}} + The filesystem at {{ .DataDir }} has less than {{ .WorkerAirgapStorageSpace }} of available storage space needed to store infrastructure images. + Ensure sufficient space is available, or go back to the Setup page and choose a different data directory. + {{- else -}} + The filesystem at {{ .DataDir }} has less than {{ .WorkerAirgapStorageSpace }} of available storage space needed to store infrastructure images. + Ensure sufficient space is available, or use --data-dir to specify an alternative data directory. + {{- end }} + - pass: + message: The filesystem at {{ .DataDir }} has sufficient available space for worker airgap storage - textAnalyze: checkName: Default Route fileName: host-collectors/run-host/ip-route-table.txt diff --git a/pkg-new/preflights/prepare.go b/pkg-new/preflights/prepare.go index 94bc60d384..4fd9c2dd61 100644 --- a/pkg-new/preflights/prepare.go +++ b/pkg-new/preflights/prepare.go @@ -17,23 +17,25 @@ var ErrPreflightsHaveFail = metrics.NewErrorNoFail(fmt.Errorf("host preflight fa // PrepareOptions contains options for preparing preflights (shared across CLI and API) type PrepareOptions struct { - HostPreflightSpec *v1beta2.HostPreflightSpec - ReplicatedAppURL string - ProxyRegistryURL string - AdminConsolePort int - LocalArtifactMirrorPort int - DataDir string - K0sDataDir string - OpenEBSDataDir string - Proxy *ecv1beta1.ProxySpec - PodCIDR string - ServiceCIDR string - GlobalCIDR *string - NodeIP string - IsAirgap bool - TCPConnectionsRequired []string - IsJoin bool - IsUI bool + HostPreflightSpec *v1beta2.HostPreflightSpec + ReplicatedAppURL string + ProxyRegistryURL string + AdminConsolePort int + LocalArtifactMirrorPort int + DataDir string + K0sDataDir string + OpenEBSDataDir string + Proxy *ecv1beta1.ProxySpec + PodCIDR string + ServiceCIDR string + GlobalCIDR *string + NodeIP string + IsAirgap bool + TCPConnectionsRequired []string + IsJoin bool + IsUI bool + ControllerAirgapStorageSpace string + WorkerAirgapStorageSpace string } // Prepare prepares the host preflights spec by merging provided spec with cluster preflights @@ -44,21 +46,23 @@ func (p *PreflightsRunner) Prepare(ctx context.Context, opts PrepareOptions) (*v } data, err := types.TemplateData{ - ReplicatedAppURL: opts.ReplicatedAppURL, - ProxyRegistryURL: opts.ProxyRegistryURL, - IsAirgap: opts.IsAirgap, - AdminConsolePort: opts.AdminConsolePort, - LocalArtifactMirrorPort: opts.LocalArtifactMirrorPort, - DataDir: opts.DataDir, - K0sDataDir: opts.K0sDataDir, - OpenEBSDataDir: opts.OpenEBSDataDir, - SystemArchitecture: runtime.GOARCH, - FromCIDR: opts.PodCIDR, - ToCIDR: opts.ServiceCIDR, - TCPConnectionsRequired: opts.TCPConnectionsRequired, - NodeIP: opts.NodeIP, - IsJoin: opts.IsJoin, - IsUI: opts.IsUI, + ReplicatedAppURL: opts.ReplicatedAppURL, + ProxyRegistryURL: opts.ProxyRegistryURL, + IsAirgap: opts.IsAirgap, + AdminConsolePort: opts.AdminConsolePort, + LocalArtifactMirrorPort: opts.LocalArtifactMirrorPort, + DataDir: opts.DataDir, + K0sDataDir: opts.K0sDataDir, + OpenEBSDataDir: opts.OpenEBSDataDir, + SystemArchitecture: runtime.GOARCH, + FromCIDR: opts.PodCIDR, + ToCIDR: opts.ServiceCIDR, + TCPConnectionsRequired: opts.TCPConnectionsRequired, + NodeIP: opts.NodeIP, + IsJoin: opts.IsJoin, + IsUI: opts.IsUI, + ControllerAirgapStorageSpace: opts.ControllerAirgapStorageSpace, + WorkerAirgapStorageSpace: opts.WorkerAirgapStorageSpace, }.WithCIDRData(opts.PodCIDR, opts.ServiceCIDR, opts.GlobalCIDR) if err != nil { diff --git a/pkg-new/preflights/template.go b/pkg-new/preflights/template.go index 6b5589994d..41e6863b7a 100644 --- a/pkg-new/preflights/template.go +++ b/pkg-new/preflights/template.go @@ -5,6 +5,7 @@ import ( "context" _ "embed" "fmt" + "math" "text/template" "github.com/replicatedhq/embedded-cluster/pkg-new/preflights/types" @@ -44,3 +45,35 @@ func renderTemplate(spec string, data types.TemplateData) (string, error) { } return buf.String(), nil } + +const ( + multiplierController = 2 + multiplierWorker = 1 +) + +// CalculateAirgapStorageSpace calculates required storage space for airgap installations. +// Controller nodes need 2x uncompressed size, worker nodes need 1x. Returns "XGi" or "XMi". +func CalculateAirgapStorageSpace(uncompressedSize int64, isController bool) string { + if uncompressedSize <= 0 { + return "" + } + + requiredBytes := uncompressedSize * multiplierWorker + if isController { + // Controller nodes require 2x the extracted bundle size for processing + requiredBytes = uncompressedSize * multiplierController + } + + // Convert to Gi if >= 1 Gi, otherwise use Mi + if requiredBytes >= 1024*1024*1024 { // 1 Gi in bytes + // Convert to Gi and round up to nearest natural number + giValue := float64(requiredBytes) / (1024 * 1024 * 1024) + roundedGi := math.Ceil(giValue) + return fmt.Sprintf("%dGi", int64(roundedGi)) + } else { + // Convert to Mi and round up to nearest natural number + miValue := float64(requiredBytes) / (1024 * 1024) + roundedMi := math.Ceil(miValue) + return fmt.Sprintf("%dMi", int64(roundedMi)) + } +} diff --git a/pkg-new/preflights/template_test.go b/pkg-new/preflights/template_test.go index 16e7289cc2..274225efcb 100644 --- a/pkg-new/preflights/template_test.go +++ b/pkg-new/preflights/template_test.go @@ -620,3 +620,74 @@ func TestTemplateTCPConnectionsRequired(t *testing.T) { }) } } + +func TestCalculateAirgapStorageSpace(t *testing.T) { + tests := []struct { + name string + uncompressedSize int64 + isController bool + expected string + }{ + { + name: "controller node with 1GB uncompressed size", + uncompressedSize: 1024 * 1024 * 1024, // 1GB + isController: true, + expected: "2Gi", // 2x for controller + }, + { + name: "worker node with 1GB uncompressed size", + uncompressedSize: 1024 * 1024 * 1024, // 1GB + isController: false, + expected: "1Gi", // 1x for worker + }, + { + name: "controller node with 500MB uncompressed size", + uncompressedSize: 500 * 1024 * 1024, // 500MB + isController: true, + expected: "1000Mi", // 2x + }, + { + name: "controller node with 500MB uncompressed size", + uncompressedSize: 512 * 1024 * 1024, // 500MB + isController: true, + expected: "1Gi", // 2x + }, + { + name: "worker node with 500MB uncompressed size", + uncompressedSize: 500 * 1024 * 1024, // 500MB + isController: false, + expected: "500Mi", // 1x = 500MB + }, + { + name: "controller node with 100MB uncompressed size", + uncompressedSize: 100 * 1024 * 1024, // 100MB + isController: true, + expected: "200Mi", // 2x = 200MB + }, + { + name: "worker node with 100MB uncompressed size", + uncompressedSize: 100 * 1024 * 1024, // 100MB + isController: false, + expected: "100Mi", // 1x = 100MB + }, + { + name: "zero uncompressed size", + uncompressedSize: 0, + isController: true, + expected: "", + }, + { + name: "negative uncompressed size", + uncompressedSize: -1, + isController: false, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := CalculateAirgapStorageSpace(tt.uncompressedSize, tt.isController) + require.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg-new/preflights/types/template.go b/pkg-new/preflights/types/template.go index 5bcff8fe75..e6ef6c6e02 100644 --- a/pkg-new/preflights/types/template.go +++ b/pkg-new/preflights/types/template.go @@ -13,28 +13,30 @@ type CIDRData struct { } type TemplateData struct { - IsAirgap bool - ReplicatedAppURL string - ProxyRegistryURL string - AdminConsolePort int - LocalArtifactMirrorPort int - DataDir string - K0sDataDir string - OpenEBSDataDir string - SystemArchitecture string - ServiceCIDR CIDRData - PodCIDR CIDRData - GlobalCIDR CIDRData - HTTPProxy string - HTTPSProxy string - ProvidedNoProxy string - NoProxy string - FromCIDR string - ToCIDR string - TCPConnectionsRequired []string - NodeIP string - IsJoin bool - IsUI bool + IsAirgap bool + ReplicatedAppURL string + ProxyRegistryURL string + AdminConsolePort int + LocalArtifactMirrorPort int + DataDir string + K0sDataDir string + OpenEBSDataDir string + SystemArchitecture string + ServiceCIDR CIDRData + PodCIDR CIDRData + GlobalCIDR CIDRData + HTTPProxy string + HTTPSProxy string + ProvidedNoProxy string + NoProxy string + FromCIDR string + ToCIDR string + TCPConnectionsRequired []string + NodeIP string + IsJoin bool + IsUI bool + ControllerAirgapStorageSpace string + WorkerAirgapStorageSpace string } // WithCIDRData sets the respective CIDR properties in the TemplateData struct based on the provided CIDR strings diff --git a/pkg/airgap/airgapinfo.go b/pkg/airgap/airgapinfo.go new file mode 100644 index 0000000000..d31aeee375 --- /dev/null +++ b/pkg/airgap/airgapinfo.go @@ -0,0 +1,61 @@ +package airgap + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "io" + "os" + + "github.com/pkg/errors" + kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "sigs.k8s.io/yaml" +) + +// AirgapInfoFromReader extracts the airgap metadata from the airgap file and returns it +func AirgapInfoFromReader(reader io.Reader) (metadata *kotsv1beta1.Airgap, err error) { + // decompress tarball + ungzip, err := gzip.NewReader(reader) + if err != nil { + return nil, fmt.Errorf("failed to decompress airgap file: %w", err) + } + defer ungzip.Close() + + // iterate through tarball + tarreader := tar.NewReader(ungzip) + var nextFile *tar.Header + for { + nextFile, err = tarreader.Next() + if err != nil { + if err == io.EOF { + return nil, errors.Wrapf(err, "airgap.yaml not found in airgap file") + } + return nil, errors.Wrapf(err, "failed to read airgap file") + } + + if nextFile.Name == "airgap.yaml" { + var contents []byte + contents, err = io.ReadAll(tarreader) + if err != nil { + return nil, errors.Wrapf(err, "failed to read airgap.yaml file within airgap file") + } + parsed := kotsv1beta1.Airgap{} + + err := yaml.Unmarshal(contents, &parsed) + if err != nil { + return nil, errors.Wrapf(err, "failed to unmarshal airgap.yaml file within airgap file") + } + return &parsed, nil + } + } +} + +func AirgapInfoFromPath(path string) (metadata *kotsv1beta1.Airgap, err error) { + reader, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("failed to open airgap file: %w", err) + } + defer reader.Close() + + return AirgapInfoFromReader(reader) +} diff --git a/pkg/airgap/version_test.go b/pkg/airgap/airgapinfo_test.go similarity index 79% rename from pkg/airgap/version_test.go rename to pkg/airgap/airgapinfo_test.go index d6caf87d88..1413b6ae7e 100644 --- a/pkg/airgap/version_test.go +++ b/pkg/airgap/airgapinfo_test.go @@ -36,15 +36,28 @@ func TestAirgapBundleVersions(t *testing.T) { t.Logf("Current working directory: %s", dir) airgapReader := createTarballFromDir(filepath.Join(dir, "testfiles", tt.airgapDir), nil) - appSlug, channelID, versionLabel, err := ChannelReleaseMetadata(airgapReader) + airgapInfo, err := AirgapInfoFromReader(airgapReader) req.NoError(err) - req.Equal(tt.wantAppslug, appSlug) - req.Equal(tt.wantChannelid, channelID) - req.Equal(tt.wantVersionlabel, versionLabel) + req.Equal(tt.wantAppslug, airgapInfo.Spec.AppSlug) + req.Equal(tt.wantChannelid, airgapInfo.Spec.ChannelID) + req.Equal(tt.wantVersionlabel, airgapInfo.Spec.VersionLabel) }) } } +func TestAirgapBundleSize(t *testing.T) { + req := require.New(t) + + dir, err := os.Getwd() + req.NoError(err) + t.Logf("Current working directory: %s", dir) + airgapReader := createTarballFromDir(filepath.Join(dir, "testfiles", "tiny-airgap-noimages"), nil) + + airgapInfo, err := AirgapInfoFromReader(airgapReader) + req.NoError(err) + req.Equal(int64(1234567890), airgapInfo.Spec.UncompressedSize) +} + func createTarballFromDir(rootPath string, additionalFiles map[string][]byte) io.Reader { appTarReader, appWriter := io.Pipe() gWriter := gzip.NewWriter(appWriter) diff --git a/pkg/airgap/testfiles/tiny-airgap-noimages/airgap.yaml b/pkg/airgap/testfiles/tiny-airgap-noimages/airgap.yaml index d762d57fe2..51975c7c3d 100644 --- a/pkg/airgap/testfiles/tiny-airgap-noimages/airgap.yaml +++ b/pkg/airgap/testfiles/tiny-airgap-noimages/airgap.yaml @@ -15,4 +15,5 @@ spec: signature: PQ4Zs4e4g1sgrd1lYog2i23+ixbDXX3NancOcDdK+JqD1S4elmkHhsGIUazIl15rL4YuJQdzeem0geK14PKADN+0YLzvEVm9Gw1Coq+y3ZDpUn2+On7caK4k1vckAEbomUDw7Cm5AGxYDFPiz4iC+OnKmFYdfU8FqSNT0iMUxjTvBLdlIf9VOh5wsbi5J511UCEv2Ht9UexcNGobgolrC5AUWKAvbH4mGxnSXVRSHjXtsAJaIw/Aw0RdQ830AIaTEz+L+qbgwasQG7lExkaQz2dEbywPP2nL9ZiyCOIAsjaKiclGx8HzKDCk7Womt4+WOfusqyk6mUFe/EflX/l2TA== updateCursor: "1" versionLabel: 0.1.0 + uncompressedSize: 1234567890 status: {} diff --git a/pkg/airgap/version.go b/pkg/airgap/version.go deleted file mode 100644 index faa696fd77..0000000000 --- a/pkg/airgap/version.go +++ /dev/null @@ -1,67 +0,0 @@ -package airgap - -import ( - "archive/tar" - "compress/gzip" - "fmt" - "io" - - kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" - "sigs.k8s.io/yaml" -) - -// ChannelReleaseMetadata returns the appSlug, channelID, and versionLabel of the airgap bundle -func ChannelReleaseMetadata(reader io.Reader) (appSlug, channelID, versionLabel string, err error) { - - // decompress tarball - ungzip, err := gzip.NewReader(reader) - if err != nil { - err = fmt.Errorf("failed to decompress airgap file: %w", err) - return - } - defer ungzip.Close() - - // iterate through tarball - tarreader := tar.NewReader(ungzip) - var nextFile *tar.Header - for { - nextFile, err = tarreader.Next() - if err != nil { - if err == io.EOF { - err = fmt.Errorf("app release not found in airgap file") - return - } - err = fmt.Errorf("failed to read airgap file: %w", err) - return - } - - if nextFile.Name == "airgap.yaml" { - var contents []byte - contents, err = io.ReadAll(tarreader) - if err != nil { - err = fmt.Errorf("failed to read airgap.yaml file within airgap file: %w", err) - return - } - var airgapInfo kotsv1beta1.Airgap - airgapInfo, err = airgapYamlVersions(contents) - if err != nil { - err = fmt.Errorf("failed to parse airgap.yaml: %w", err) - return - } - appSlug = airgapInfo.Spec.AppSlug - channelID = airgapInfo.Spec.ChannelID - versionLabel = airgapInfo.Spec.VersionLabel - return - } - } -} - -func airgapYamlVersions(contents []byte) (kotsv1beta1.Airgap, error) { - parsed := kotsv1beta1.Airgap{} - - err := yaml.Unmarshal(contents, &parsed) - if err != nil { - return kotsv1beta1.Airgap{}, err - } - return parsed, nil -} diff --git a/pkg/crds/resources.yaml b/pkg/crds/resources.yaml index e8b53ae965..4934bb3613 100644 --- a/pkg/crds/resources.yaml +++ b/pkg/crds/resources.yaml @@ -313,6 +313,10 @@ spec: airGap: description: AirGap indicates if the installation is airgapped. type: boolean + airgapUncompressedSize: + description: AirgapUncompressedSize holds the size of the uncompressed airgap bundle in bytes. + format: int64 + type: integer artifacts: description: Artifacts holds the location of the airgap bundle. properties: diff --git a/pkg/kubeutils/installation.go b/pkg/kubeutils/installation.go index 6e49a94a53..2ae7d168ae 100644 --- a/pkg/kubeutils/installation.go +++ b/pkg/kubeutils/installation.go @@ -122,12 +122,13 @@ func writeInstallationStatusMessage(writer *spinner.MessageWriter, install *ecv1 } type RecordInstallationOptions struct { - IsAirgap bool - License *kotsv1beta1.License - ConfigSpec *ecv1beta1.ConfigSpec - MetricsBaseURL string - RuntimeConfig *ecv1beta1.RuntimeConfigSpec - EndUserConfig *ecv1beta1.Config + IsAirgap bool + License *kotsv1beta1.License + ConfigSpec *ecv1beta1.ConfigSpec + MetricsBaseURL string + RuntimeConfig *ecv1beta1.RuntimeConfigSpec + EndUserConfig *ecv1beta1.Config + AirgapUncompressedSize int64 } func RecordInstallation(ctx context.Context, kcli client.Client, opts RecordInstallationOptions) (*ecv1beta1.Installation, error) { @@ -163,6 +164,7 @@ func RecordInstallation(ctx context.Context, kcli client.Client, opts RecordInst ClusterID: metrics.ClusterID().String(), MetricsBaseURL: opts.MetricsBaseURL, AirGap: opts.IsAirgap, + AirgapUncompressedSize: opts.AirgapUncompressedSize, Config: opts.ConfigSpec, RuntimeConfig: opts.RuntimeConfig, EndUserK0sConfigOverrides: euOverrides, diff --git a/pkg/kubeutils/installation_test.go b/pkg/kubeutils/installation_test.go index eccb2dec9a..d614f6b9e7 100644 --- a/pkg/kubeutils/installation_test.go +++ b/pkg/kubeutils/installation_test.go @@ -2,6 +2,7 @@ package kubeutils import ( "context" + "encoding/json" "os" "strings" "testing" @@ -12,7 +13,12 @@ import ( "github.com/go-logr/logr/testr" ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1" "github.com/replicatedhq/embedded-cluster/pkg/crds" + "github.com/replicatedhq/embedded-cluster/pkg/metrics" + "github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig" + kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -180,3 +186,178 @@ func TestEnsureInstallationCRD(t *testing.T) { }) } } + +func TestRecordInstallation(t *testing.T) { + // Setup the test scheme + s := runtime.NewScheme() + require.NoError(t, apiextensionsv1.AddToScheme(s)) + require.NoError(t, ecv1beta1.AddToScheme(s)) + require.NoError(t, corev1.AddToScheme(s)) + + tests := []struct { + name string + opts RecordInstallationOptions + wantErr bool + validate func(t *testing.T, installation *ecv1beta1.Installation) + }{ + { + name: "online installation without airgap", + opts: RecordInstallationOptions{ + IsAirgap: false, + License: &kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{ + IsDisasterRecoverySupported: true, + IsEmbeddedClusterMultiNodeEnabled: false, + }, + }, + ConfigSpec: &ecv1beta1.ConfigSpec{ + Version: "1.15.0+k8s-1.30", + }, + MetricsBaseURL: "https://replicated.app", + RuntimeConfig: &ecv1beta1.RuntimeConfigSpec{ + DataDir: "/var/lib/embedded-cluster", + }, + EndUserConfig: &ecv1beta1.Config{ + Spec: ecv1beta1.ConfigSpec{ + UnsupportedOverrides: ecv1beta1.UnsupportedOverrides{ + K0s: "apiVersion: k0s.k0sproject.io/v1beta1\nkind: Cluster", + }, + }, + }, + }, + wantErr: false, + validate: func(t *testing.T, installation *ecv1beta1.Installation) { + assert.False(t, installation.Spec.AirGap) + assert.Equal(t, int64(0), installation.Spec.AirgapUncompressedSize) + assert.Equal(t, "1.15.0+k8s-1.30", installation.Spec.Config.Version) + assert.Equal(t, "https://replicated.app", installation.Spec.MetricsBaseURL) + assert.Equal(t, "/var/lib/embedded-cluster", installation.Spec.RuntimeConfig.DataDir) + assert.Equal(t, "apiVersion: k0s.k0sproject.io/v1beta1\nkind: Cluster", installation.Spec.EndUserK0sConfigOverrides) + assert.True(t, installation.Spec.LicenseInfo.IsDisasterRecoverySupported) + assert.False(t, installation.Spec.LicenseInfo.IsMultiNodeEnabled) + assert.Equal(t, ecv1beta1.InstallationStateKubernetesInstalled, installation.Status.State) + assert.Equal(t, "Kubernetes installed", installation.Status.Reason) + }, + }, + { + name: "airgap installation with uncompressed size", + opts: RecordInstallationOptions{ + IsAirgap: true, + License: &kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{ + IsDisasterRecoverySupported: false, + IsEmbeddedClusterMultiNodeEnabled: true, + }, + }, + ConfigSpec: &ecv1beta1.ConfigSpec{ + Version: "1.16.0+k8s-1.31", + }, + MetricsBaseURL: "https://staging.replicated.app", + RuntimeConfig: &ecv1beta1.RuntimeConfigSpec{ + DataDir: "/opt/embedded-cluster", + }, + EndUserConfig: nil, + AirgapUncompressedSize: 1234567890, + }, + wantErr: false, + validate: func(t *testing.T, installation *ecv1beta1.Installation) { + assert.True(t, installation.Spec.AirGap) + assert.Equal(t, int64(1234567890), installation.Spec.AirgapUncompressedSize) + assert.Equal(t, "1.16.0+k8s-1.31", installation.Spec.Config.Version) + assert.Equal(t, "https://staging.replicated.app", installation.Spec.MetricsBaseURL) + assert.Equal(t, "/opt/embedded-cluster", installation.Spec.RuntimeConfig.DataDir) + assert.Empty(t, installation.Spec.EndUserK0sConfigOverrides) + assert.False(t, installation.Spec.LicenseInfo.IsDisasterRecoverySupported) + assert.True(t, installation.Spec.LicenseInfo.IsMultiNodeEnabled) + assert.Equal(t, ecv1beta1.InstallationStateKubernetesInstalled, installation.Status.State) + assert.Equal(t, "Kubernetes installed", installation.Status.Reason) + }, + }, + { + name: "airgap installation with large uncompressed size", + opts: RecordInstallationOptions{ + IsAirgap: true, + License: &kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{ + IsDisasterRecoverySupported: false, + IsEmbeddedClusterMultiNodeEnabled: false, + }, + }, + ConfigSpec: &ecv1beta1.ConfigSpec{ + Version: "1.18.0+k8s-1.33", + }, + MetricsBaseURL: "https://custom.replicated.app", + RuntimeConfig: &ecv1beta1.RuntimeConfigSpec{ + DataDir: "/custom/data/dir", + }, + EndUserConfig: nil, + AirgapUncompressedSize: 9876543210, + }, + wantErr: false, + validate: func(t *testing.T, installation *ecv1beta1.Installation) { + assert.True(t, installation.Spec.AirGap) + assert.Equal(t, int64(9876543210), installation.Spec.AirgapUncompressedSize) + assert.Equal(t, "1.18.0+k8s-1.33", installation.Spec.Config.Version) + assert.Equal(t, "https://custom.replicated.app", installation.Spec.MetricsBaseURL) + assert.Equal(t, "/custom/data/dir", installation.Spec.RuntimeConfig.DataDir) + assert.Empty(t, installation.Spec.EndUserK0sConfigOverrides) + assert.False(t, installation.Spec.LicenseInfo.IsDisasterRecoverySupported) + assert.False(t, installation.Spec.LicenseInfo.IsMultiNodeEnabled) + assert.Equal(t, ecv1beta1.InstallationStateKubernetesInstalled, installation.Status.State) + assert.Equal(t, "Kubernetes installed", installation.Status.Reason) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup the test environment + verbosity := 1 + if os.Getenv("DEBUG") != "" { + verbosity = 10 + } + log := testr.NewWithOptions(t, testr.Options{Verbosity: verbosity}) + ctx := logr.NewContext(context.Background(), log) + + testEnv := &envtest.Environment{} + cfg, err := testEnv.Start() + require.NoError(t, err) + t.Cleanup(func() { _ = testEnv.Stop() }) + + cli, err := client.New(cfg, client.Options{Scheme: s}) + require.NoError(t, err) + + // Call the function being tested + installation, err := RecordInstallation(ctx, cli, tt.opts) + + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.NotNil(t, installation) + + // Verify the installation was created in the cluster + var resultInstallation ecv1beta1.Installation + err = cli.Get(ctx, client.ObjectKey{Name: installation.Name}, &resultInstallation) + require.NoError(t, err) + + // Run custom validation + if tt.validate != nil { + tt.validate(t, &resultInstallation) + } + + json, err := json.MarshalIndent(resultInstallation, "", " ") + require.NoError(t, err) + t.Logf("resultInstallation: %s", string(json)) + // Verify common fields + assert.NotEmpty(t, resultInstallation.Name) + assert.Equal(t, "", resultInstallation.APIVersion) // I expected this to be "embeddedcluster.replicated.com/v1beta1" + assert.Equal(t, "", resultInstallation.Kind) // I expected this to be "Installation" + assert.Equal(t, metrics.ClusterID().String(), resultInstallation.Spec.ClusterID) + assert.Equal(t, runtimeconfig.BinaryName(), resultInstallation.Spec.BinaryName) + assert.Equal(t, ecv1beta1.InstallationSourceTypeCRD, resultInstallation.Spec.SourceType) + assert.Equal(t, "ec-install", resultInstallation.Labels["replicated.com/disaster-recovery"]) + }) + } +}