Skip to content

Commit 3e157e7

Browse files
authored
feat(v3): headless install cli flags and validation (#3133)
1 parent ab2670a commit 3e157e7

File tree

8 files changed

+1687
-387
lines changed

8 files changed

+1687
-387
lines changed

api/controllers/app/controller.go

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -202,11 +202,8 @@ func NewAppController(opts ...AppControllerOption) (*AppController, error) {
202202
}
203203

204204
if controller.configValues != nil {
205-
err := controller.appConfigManager.ValidateConfigValues(controller.configValues)
206-
if err != nil {
207-
return nil, fmt.Errorf("validate app config values: %w", err)
208-
}
209-
err = controller.appConfigManager.PatchConfigValues(controller.configValues)
205+
// Prepopulate the config values in the UI
206+
err := controller.appConfigManager.PatchConfigValues(controller.configValues)
210207
if err != nil {
211208
return nil, fmt.Errorf("patch app config values: %w", err)
212209
}

cmd/installer/cli/api.go

Lines changed: 31 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ type apiOptions struct {
2828
apitypes.APIConfig
2929

3030
ManagerPort int
31+
Headless bool
3132
// The mode the web will be running on, install or upgrade
3233
WebMode web.Mode
3334

@@ -41,6 +42,7 @@ func startAPI(ctx context.Context, cert tls.Certificate, opts apiOptions, cancel
4142
if err != nil {
4243
return fmt.Errorf("unable to create listener: %w", err)
4344
}
45+
logrus.Debugf("API server listening on port: %d", opts.ManagerPort)
4446

4547
go func() {
4648
// If the api exits, we want to exit the entire process
@@ -52,8 +54,17 @@ func startAPI(ctx context.Context, cert tls.Certificate, opts apiOptions, cancel
5254
}
5355
}()
5456

55-
addr := fmt.Sprintf("localhost:%d", opts.ManagerPort)
56-
if err := waitForAPI(ctx, addr); err != nil {
57+
addr := fmt.Sprintf("https://localhost:%d", opts.ManagerPort)
58+
httpClient := &http.Client{
59+
Timeout: 2 * time.Second,
60+
Transport: &http.Transport{
61+
Proxy: nil,
62+
TLSClientConfig: &tls.Config{
63+
InsecureSkipVerify: true,
64+
},
65+
},
66+
}
67+
if err := waitForAPI(ctx, httpClient, addr); err != nil {
5768
return fmt.Errorf("unable to wait for api: %w", err)
5869
}
5970

@@ -84,20 +95,23 @@ func serveAPI(ctx context.Context, listener net.Listener, cert tls.Certificate,
8495
return fmt.Errorf("new api: %w", err)
8596
}
8697

87-
webServer, err := web.New(web.InitialState{
88-
Title: opts.ReleaseData.Application.Spec.Title,
89-
Icon: opts.ReleaseData.Application.Spec.Icon,
90-
InstallTarget: string(opts.InstallTarget),
91-
Mode: opts.WebMode,
92-
IsAirgap: opts.AirgapBundle != "",
93-
RequiresInfraUpgrade: opts.RequiresInfraUpgrade,
94-
}, web.WithLogger(logger), web.WithAssetsFS(opts.WebAssetsFS))
95-
if err != nil {
96-
return fmt.Errorf("new web server: %w", err)
97-
}
98-
9998
api.RegisterRoutes(router.PathPrefix("/api").Subrouter())
100-
webServer.RegisterRoutes(router.PathPrefix("/").Subrouter())
99+
100+
// Only start web server for UI mode, not headless
101+
if !opts.Headless {
102+
webServer, err := web.New(web.InitialState{
103+
Title: opts.ReleaseData.Application.Spec.Title,
104+
Icon: opts.ReleaseData.Application.Spec.Icon,
105+
InstallTarget: string(opts.InstallTarget),
106+
Mode: opts.WebMode,
107+
IsAirgap: opts.AirgapBundle != "",
108+
RequiresInfraUpgrade: opts.RequiresInfraUpgrade,
109+
}, web.WithLogger(logger), web.WithAssetsFS(opts.WebAssetsFS))
110+
if err != nil {
111+
return fmt.Errorf("new web server: %w", err)
112+
}
113+
webServer.RegisterRoutes(router.PathPrefix("/").Subrouter())
114+
}
101115

102116
server := &http.Server{
103117
// ErrorLog outputs TLS errors and warnings to the console, we want to make sure we use the same logrus logger for them
@@ -126,16 +140,7 @@ func loggerFromOptions(opts apiOptions) (logrus.FieldLogger, error) {
126140
return logger, nil
127141
}
128142

129-
func waitForAPI(ctx context.Context, addr string) error {
130-
httpClient := http.Client{
131-
Timeout: 2 * time.Second,
132-
Transport: &http.Transport{
133-
Proxy: nil,
134-
TLSClientConfig: &tls.Config{
135-
InsecureSkipVerify: true,
136-
},
137-
},
138-
}
143+
func waitForAPI(ctx context.Context, httpClient *http.Client, addr string) error {
139144
timeout := time.After(10 * time.Second)
140145
var lastErr error
141146
for {
@@ -148,7 +153,7 @@ func waitForAPI(ctx context.Context, addr string) error {
148153
}
149154
return fmt.Errorf("api did not start in time")
150155
case <-time.Tick(1 * time.Second):
151-
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("https://%s/api/health", addr), nil)
156+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/api/health", addr), nil)
152157
if err != nil {
153158
lastErr = fmt.Errorf("unable to create request: %w", err)
154159
continue

cmd/installer/cli/install.go

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ type installFlags struct {
6262
assumeYes bool
6363
overrides string
6464
configValues string
65+
headless bool
6566

6667
// linux flags
6768
dataDir string
@@ -97,6 +98,7 @@ type installConfig struct {
9798
tlsCert tls.Certificate
9899
tlsCertBytes []byte
99100
tlsKeyBytes []byte
101+
configValues *kotsv1beta1.ConfigValues
100102
}
101103

102104
// webAssetsFS is the filesystem to be used by the web component. Defaults to nil allowing the web server to use the default assets embedded in the binary. Useful for testing.
@@ -129,6 +131,7 @@ func InstallCmd(ctx context.Context, appSlug, appTitle string) *cobra.Command {
129131
if err != nil {
130132
return err
131133
}
134+
132135
if err := verifyAndPrompt(ctx, cmd, appSlug, &flags, installCfg, prompts.New()); err != nil {
133136
return err
134137
}
@@ -346,13 +349,17 @@ func addTLSFlags(cmd *cobra.Command, flags *installFlags) error {
346349

347350
func addManagementConsoleFlags(cmd *cobra.Command, flags *installFlags) error {
348351
cmd.Flags().IntVar(&flags.managerPort, "manager-port", ecv1beta1.DefaultManagerPort, "Port on which the Manager will be served")
352+
cmd.Flags().BoolVar(&flags.headless, "headless", false, "Run installation in headless mode without UI interaction.")
349353

350354
// If the ENABLE_V3 environment variable is set, default to the new manager experience and do
351355
// not hide the manager-port flag.
352356
if !isV3Enabled() {
353357
if err := cmd.Flags().MarkHidden("manager-port"); err != nil {
354358
return err
355359
}
360+
if err := cmd.Flags().MarkHidden("headless"); err != nil {
361+
return err
362+
}
356363
}
357364

358365
return nil
@@ -493,12 +500,8 @@ func runManagerExperienceInstall(
493500
}
494501

495502
var configValues apitypes.AppConfigValues
496-
if flags.configValues != "" {
497-
kotsConfigValues, err := helpers.ParseConfigValues(flags.configValues)
498-
if err != nil {
499-
return fmt.Errorf("parse config values file: %w", err)
500-
}
501-
configValues = apitypes.ConvertToAppConfigValues(kotsConfigValues)
503+
if installCfg.configValues != nil {
504+
configValues = apitypes.ConvertToAppConfigValues(installCfg.configValues)
502505
}
503506

504507
apiConfig := apiOptions{
@@ -532,6 +535,7 @@ func runManagerExperienceInstall(
532535
},
533536

534537
ManagerPort: flags.managerPort,
538+
Headless: flags.headless,
535539
WebMode: web.ModeInstall,
536540
MetricsReporter: metricsReporter,
537541
}
@@ -543,6 +547,11 @@ func runManagerExperienceInstall(
543547
return fmt.Errorf("failed to start api: %w", err)
544548
}
545549

550+
if flags.headless {
551+
// TODO(PR2): Implement headless installation orchestration
552+
return fmt.Errorf("headless installation is not yet fully implemented - coming in a future release")
553+
}
554+
546555
logrus.Infof("\nVisit the %s manager to continue: %s\n",
547556
appTitle,
548557
getManagerURL(flags.hostname, flags.managerPort))
@@ -718,10 +727,11 @@ func verifyAndPrompt(ctx context.Context, cmd *cobra.Command, appSlug string, fl
718727
}
719728

720729
logrus.Debugf("checking license matches")
721-
license, err := verifyLicense(installCfg.license)
730+
err = verifyLicense(installCfg.license)
722731
if err != nil {
723732
return err
724733
}
734+
725735
if installCfg.airgapMetadata != nil && installCfg.airgapMetadata.AirgapInfo != nil {
726736
logrus.Debugf("checking airgap bundle matches binary")
727737
if err := checkAirgapMatches(installCfg.airgapMetadata.AirgapInfo); err != nil {
@@ -730,7 +740,7 @@ func verifyAndPrompt(ctx context.Context, cmd *cobra.Command, appSlug string, fl
730740
}
731741

732742
if !installCfg.isAirgap {
733-
if err := maybePromptForAppUpdate(ctx, prompt, license, flags.assumeYes); err != nil {
743+
if err := maybePromptForAppUpdate(ctx, prompt, installCfg.license, flags.assumeYes); err != nil {
734744
if errors.As(err, &ErrorNothingElseToAdd{}) {
735745
return err
736746
}
@@ -796,7 +806,7 @@ func ensureAdminConsolePassword(flags *installFlags) error {
796806
return nil
797807
}
798808

799-
func verifyLicense(license *kotsv1beta1.License) (*kotsv1beta1.License, error) {
809+
func verifyLicense(license *kotsv1beta1.License) error {
800810
rel := release.GetChannelRelease()
801811

802812
// handle the three cases that do not require parsing the license file
@@ -805,42 +815,42 @@ func verifyLicense(license *kotsv1beta1.License) (*kotsv1beta1.License, error) {
805815
// 3. a license and no release, which is not OK
806816
if rel == nil && license == nil {
807817
// no license and no release, this is OK
808-
return nil, nil
818+
return nil
809819
} else if rel == nil && license != nil {
810820
// license is present but no release, this means we would install without vendor charts and k0s overrides
811-
return nil, fmt.Errorf("a license was provided but no release was found in binary, please rerun without the license flag")
821+
return fmt.Errorf("a license was provided but no release was found in binary, please rerun without the license flag")
812822
} else if rel != nil && license == nil {
813823
// release is present but no license, this is not OK
814-
return nil, fmt.Errorf("no license was provided for %s and one is required, please rerun with '--license <path to license file>'", rel.AppSlug)
824+
return fmt.Errorf("no license was provided for %s and one is required, please rerun with '--license <path to license file>'", rel.AppSlug)
815825
}
816826

817827
// Check if the license matches the application version data
818828
if rel.AppSlug != license.Spec.AppSlug {
819829
// if the app is different, we will not be able to provide the correct vendor supplied charts and k0s overrides
820-
return nil, fmt.Errorf("license app %s does not match binary app %s, please provide the correct license", license.Spec.AppSlug, rel.AppSlug)
830+
return fmt.Errorf("license app %s does not match binary app %s, please provide the correct license", license.Spec.AppSlug, rel.AppSlug)
821831
}
822832

823833
// Ensure the binary channel actually is present in the supplied license
824834
if err := checkChannelExistence(license, rel); err != nil {
825-
return nil, err
835+
return err
826836
}
827837

828838
if license.Spec.Entitlements["expires_at"].Value.StrVal != "" {
829839
// read the expiration date, and check it against the current date
830840
expiration, err := time.Parse(time.RFC3339, license.Spec.Entitlements["expires_at"].Value.StrVal)
831841
if err != nil {
832-
return nil, fmt.Errorf("parse expiration date: %w", err)
842+
return fmt.Errorf("parse expiration date: %w", err)
833843
}
834844
if time.Now().After(expiration) {
835-
return nil, fmt.Errorf("license expired on %s, please provide a valid license", expiration)
845+
return fmt.Errorf("license expired on %s, please provide a valid license", expiration)
836846
}
837847
}
838848

839849
if !license.Spec.IsEmbeddedClusterDownloadEnabled {
840-
return nil, fmt.Errorf("license does not have embedded cluster enabled, please provide a valid license")
850+
return fmt.Errorf("license does not have embedded cluster enabled, please provide a valid license")
841851
}
842852

843-
return license, nil
853+
return nil
844854
}
845855

846856
// checkChannelExistence verifies that a channel exists in a supplied license, returning a user-friendly

cmd/installer/cli/install_config.go

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"os"
99

1010
"github.com/google/uuid"
11+
apitypes "github.com/replicatedhq/embedded-cluster/api/types"
1112
"github.com/replicatedhq/embedded-cluster/cmd/installer/goods"
1213
newconfig "github.com/replicatedhq/embedded-cluster/pkg-new/config"
1314
"github.com/replicatedhq/embedded-cluster/pkg-new/tlsutils"
@@ -81,6 +82,29 @@ func buildInstallFlags(cmd *cobra.Command, flags *installFlags) error {
8182
}
8283
flags.proxySpec = proxy
8384

85+
// Headless installation validation
86+
if isV3Enabled() && flags.headless {
87+
if err := validateHeadlessInstallFlags(flags); err != nil {
88+
return err
89+
}
90+
}
91+
92+
return nil
93+
}
94+
95+
func validateHeadlessInstallFlags(flags *installFlags) error {
96+
if flags.configValues == "" {
97+
return fmt.Errorf("--config-values flag is required for headless installation")
98+
}
99+
100+
if flags.adminConsolePassword == "" {
101+
return fmt.Errorf("--admin-console-password flag is required for headless installation")
102+
}
103+
104+
if flags.target != string(apitypes.InstallTargetLinux) {
105+
return fmt.Errorf("headless installation only supports --target=linux (got: %s)", flags.target)
106+
}
107+
84108
return nil
85109
}
86110

@@ -118,6 +142,13 @@ func buildInstallConfig(flags *installFlags) (*installConfig, error) {
118142
if err != nil {
119143
return nil, fmt.Errorf("config values file is not valid: %w", err)
120144
}
145+
146+
// Parse the config values file
147+
cv, err := helpers.ParseConfigValues(flags.configValues)
148+
if err != nil {
149+
return nil, fmt.Errorf("failed to parse config values file: %w", err)
150+
}
151+
installCfg.configValues = cv
121152
}
122153

123154
// Airgap detection and metadata
@@ -187,7 +218,7 @@ func processTLSConfig(flags *installFlags, installCfg *installConfig) error {
187218
installCfg.tlsCertBytes = certBytes
188219
installCfg.tlsKeyBytes = keyBytes
189220
} else if installCfg.enableManagerExperience {
190-
// For manager experience, generate self-signed certificate if none provided
221+
// For manager experience, generate self-signed cert if none provided, with user confirmation
191222
logrus.Warn("\nNo certificate files provided. A self-signed certificate will be used, and your browser will show a security warning.")
192223
logrus.Info("To use your own certificate, provide both --tls-key and --tls-cert flags.")
193224

@@ -198,7 +229,7 @@ func processTLSConfig(flags *installFlags, installCfg *installConfig) error {
198229
return fmt.Errorf("failed to get confirmation: %w", err)
199230
}
200231
if !confirmed {
201-
logrus.Infof("\nInstallation cancelled. Please run the command again with the --tls-key and --tls-cert flags.\n")
232+
logrus.Info("Installation cancelled. Please run the command again with the --tls-key and --tls-cert flags or use the --yes flag to continue with a self-signed certificate.\n")
202233
return fmt.Errorf("installation cancelled by user")
203234
}
204235
}

0 commit comments

Comments
 (0)