Skip to content

Commit fd07258

Browse files
feat: Host Support Bundle Improvements (#2236)
* add support for templating based on airgap and proxy for support bundle * add curl commands for comparison with the http collectors * update unit test * update spec * update tests to validate content of actual support bundle template * capture stderr from curl collectors
1 parent b2401d1 commit fd07258

File tree

5 files changed

+339
-9
lines changed

5 files changed

+339
-9
lines changed

cmd/installer/cli/join.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,8 @@ func materializeFilesForJoin(ctx context.Context, rc runtimeconfig.RuntimeConfig
341341
if err := materializer.Materialize(); err != nil {
342342
return fmt.Errorf("materialize binaries: %w", err)
343343
}
344-
if err := support.MaterializeSupportBundleSpec(rc); err != nil {
344+
345+
if err := support.MaterializeSupportBundleSpec(rc, jcmd.InstallationSpec.AirGap); err != nil {
345346
return fmt.Errorf("materialize support bundle spec: %w", err)
346347
}
347348

cmd/installer/goods/support/host-support-bundle.tmpl.yaml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,50 @@ spec:
173173
collectorName: "ip-route-table"
174174
command: "ip"
175175
args: ["route"]
176+
- run:
177+
collectorName: "ip-neighbor-show"
178+
command: "ip"
179+
args: ["-s", "-d", "neigh", "show"]
180+
# HTTP connectivity checks (only run for online installations)
181+
- http:
182+
collectorName: http-replicated-app
183+
get:
184+
url: '{{ .ReplicatedAppURL }}/healthz'
185+
timeout: 5s
186+
proxy: '{{ .HTTPSProxy }}'
187+
exclude: '{{ or .IsAirgap (eq .ReplicatedAppURL "") }}'
188+
- http:
189+
collectorName: http-proxy-replicated-com
190+
get:
191+
url: '{{ .ProxyRegistryURL }}/v2/'
192+
timeout: 5s
193+
proxy: '{{ .HTTPSProxy }}'
194+
exclude: '{{ or .IsAirgap (eq .ProxyRegistryURL "") }}'
195+
# Curl-based connectivity checks (for comparison with HTTP collectors)
196+
- run:
197+
collectorName: curl-replicated-app
198+
command: sh
199+
args:
200+
- -c
201+
- |
202+
if [ -n "{{ .HTTPSProxy }}" ]; then
203+
curl --connect-timeout 5 --max-time 10 -v --proxy "{{ .HTTPSProxy }}" "{{ .ReplicatedAppURL }}/healthz" 2>&1
204+
else
205+
curl --connect-timeout 5 --max-time 10 -v "{{ .ReplicatedAppURL }}" 2>&1
206+
fi
207+
exclude: '{{ or .IsAirgap (eq .ReplicatedAppURL "") }}'
208+
- run:
209+
collectorName: curl-proxy-replicated-com
210+
command: sh
211+
args:
212+
- -c
213+
- |
214+
if [ -n "{{ .HTTPSProxy }}" ]; then
215+
curl --connect-timeout 5 --max-time 10 -v --proxy "{{ .HTTPSProxy }}" "{{ .ProxyRegistryURL }}/v2/" 2>&1
216+
else
217+
curl --connect-timeout 5 --max-time 10 -v "{{ .ProxyRegistryURL }}/v2/" 2>&1
218+
fi
219+
exclude: '{{ or .IsAirgap (eq .ProxyRegistryURL "") }}'
176220
- run:
177221
collectorName: "ip-address-stats"
178222
command: "ip"

pkg-new/hostutils/files.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ func (h *HostUtils) MaterializeFiles(rc runtimeconfig.RuntimeConfig, airgapBundl
1515
if err := materializer.Materialize(); err != nil {
1616
return fmt.Errorf("materialize binaries: %w", err)
1717
}
18-
if err := support.MaterializeSupportBundleSpec(rc); err != nil {
18+
19+
isAirgap := airgapBundle != ""
20+
if err := support.MaterializeSupportBundleSpec(rc, isAirgap); err != nil {
1921
return fmt.Errorf("materialize support bundle spec: %w", err)
2022
}
2123

pkg/support/materialize.go

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,47 @@ import (
66
"os"
77
"text/template"
88

9+
ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1"
10+
"github.com/replicatedhq/embedded-cluster/pkg/netutils"
11+
"github.com/replicatedhq/embedded-cluster/pkg/release"
912
"github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig"
1013
)
1114

1215
type TemplateData struct {
13-
DataDir string
14-
K0sDataDir string
15-
OpenEBSDataDir string
16+
DataDir string
17+
K0sDataDir string
18+
OpenEBSDataDir string
19+
IsAirgap bool
20+
ReplicatedAppURL string
21+
ProxyRegistryURL string
22+
HTTPProxy string
23+
HTTPSProxy string
24+
NoProxy string
1625
}
1726

18-
func MaterializeSupportBundleSpec(rc runtimeconfig.RuntimeConfig) error {
27+
func MaterializeSupportBundleSpec(rc runtimeconfig.RuntimeConfig, isAirgap bool) error {
28+
var embCfgSpec *ecv1beta1.ConfigSpec
29+
if embCfg := release.GetEmbeddedClusterConfig(); embCfg != nil {
30+
embCfgSpec = &embCfg.Spec
31+
}
32+
domains := runtimeconfig.GetDomains(embCfgSpec)
33+
1934
data := TemplateData{
20-
DataDir: rc.EmbeddedClusterHomeDirectory(),
21-
K0sDataDir: rc.EmbeddedClusterK0sSubDir(),
22-
OpenEBSDataDir: rc.EmbeddedClusterOpenEBSLocalSubDir(),
35+
DataDir: rc.EmbeddedClusterHomeDirectory(),
36+
K0sDataDir: rc.EmbeddedClusterK0sSubDir(),
37+
OpenEBSDataDir: rc.EmbeddedClusterOpenEBSLocalSubDir(),
38+
IsAirgap: isAirgap,
39+
ReplicatedAppURL: netutils.MaybeAddHTTPS(domains.ReplicatedAppDomain),
40+
ProxyRegistryURL: netutils.MaybeAddHTTPS(domains.ProxyRegistryDomain),
2341
}
42+
43+
// Add proxy configuration if available
44+
if proxy := rc.ProxySpec(); proxy != nil {
45+
data.HTTPProxy = proxy.HTTPProxy
46+
data.HTTPSProxy = proxy.HTTPSProxy
47+
data.NoProxy = proxy.NoProxy
48+
}
49+
2450
path := rc.PathToEmbeddedClusterSupportFile("host-support-bundle.tmpl.yaml")
2551
tmpl, err := os.ReadFile(path)
2652
if err != nil {

pkg/support/materialize_test.go

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
package support
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
9+
ecv1beta1 "github.com/replicatedhq/embedded-cluster/kinds/apis/v1beta1"
10+
"github.com/replicatedhq/embedded-cluster/pkg/runtimeconfig"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
func TestMaterializeSupportBundleSpec(t *testing.T) {
16+
tests := []struct {
17+
name string
18+
isAirgap bool
19+
proxySpec *ecv1beta1.ProxySpec
20+
expectedInFile []string
21+
notInFile []string
22+
validateFunc func(t *testing.T, content string)
23+
}{
24+
{
25+
name: "airgap installation - HTTP collectors excluded",
26+
isAirgap: true,
27+
proxySpec: &ecv1beta1.ProxySpec{
28+
HTTPSProxy: "https://proxy:8080",
29+
HTTPProxy: "http://proxy:8080",
30+
NoProxy: "localhost,127.0.0.1",
31+
},
32+
expectedInFile: []string{
33+
// Core collectors should always be present
34+
"k8s-api-healthz-6443",
35+
"free",
36+
"embedded-cluster-path-usage",
37+
// HTTP collectors are present in template (but will be excluded)
38+
"http-replicated-app",
39+
"curl-replicated-app",
40+
},
41+
notInFile: []string{
42+
// Template variables should be substituted
43+
"{{ .ReplicatedAppURL }}",
44+
"{{ .ProxyRegistryURL }}",
45+
"{{ .HTTPSProxy }}",
46+
},
47+
validateFunc: func(t *testing.T, content string) {
48+
// Validate that HTTP collectors have exclude: 'true' for airgap
49+
assert.Contains(t, content, "collectorName: http-replicated-app")
50+
51+
// Check that the http-replicated-app collector block has exclude: 'true'
52+
httpCollectorStart := strings.Index(content, "collectorName: http-replicated-app")
53+
require.Greater(t, httpCollectorStart, -1, "http-replicated-app collector should be present")
54+
55+
// Find the next collector to limit our search scope
56+
nextCollectorStart := strings.Index(content[httpCollectorStart+1:], "collectorName:")
57+
var httpCollectorBlock string
58+
if nextCollectorStart > -1 {
59+
httpCollectorBlock = content[httpCollectorStart : httpCollectorStart+1+nextCollectorStart]
60+
} else {
61+
httpCollectorBlock = content[httpCollectorStart:]
62+
}
63+
64+
assert.Contains(t, httpCollectorBlock, "exclude: 'true'",
65+
"http-replicated-app collector should be excluded in airgap mode")
66+
67+
// Also validate curl-replicated-app is excluded
68+
curlCollectorStart := strings.Index(content, "collectorName: curl-replicated-app")
69+
require.Greater(t, curlCollectorStart, -1, "curl-replicated-app collector should be present")
70+
71+
nextCurlCollectorStart := strings.Index(content[curlCollectorStart+1:], "collectorName:")
72+
var curlCollectorBlock string
73+
if nextCurlCollectorStart > -1 {
74+
curlCollectorBlock = content[curlCollectorStart : curlCollectorStart+1+nextCurlCollectorStart]
75+
} else {
76+
curlCollectorBlock = content[curlCollectorStart:]
77+
}
78+
79+
assert.Contains(t, curlCollectorBlock, "exclude: 'true'",
80+
"curl-replicated-app collector should be excluded in airgap mode")
81+
},
82+
},
83+
{
84+
name: "online installation with proxy - HTTP collectors included",
85+
isAirgap: false,
86+
proxySpec: &ecv1beta1.ProxySpec{
87+
HTTPSProxy: "https://proxy:8080",
88+
HTTPProxy: "http://proxy:8080",
89+
NoProxy: "localhost,127.0.0.1",
90+
},
91+
expectedInFile: []string{
92+
// Core collectors
93+
"k8s-api-healthz-6443",
94+
"free",
95+
"embedded-cluster-path-usage",
96+
// HTTP collectors are included for online
97+
"http-replicated-app",
98+
"curl-replicated-app",
99+
// URLs and proxy settings
100+
"https://replicated.app/healthz",
101+
"https://proxy.replicated.com/v2/",
102+
"proxy: 'https://proxy:8080'",
103+
},
104+
notInFile: []string{
105+
// Template variables should be substituted
106+
"{{ .ReplicatedAppURL }}",
107+
"{{ .HTTPSProxy }}",
108+
},
109+
validateFunc: func(t *testing.T, content string) {
110+
// Validate that HTTP collectors have exclude: 'false' for online
111+
assert.Contains(t, content, "collectorName: http-replicated-app")
112+
113+
// Check that the http-replicated-app collector block has exclude: 'false'
114+
httpCollectorStart := strings.Index(content, "collectorName: http-replicated-app")
115+
require.Greater(t, httpCollectorStart, -1, "http-replicated-app collector should be present")
116+
117+
// Find the next collector to limit our search scope
118+
nextCollectorStart := strings.Index(content[httpCollectorStart+1:], "collectorName:")
119+
var httpCollectorBlock string
120+
if nextCollectorStart > -1 {
121+
httpCollectorBlock = content[httpCollectorStart : httpCollectorStart+1+nextCollectorStart]
122+
} else {
123+
httpCollectorBlock = content[httpCollectorStart:]
124+
}
125+
126+
assert.Contains(t, httpCollectorBlock, "exclude: 'false'",
127+
"http-replicated-app collector should not be excluded in online mode")
128+
},
129+
},
130+
{
131+
name: "online installation without proxy - HTTP collectors included, no proxy config",
132+
isAirgap: false,
133+
proxySpec: nil,
134+
expectedInFile: []string{
135+
// Core collectors
136+
"k8s-api-healthz-6443",
137+
"embedded-cluster-path-usage",
138+
// HTTP collectors included
139+
"http-replicated-app",
140+
"curl-replicated-app",
141+
// URLs populated
142+
"https://replicated.app/healthz",
143+
"https://proxy.replicated.com/v2/",
144+
},
145+
notInFile: []string{
146+
// No proxy settings when proxy not configured
147+
"proxy: 'https://proxy:8080'",
148+
"proxy: 'http://proxy:8080'",
149+
// Template variables should be substituted
150+
"{{ .HTTPSProxy }}",
151+
"{{ .HTTPProxy }}",
152+
},
153+
validateFunc: func(t *testing.T, content string) {
154+
// Validate that HTTP collectors have exclude: 'false' for online
155+
httpCollectorStart := strings.Index(content, "collectorName: http-replicated-app")
156+
require.Greater(t, httpCollectorStart, -1, "http-replicated-app collector should be present")
157+
158+
nextCollectorStart := strings.Index(content[httpCollectorStart+1:], "collectorName:")
159+
var httpCollectorBlock string
160+
if nextCollectorStart > -1 {
161+
httpCollectorBlock = content[httpCollectorStart : httpCollectorStart+1+nextCollectorStart]
162+
} else {
163+
httpCollectorBlock = content[httpCollectorStart:]
164+
}
165+
166+
assert.Contains(t, httpCollectorBlock, "exclude: 'false'",
167+
"http-replicated-app collector should not be excluded in online mode")
168+
169+
// Verify proxy is empty/not set in the collector block
170+
assert.Contains(t, httpCollectorBlock, "proxy: ''",
171+
"proxy should be empty when no proxy is configured")
172+
},
173+
},
174+
}
175+
176+
for _, tt := range tests {
177+
t.Run(tt.name, func(t *testing.T) {
178+
// Create a temporary directory for the test
179+
tempDir := t.TempDir()
180+
181+
// Create the support subdirectory
182+
supportDir := filepath.Join(tempDir, "support")
183+
err := os.MkdirAll(supportDir, 0755)
184+
require.NoError(t, err)
185+
186+
// Copy the actual customer template to the test directory
187+
actualTemplatePath := filepath.Join("../../cmd/installer/goods/support/host-support-bundle.tmpl.yaml")
188+
templateContent, err := os.ReadFile(actualTemplatePath)
189+
require.NoError(t, err, "Should be able to read the actual customer template")
190+
191+
// Write the actual template to the test directory
192+
templatePath := filepath.Join(supportDir, "host-support-bundle.tmpl.yaml")
193+
err = os.WriteFile(templatePath, templateContent, 0644)
194+
require.NoError(t, err)
195+
196+
// Create mock RuntimeConfig
197+
mockRC := &runtimeconfig.MockRuntimeConfig{}
198+
mockRC.On("EmbeddedClusterHomeDirectory").Return(tempDir)
199+
mockRC.On("EmbeddedClusterK0sSubDir").Return(filepath.Join(tempDir, "k0s"))
200+
mockRC.On("EmbeddedClusterOpenEBSLocalSubDir").Return(filepath.Join(tempDir, "openebs"))
201+
mockRC.On("PathToEmbeddedClusterSupportFile", "host-support-bundle.tmpl.yaml").Return(templatePath)
202+
mockRC.On("PathToEmbeddedClusterSupportFile", "host-support-bundle.yaml").Return(
203+
filepath.Join(supportDir, "host-support-bundle.yaml"))
204+
mockRC.On("ProxySpec").Return(tt.proxySpec)
205+
206+
// Call the function under test
207+
err = MaterializeSupportBundleSpec(mockRC, tt.isAirgap)
208+
require.NoError(t, err)
209+
210+
// Verify the file was created
211+
outputFile := filepath.Join(supportDir, "host-support-bundle.yaml")
212+
_, err = os.Stat(outputFile)
213+
require.NoError(t, err, "Support bundle spec file should be created")
214+
215+
// Read the generated file content
216+
content, err := os.ReadFile(outputFile)
217+
require.NoError(t, err)
218+
contentStr := string(content)
219+
220+
// Verify expected content is present
221+
for _, expected := range tt.expectedInFile {
222+
assert.Contains(t, contentStr, expected,
223+
"Expected %q to be in the generated support bundle spec", expected)
224+
}
225+
226+
// Verify unwanted content is not present
227+
for _, notExpected := range tt.notInFile {
228+
assert.NotContains(t, contentStr, notExpected,
229+
"Expected %q to NOT be in the generated support bundle spec", notExpected)
230+
}
231+
232+
// Verify that key template variables were properly substituted
233+
assert.Contains(t, contentStr, tempDir, "Data directory should be substituted")
234+
assert.Contains(t, contentStr, filepath.Join(tempDir, "k0s"), "K0s data directory should be substituted")
235+
assert.Contains(t, contentStr, filepath.Join(tempDir, "openebs"), "OpenEBS data directory should be substituted")
236+
237+
// Verify the YAML structure is valid
238+
assert.Contains(t, contentStr, "apiVersion: troubleshoot.sh/v1beta2")
239+
assert.Contains(t, contentStr, "kind: SupportBundle")
240+
assert.Contains(t, contentStr, "hostCollectors:")
241+
assert.Contains(t, contentStr, "hostAnalyzers:")
242+
243+
// Verify key collectors that should always be present
244+
assert.Contains(t, contentStr, "ipv4Interfaces", "Basic network collector should be present")
245+
assert.Contains(t, contentStr, "memory", "Memory collector should be present")
246+
assert.Contains(t, contentStr, "filesystem-write-latency-etcd", "Performance collector should be present")
247+
248+
// Run the specific validation function for this test case
249+
if tt.validateFunc != nil {
250+
tt.validateFunc(t, contentStr)
251+
}
252+
253+
// Assert all mock expectations were met
254+
mockRC.AssertExpectations(t)
255+
})
256+
}
257+
}

0 commit comments

Comments
 (0)