Skip to content

Commit 030261b

Browse files
authored
feat(v3): headless install orchestrator stub and dryrun test (#3158)
* feat(v3): headless install orchestrator stub and dryrun test * f
1 parent 9b59a95 commit 030261b

File tree

14 files changed

+398
-27
lines changed

14 files changed

+398
-27
lines changed

.github/workflows/ci.yaml

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,9 @@ jobs:
293293
path: |
294294
./dev/.gocache
295295
./dev/.gomodcache
296-
key: dryrun-tests-go-cache
296+
key: dryrun-tests-go-cache-${{ hashFiles('**/go.sum') }}
297+
restore-keys: |
298+
dryrun-tests-go-cache-
297299
- name: Run tests
298300
env:
299301
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -436,7 +438,9 @@ jobs:
436438
with:
437439
path: |
438440
output/bins
439-
key: bins-cache
441+
key: bins-cache-${{ hashFiles('Makefile', 'versions.mk') }}
442+
restore-keys: |
443+
bins-cache-
440444
441445
- name: Setup go
442446
uses: actions/setup-go@v6
@@ -587,7 +591,9 @@ jobs:
587591
with:
588592
path: |
589593
output/bins
590-
key: bins-cache
594+
key: bins-cache-${{ hashFiles('Makefile', 'versions.mk') }}
595+
restore-keys: |
596+
bins-cache-
591597
592598
- name: Setup go
593599
uses: actions/setup-go@v6

.github/workflows/release-prod.yaml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,9 @@ jobs:
158158
with:
159159
path: |
160160
output/bins
161-
key: bins-cache
161+
key: bins-cache-${{ hashFiles('Makefile', 'versions.mk') }}
162+
restore-keys: |
163+
bins-cache-
162164
163165
- name: Setup Go
164166
uses: actions/setup-go@v6

cmd/installer/cli/api.go

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package cli
33
import (
44
"context"
55
"crypto/tls"
6-
"errors"
76
"fmt"
87
"io/fs"
98
"log"
@@ -37,23 +36,15 @@ type apiOptions struct {
3736
WebAssetsFS fs.FS
3837
}
3938

40-
func startAPI(ctx context.Context, cert tls.Certificate, opts apiOptions, cancel context.CancelFunc) error {
39+
// startAPI starts the API server and returns a channel that will receive an error if the API exits unexpectedly.
40+
// The returned channel will be closed when the API exits normally (via context cancellation).
41+
func startAPI(ctx context.Context, cert tls.Certificate, opts apiOptions) (<-chan error, error) {
4142
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", opts.ManagerPort))
4243
if err != nil {
43-
return fmt.Errorf("unable to create listener: %w", err)
44+
return nil, fmt.Errorf("unable to create listener: %w", err)
4445
}
4546
logrus.Debugf("API server listening on port: %d", opts.ManagerPort)
4647

47-
go func() {
48-
// If the api exits, we want to exit the entire process
49-
defer cancel()
50-
if err := serveAPI(ctx, listener, cert, opts); err != nil {
51-
if !errors.Is(err, http.ErrServerClosed) {
52-
logrus.Errorf("API server exited with error: %v", err)
53-
}
54-
}
55-
}()
56-
5748
addr := fmt.Sprintf("https://localhost:%d", opts.ManagerPort)
5849
httpClient := &http.Client{
5950
Timeout: 2 * time.Second,
@@ -64,11 +55,30 @@ func startAPI(ctx context.Context, cert tls.Certificate, opts apiOptions, cancel
6455
},
6556
},
6657
}
67-
if err := waitForAPI(ctx, httpClient, addr); err != nil {
68-
return fmt.Errorf("unable to wait for api: %w", err)
58+
59+
apiExitCh := make(chan error, 1)
60+
waitErrCh := make(chan error, 1)
61+
62+
go func() {
63+
apiExitCh <- serveAPI(ctx, listener, cert, opts)
64+
}()
65+
66+
go func() {
67+
waitErrCh <- waitForAPI(ctx, httpClient, addr)
68+
}()
69+
70+
select {
71+
case err := <-apiExitCh:
72+
if err != nil {
73+
return nil, fmt.Errorf("serve api: %w", err)
74+
}
75+
case err := <-waitErrCh:
76+
if err != nil {
77+
return apiExitCh, fmt.Errorf("wait for api: %w", err)
78+
}
6979
}
7080

71-
return nil
81+
return apiExitCh, nil
7282
}
7383

7484
func serveAPI(ctx context.Context, listener net.Listener, cert tls.Certificate, opts apiOptions) error {
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package install
2+
3+
import (
4+
"context"
5+
6+
"github.com/stretchr/testify/mock"
7+
)
8+
9+
// MockOrchestrator is a mock implementation of the Orchestrator interface for testing.
10+
// It records method calls and allows configuring return values.
11+
type MockOrchestrator struct {
12+
mock.Mock
13+
}
14+
15+
// NewMockOrchestrator creates a new MockOrchestrator instance
16+
func NewMockOrchestrator() *MockOrchestrator {
17+
return &MockOrchestrator{}
18+
}
19+
20+
// RunHeadlessInstall implements the Orchestrator interface
21+
func (m *MockOrchestrator) RunHeadlessInstall(ctx context.Context, opts HeadlessInstallOptions) error {
22+
return m.Called(ctx, opts).Error(0)
23+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package install
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
apitypes "github.com/replicatedhq/embedded-cluster/api/types"
8+
)
9+
10+
// HeadlessInstallOptions contains the configuration options for a headless installation
11+
type HeadlessInstallOptions struct {
12+
// ConfigValues are the application config values to use for installation
13+
ConfigValues apitypes.AppConfigValues
14+
15+
// LinuxInstallationConfig contains the installation settings for the Linux target
16+
LinuxInstallationConfig apitypes.LinuxInstallationConfig
17+
18+
// IgnoreHostPreflights indicates whether to bypass host preflight check failures
19+
IgnoreHostPreflights bool
20+
21+
// IgnoreAppPreflights indicates whether to bypass app preflight check failures
22+
IgnoreAppPreflights bool
23+
24+
// AirgapBundle is the path to the airgap bundle file (empty string for online installs)
25+
AirgapBundle string
26+
}
27+
28+
// Orchestrator defines the interface for headless installation operations.
29+
// It orchestrates the installation process by interacting with the v3 API server
30+
// running in-process via HTTP calls to localhost.
31+
type Orchestrator interface {
32+
// RunHeadlessInstall executes a complete headless installation workflow.
33+
// It performs the following steps in order:
34+
// 1. Configure application with config values
35+
// 2. Configure installation settings
36+
// 3. Run host preflights (with optional bypass)
37+
// 4. Setup infrastructure
38+
// 5. Process airgap bundle (if provided)
39+
// 6. Run app preflights (with optional bypass)
40+
// 7. Install application
41+
//
42+
// The installation cannot be resumed if it fails after infrastructure setup begins.
43+
// Any failure after that point requires running 'embedded-cluster reset' and retrying.
44+
//
45+
// Returns an error if any step fails, with a descriptive message for recovery.
46+
RunHeadlessInstall(ctx context.Context, opts HeadlessInstallOptions) error
47+
}
48+
49+
// NewOrchestrator creates a new Orchestrator instance
50+
func NewOrchestrator() Orchestrator {
51+
return &orchestrator{}
52+
}
53+
54+
// orchestrator is the default implementation of the Orchestrator interface
55+
type orchestrator struct {
56+
}
57+
58+
// RunHeadlessInstall executes a complete headless installation workflow
59+
func (o *orchestrator) RunHeadlessInstall(ctx context.Context, opts HeadlessInstallOptions) error {
60+
// TODO(PR2): Implement real headless installation orchestration
61+
return fmt.Errorf("headless installation is not yet fully implemented - coming in a future release")
62+
}

cmd/installer/cli/install.go

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -543,21 +543,50 @@ func runManagerExperienceInstall(
543543
ctx, cancel := context.WithCancel(ctx)
544544
defer cancel()
545545

546-
if err := startAPI(ctx, installCfg.tlsCert, apiConfig, cancel); err != nil {
546+
apiExitCh, err := startAPI(ctx, installCfg.tlsCert, apiConfig)
547+
if err != nil {
547548
return fmt.Errorf("failed to start api: %w", err)
548549
}
549550

550551
if flags.headless {
552+
// Setup signal handler with the metrics reporter cleanup function
553+
signalHandler(ctx, cancel, func(ctx context.Context, sig os.Signal) {
554+
metricsReporter.ReportSignalAborted(ctx, sig)
555+
})
556+
551557
// TODO(PR2): Implement headless installation orchestration
552-
return fmt.Errorf("headless installation is not yet fully implemented - coming in a future release")
558+
err := fmt.Errorf("headless installation is not yet fully implemented - coming in a future release")
559+
560+
if err != nil {
561+
// Check if this is an interrupt error from the terminal
562+
if errors.Is(err, terminal.InterruptErr) {
563+
metricsReporter.ReportSignalAborted(ctx, syscall.SIGINT)
564+
} else {
565+
metricsReporter.ReportInstallationFailed(ctx, err)
566+
}
567+
return err
568+
}
569+
metricsReporter.ReportInstallationSucceeded(ctx)
570+
571+
return nil
553572
}
554573

555574
logrus.Infof("\nVisit the %s manager to continue: %s\n",
556575
appTitle,
557576
getManagerURL(flags.hostname, flags.managerPort))
558-
<-ctx.Done()
559577

560-
return nil
578+
// Wait for either user cancellation or API unexpected exit
579+
select {
580+
case <-ctx.Done():
581+
// Normal exit (user interrupted)
582+
return nil
583+
case err := <-apiExitCh:
584+
// API exited unexpectedly
585+
if err != nil {
586+
return err
587+
}
588+
return fmt.Errorf("api server exited unexpectedly")
589+
}
561590
}
562591

563592
func runInstall(ctx context.Context, flags installFlags, installCfg *installConfig, rc runtimeconfig.RuntimeConfig, metricsReporter *installReporter) (finalErr error) {

cmd/installer/cli/upgrade.go

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -496,16 +496,27 @@ func runManagerExperienceUpgrade(
496496
ctx, cancel := context.WithCancel(ctx)
497497
defer cancel()
498498

499-
if err := startAPI(ctx, upgradeConfig.tlsCert, apiConfig, cancel); err != nil {
499+
apiExitCh, err := startAPI(ctx, upgradeConfig.tlsCert, apiConfig)
500+
if err != nil {
500501
return fmt.Errorf("failed to start api: %w", err)
501502
}
502503

503504
logrus.Infof("\nVisit the %s manager to continue the upgrade: %s\n",
504505
appTitle,
505506
getManagerURL(upgradeConfig.tlsConfig.Hostname, upgradeConfig.managerPort))
506-
<-ctx.Done()
507507

508-
return nil
508+
// Wait for either user cancellation or API unexpected exit
509+
select {
510+
case <-ctx.Done():
511+
// Normal exit (user interrupted)
512+
return nil
513+
case err := <-apiExitCh:
514+
// API exited unexpectedly
515+
if err != nil {
516+
return err
517+
}
518+
return fmt.Errorf("api server exited unexpectedly")
519+
}
509520
}
510521

511522
// checkRequiresInfraUpgrade determines if an infrastructure upgrade is required by comparing

pkg-new/replicatedapi/client.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,16 @@ import (
1717

1818
var defaultHTTPClient = newRetryableHTTPClient()
1919

20+
// ClientFactory is a function type for creating replicatedapi clients
21+
type ClientFactory func(replicatedAppURL string, license *kotsv1beta1.License, releaseData *release.ReleaseData, opts ...ClientOption) (Client, error)
22+
23+
var clientFactory ClientFactory = defaultNewClient
24+
25+
// SetClientFactory sets a custom factory for creating clients (used for testing/mocking)
26+
func SetClientFactory(factory ClientFactory) {
27+
clientFactory = factory
28+
}
29+
2030
type Client interface {
2131
SyncLicense(ctx context.Context) (*kotsv1beta1.License, []byte, error)
2232
}
@@ -43,7 +53,13 @@ func WithHTTPClient(httpClient *retryablehttp.Client) ClientOption {
4353
}
4454
}
4555

56+
// NewClient creates a new replicatedapi client using the configured factory
4657
func NewClient(replicatedAppURL string, license *kotsv1beta1.License, releaseData *release.ReleaseData, opts ...ClientOption) (Client, error) {
58+
return clientFactory(replicatedAppURL, license, releaseData, opts...)
59+
}
60+
61+
// defaultNewClient is the default implementation of NewClient
62+
func defaultNewClient(replicatedAppURL string, license *kotsv1beta1.License, releaseData *release.ReleaseData, opts ...ClientOption) (Client, error) {
4763
c := &client{
4864
replicatedAppURL: replicatedAppURL,
4965
license: license,

pkg/dryrun/dryrun.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99

1010
"github.com/replicatedhq/embedded-cluster/pkg-new/config"
1111
"github.com/replicatedhq/embedded-cluster/pkg-new/k0s"
12+
"github.com/replicatedhq/embedded-cluster/pkg-new/replicatedapi"
1213
"github.com/replicatedhq/embedded-cluster/pkg/dryrun/types"
1314
"github.com/replicatedhq/embedded-cluster/pkg/helm"
1415
"github.com/replicatedhq/embedded-cluster/pkg/helpers"
@@ -18,6 +19,8 @@ import (
1819
"github.com/replicatedhq/embedded-cluster/pkg/kubeutils"
1920
"github.com/replicatedhq/embedded-cluster/pkg/metrics"
2021
"github.com/replicatedhq/embedded-cluster/pkg/netutils"
22+
"github.com/replicatedhq/embedded-cluster/pkg/release"
23+
kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1"
2124
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
2225
"github.com/sirupsen/logrus"
2326
"github.com/spf13/pflag"
@@ -42,6 +45,7 @@ type Client struct {
4245
K0sClient *K0s
4346
HelmClient helm.Client
4447
Kotsadm *Kotsadm
48+
ReplicatedAPIClient *ReplicatedAPIClient
4549
NetworkInterfaceProvider netutils.NetworkInterfaceProvider
4650
ChooseHostInterfaceImpl *ChooseInterfaceImpl
4751
}
@@ -78,6 +82,11 @@ func Init(outputFile string, client *Client) {
7882
return client.HelmClient, nil
7983
})
8084
}
85+
if client.ReplicatedAPIClient != nil {
86+
replicatedapi.SetClientFactory(func(replicatedAppURL string, license *kotsv1beta1.License, releaseData *release.ReleaseData, opts ...replicatedapi.ClientOption) (replicatedapi.Client, error) {
87+
return client.ReplicatedAPIClient, nil
88+
})
89+
}
8190
if client.NetworkInterfaceProvider != nil {
8291
config.NetworkInterfaceProvider = client.NetworkInterfaceProvider
8392
netutils.DefaultNetworkInterfaceProvider = client.NetworkInterfaceProvider

pkg/dryrun/replicatedapi.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package dryrun
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/replicatedhq/embedded-cluster/pkg-new/replicatedapi"
8+
kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1"
9+
"sigs.k8s.io/yaml"
10+
)
11+
12+
var _ replicatedapi.Client = (*ReplicatedAPIClient)(nil)
13+
14+
// ReplicatedAPIClient is a mockable implementation of the replicatedapi.Client interface.
15+
type ReplicatedAPIClient struct {
16+
License *kotsv1beta1.License
17+
LicenseBytes []byte
18+
}
19+
20+
// SyncLicense returns the mocked license data.
21+
func (c *ReplicatedAPIClient) SyncLicense(ctx context.Context) (*kotsv1beta1.License, []byte, error) {
22+
// If License is not set but LicenseBytes is, parse the license from bytes
23+
if c.License == nil && len(c.LicenseBytes) > 0 {
24+
var license kotsv1beta1.License
25+
if err := yaml.Unmarshal(c.LicenseBytes, &license); err != nil {
26+
return nil, nil, fmt.Errorf("failed to parse license from bytes: %w", err)
27+
}
28+
c.License = &license
29+
}
30+
31+
return c.License, c.LicenseBytes, nil
32+
}

0 commit comments

Comments
 (0)