Skip to content

Commit bae9ccc

Browse files
authored
support airgap heartbeat (#106)
1 parent f138052 commit bae9ccc

File tree

16 files changed

+807
-34
lines changed

16 files changed

+807
-34
lines changed

.github/actions/validate-endpoints/action.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ inputs:
2020
description: 'If the chart was deployed via kubectl after running helm template'
2121
required: false
2222
default: 'false'
23+
is-airgap:
24+
description: 'If the chart was deployed in airgap mode'
25+
required: false
26+
default: 'false'
2327
runs:
2428
using: "composite"
2529
steps:
@@ -110,6 +114,7 @@ runs:
110114
fi
111115
112116
- name: Validate /app/updates endpoint
117+
if: ${{ inputs.is-airgap == 'false' }}
113118
shell: bash
114119
run: |
115120
updatesLength=$(curl -s --fail --show-error localhost:8888/api/v1/app/updates | jq '. | length' | tr -d '\n')

.github/workflows/main.yaml

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,114 @@ jobs:
464464
helm template test-chart oci://registry.replicated.com/$APP_SLUG/$CHANNEL_SLUG/test-chart --set replicated.integration.enabled=false -f test-values.yaml | kubectl delete -f -
465465
kubectl wait --for=delete deployment/test-chart --timeout=2m
466466
kubectl wait --for=delete deployment/replicated --timeout=2m
467+
468+
# validate airgap
469+
- name: Download support-bundle binary
470+
run: |
471+
RELEASE="$(
472+
curl -sfL https://api.github.com/repos/replicatedhq/troubleshoot/releases/latest | \
473+
grep '"tag_name":' | \
474+
sed -E 's/.*"(v[^"]+)".*/\1/'
475+
)"
476+
curl -fsLO "https://github.com/replicatedhq/troubleshoot/releases/download/${RELEASE}/support-bundle_linux_amd64.tar.gz"
477+
tar xzf support-bundle_linux_amd64.tar.gz
478+
479+
- name: Install via Helm as subchart in production mode
480+
run: |
481+
helm install test-chart oci://registry.replicated.com/$APP_SLUG/$CHANNEL_SLUG/test-chart --set replicated.integration.enabled=false --set replicated.isAirgap=true --wait --timeout 2m
482+
483+
COUNTER=1
484+
while ! kubectl get secret/replicated-instance-report; do
485+
((COUNTER += 1))
486+
if [ $COUNTER -gt 60 ]; then
487+
echo "Did not create replicated-instance-report secret"
488+
exit 1
489+
fi
490+
sleep 1
491+
done
492+
493+
- name: Validate endpoints
494+
uses: ./.github/actions/validate-endpoints
495+
with:
496+
license-id: ${{ env.LICENSE_ID }}
497+
license-fields: ${{ env.LICENSE_FIELDS }}
498+
integration-enabled: 'false'
499+
is-airgap: 'true'
500+
501+
- name: Validate support bundle instance report
502+
run: |
503+
./support-bundle --load-cluster-specs --interactive=false
504+
tar xzf support-bundle-*.tar.gz
505+
if ! ls support-bundle-*/secrets/*/replicated-instance-report/report.json; then
506+
echo "Did not find replicated-instance-report in support bundle"
507+
exit 1
508+
fi
509+
rm -rf support-bundle-*
510+
511+
- name: Uninstall test-chart via Helm
512+
run: |
513+
helm uninstall test-chart --wait --timeout 2m
514+
515+
COUNTER=1
516+
while kubectl get secret/replicated-instance-report; do
517+
((COUNTER += 1))
518+
if [ $COUNTER -gt 60 ]; then
519+
echo "Did not delete replicated-instance-report secret"
520+
exit 1
521+
fi
522+
sleep 1
523+
done
524+
525+
- name: Install via kubectl as subchart in production mode
526+
run: |
527+
helm template test-chart oci://registry.replicated.com/$APP_SLUG/$CHANNEL_SLUG/test-chart --set replicated.integration.enabled=false --set replicated.isAirgap=true | kubectl apply -f -
528+
kubectl rollout status deployment test-chart --timeout=2m
529+
kubectl rollout status deployment replicated --timeout=2m
530+
531+
COUNTER=1
532+
while ! kubectl get secret/replicated-instance-report; do
533+
((COUNTER += 1))
534+
if [ $COUNTER -gt 60 ]; then
535+
echo "Did not create replicated-instance-report secret"
536+
exit 1
537+
fi
538+
sleep 1
539+
done
540+
541+
- name: Validate endpoints
542+
uses: ./.github/actions/validate-endpoints
543+
with:
544+
license-id: ${{ env.LICENSE_ID }}
545+
license-fields: ${{ env.LICENSE_FIELDS }}
546+
integration-enabled: 'false'
547+
deployed-via-kubectl: 'true'
548+
is-airgap: 'true'
549+
550+
- name: Validate support bundle instance report
551+
run: |
552+
./support-bundle --load-cluster-specs --interactive=false
553+
tar xzf support-bundle-*.tar.gz
554+
if ! ls support-bundle-*/secrets/*/replicated-instance-report/report.json; then
555+
echo "Did not find replicated-instance-report in support bundle"
556+
exit 1
557+
fi
558+
rm -rf support-bundle-*
559+
560+
- name: Uninstall test-chart via kubectl
561+
run: |
562+
helm template test-chart oci://registry.replicated.com/$APP_SLUG/$CHANNEL_SLUG/test-chart --set replicated.integration.enabled=false --set replicated.isAirgap=true | kubectl delete -f -
563+
kubectl wait --for=delete deployment/test-chart --timeout=2m
564+
kubectl wait --for=delete deployment/replicated --timeout=2m
565+
566+
COUNTER=1
567+
while kubectl get secret/replicated-instance-report; do
568+
((COUNTER += 1))
569+
if [ $COUNTER -gt 60 ]; then
570+
echo "Did not delete replicated-instance-report secret"
571+
exit 1
572+
fi
573+
sleep 1
574+
done
467575
468576
- name: Remove Cluster
469577
uses: replicatedhq/replicated-actions/[email protected]

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,5 @@ cmd/replicated/__debug_bin
4141

4242
# pact
4343
pact_logs/
44+
pact/log/
4445
pacts/

chart/templates/replicated-role.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ rules:
1515
- 'get'
1616
- 'list'
1717
- 'watch'
18+
- apiGroups:
19+
- ''
20+
resources:
21+
- 'secrets'
22+
verbs:
23+
- 'create'
1824
- apiGroups:
1925
- ''
2026
resources:
@@ -23,4 +29,5 @@ rules:
2329
- 'update'
2430
resourceNames:
2531
- {{ include "replicated.secretName" . }}
32+
- replicated-instance-report
2633
{{ end }}

chart/templates/replicated-supportbundle.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ stringData:
3939
headers:
4040
User-Agent: "troubleshoot.sh/support-bundle"
4141
timeout: 5s
42+
- secret:
43+
namespace: {{ include "replicated.namespace" . }}
44+
name: replicated-instance-report
45+
includeValue: true
46+
key: report
4247
analyzers:
4348
- jsonCompare:
4449
checkName: Replicated SDK App Status

pkg/heartbeat/app.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import (
66
"encoding/json"
77
"fmt"
88
"net/http"
9+
"time"
910

1011
"github.com/pkg/errors"
12+
"github.com/replicatedhq/kotskinds/apis/kots/v1beta1"
1113
"github.com/replicatedhq/replicated-sdk/pkg/heartbeat/types"
1214
"github.com/replicatedhq/replicated-sdk/pkg/k8sutil"
1315
"github.com/replicatedhq/replicated-sdk/pkg/logger"
@@ -29,6 +31,43 @@ func SendAppHeartbeat(clientset kubernetes.Interface, sdkStore store.Store) erro
2931

3032
heartbeatInfo := GetHeartbeatInfo(sdkStore)
3133

34+
if util.IsAirgap() {
35+
return SendAirgapHeartbeat(clientset, sdkStore.GetNamespace(), license.Spec.LicenseID, heartbeatInfo)
36+
}
37+
38+
return SendOnlineHeartbeat(license, heartbeatInfo)
39+
}
40+
41+
func SendAirgapHeartbeat(clientset kubernetes.Interface, namespace string, licenseID string, heartbeatInfo *types.HeartbeatInfo) error {
42+
event := types.InstanceReportEvent{
43+
ReportedAt: time.Now().UTC().UnixMilli(),
44+
LicenseID: licenseID,
45+
InstanceID: heartbeatInfo.InstanceID,
46+
ClusterID: heartbeatInfo.ClusterID,
47+
AppStatus: heartbeatInfo.AppStatus,
48+
K8sVersion: heartbeatInfo.K8sVersion,
49+
K8sDistribution: heartbeatInfo.K8sDistribution,
50+
DownstreamChannelID: heartbeatInfo.ChannelID,
51+
DownstreamChannelName: heartbeatInfo.ChannelName,
52+
DownstreamChannelSequence: heartbeatInfo.ChannelSequence,
53+
}
54+
55+
if heartbeatInfo.ResourceStates != nil {
56+
marshalledRS, err := json.Marshal(heartbeatInfo.ResourceStates)
57+
if err != nil {
58+
return errors.Wrap(err, "failed to marshal resource states")
59+
}
60+
event.ResourceStates = string(marshalledRS)
61+
}
62+
63+
if err := CreateInstanceReportEvent(clientset, namespace, event); err != nil {
64+
return errors.Wrap(err, "failed to create airgap heartbeat")
65+
}
66+
67+
return nil
68+
}
69+
70+
func SendOnlineHeartbeat(license *v1beta1.License, heartbeatInfo *types.HeartbeatInfo) error {
3271
// build the request body
3372
reqPayload := map[string]interface{}{}
3473
if err := InjectHeartbeatInfoPayload(reqPayload, heartbeatInfo); err != nil {

pkg/heartbeat/app_test.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package heartbeat
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
8+
"github.com/golang/mock/gomock"
9+
"github.com/gorilla/mux"
10+
"github.com/replicatedhq/kotskinds/apis/kots/v1beta1"
11+
appstatetypes "github.com/replicatedhq/replicated-sdk/pkg/appstate/types"
12+
"github.com/replicatedhq/replicated-sdk/pkg/k8sutil"
13+
"github.com/replicatedhq/replicated-sdk/pkg/store"
14+
mock_store "github.com/replicatedhq/replicated-sdk/pkg/store/mock"
15+
"github.com/replicatedhq/replicated-sdk/pkg/util"
16+
"github.com/stretchr/testify/require"
17+
"k8s.io/client-go/kubernetes"
18+
"k8s.io/client-go/kubernetes/fake"
19+
)
20+
21+
func Test_SendAppHeartbeat(t *testing.T) {
22+
ctrl := gomock.NewController(t)
23+
defer ctrl.Finish()
24+
mockStore := mock_store.NewMockStore(ctrl)
25+
26+
respRecorder := httptest.NewRecorder()
27+
mockRouter := mux.NewRouter()
28+
mockServer := httptest.NewServer(mockRouter)
29+
defer mockServer.Close()
30+
mockRouter.Methods("POST").Path("/kots_metrics/license_instance/info").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
31+
respRecorder.Write([]byte("received heartbeat"))
32+
w.WriteHeader(http.StatusOK)
33+
})
34+
35+
type args struct {
36+
clientset kubernetes.Interface
37+
sdkStore store.Store
38+
}
39+
tests := []struct {
40+
name string
41+
args args
42+
env map[string]string
43+
isAirgap bool
44+
mockStoreExpectations func()
45+
}{
46+
{
47+
name: "online heartbeat",
48+
args: args{
49+
clientset: fake.NewSimpleClientset(
50+
k8sutil.CreateTestDeployment(util.GetReplicatedDeploymentName(), "test-namespace", "1", map[string]string{"app": "test-app"}),
51+
k8sutil.CreateTestReplicaSet("test-replicaset", "test-namespace", "1"),
52+
k8sutil.CreateTestPod("test-pod", "test-namespace", "test-replicaset", map[string]string{"app": "test-app"}),
53+
),
54+
sdkStore: mockStore,
55+
},
56+
env: map[string]string{
57+
"DISABLE_OUTBOUND_CONNECTIONS": "false",
58+
"REPLICATED_POD_NAME": "test-pod",
59+
},
60+
isAirgap: false,
61+
mockStoreExpectations: func() {
62+
mockStore.EXPECT().GetLicense().Return(&v1beta1.License{
63+
Spec: v1beta1.LicenseSpec{
64+
LicenseID: "test-license-id",
65+
Endpoint: mockServer.URL,
66+
},
67+
})
68+
mockStore.EXPECT().GetNamespace().Return("test-namespace")
69+
mockStore.EXPECT().GetReplicatedID().Return("test-cluster-id")
70+
mockStore.EXPECT().GetAppID().Return("test-app")
71+
mockStore.EXPECT().GetChannelID().Return("test-app-nightly")
72+
mockStore.EXPECT().GetChannelName().Return("Test Channel")
73+
mockStore.EXPECT().GetChannelSequence().Return(int64(1))
74+
mockStore.EXPECT().GetAppStatus().Times(2).Return(appstatetypes.AppStatus{
75+
AppSlug: "test-app",
76+
Sequence: 1,
77+
State: appstatetypes.StateMissing,
78+
ResourceStates: []appstatetypes.ResourceState{},
79+
})
80+
},
81+
},
82+
{
83+
name: "airgap heartbeat",
84+
args: args{
85+
clientset: fake.NewSimpleClientset(
86+
k8sutil.CreateTestDeployment(util.GetReplicatedDeploymentName(), "test-namespace", "1", map[string]string{"app": "test-app"}),
87+
k8sutil.CreateTestReplicaSet("test-replicaset", "test-namespace", "1"),
88+
k8sutil.CreateTestPod("test-pod", "test-namespace", "test-replicaset", map[string]string{"app": "test-app"}),
89+
),
90+
sdkStore: mockStore,
91+
},
92+
env: map[string]string{
93+
"DISABLE_OUTBOUND_CONNECTIONS": "true",
94+
"REPLICATED_POD_NAME": "test-pod",
95+
},
96+
isAirgap: true,
97+
mockStoreExpectations: func() {
98+
mockStore.EXPECT().GetLicense().Return(&v1beta1.License{
99+
Spec: v1beta1.LicenseSpec{
100+
LicenseID: "test-license-id",
101+
Endpoint: mockServer.URL,
102+
},
103+
})
104+
mockStore.EXPECT().GetNamespace().Times(2).Return("test-namespace")
105+
mockStore.EXPECT().GetReplicatedID().Return("test-cluster-id")
106+
mockStore.EXPECT().GetAppID().Return("test-app")
107+
mockStore.EXPECT().GetChannelID().Return("test-app-nightly")
108+
mockStore.EXPECT().GetChannelName().Return("Test Channel")
109+
mockStore.EXPECT().GetChannelSequence().Return(int64(1))
110+
mockStore.EXPECT().GetAppStatus().Times(2).Return(appstatetypes.AppStatus{
111+
AppSlug: "test-app",
112+
Sequence: 1,
113+
State: appstatetypes.StateMissing,
114+
ResourceStates: []appstatetypes.ResourceState{},
115+
})
116+
},
117+
},
118+
}
119+
for _, tt := range tests {
120+
t.Run(tt.name, func(t *testing.T) {
121+
req := require.New(t)
122+
123+
for k, v := range tt.env {
124+
t.Setenv(k, v)
125+
}
126+
127+
respRecorder.Body.Reset()
128+
129+
tt.mockStoreExpectations()
130+
131+
err := SendAppHeartbeat(tt.args.clientset, tt.args.sdkStore)
132+
req.NoError(err)
133+
134+
if !tt.isAirgap {
135+
req.Equal("received heartbeat", respRecorder.Body.String())
136+
} else {
137+
req.Equal("", respRecorder.Body.String())
138+
}
139+
})
140+
}
141+
}

0 commit comments

Comments
 (0)