Skip to content

Commit

Permalink
support airgap heartbeat (#106)
Browse files Browse the repository at this point in the history
  • Loading branch information
Craig O'Donnell authored Oct 19, 2023
1 parent f138052 commit bae9ccc
Show file tree
Hide file tree
Showing 16 changed files with 807 additions and 34 deletions.
5 changes: 5 additions & 0 deletions .github/actions/validate-endpoints/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ inputs:
description: 'If the chart was deployed via kubectl after running helm template'
required: false
default: 'false'
is-airgap:
description: 'If the chart was deployed in airgap mode'
required: false
default: 'false'
runs:
using: "composite"
steps:
Expand Down Expand Up @@ -110,6 +114,7 @@ runs:
fi
- name: Validate /app/updates endpoint
if: ${{ inputs.is-airgap == 'false' }}
shell: bash
run: |
updatesLength=$(curl -s --fail --show-error localhost:8888/api/v1/app/updates | jq '. | length' | tr -d '\n')
Expand Down
108 changes: 108 additions & 0 deletions .github/workflows/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,114 @@ jobs:
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 -
kubectl wait --for=delete deployment/test-chart --timeout=2m
kubectl wait --for=delete deployment/replicated --timeout=2m
# validate airgap
- name: Download support-bundle binary
run: |
RELEASE="$(
curl -sfL https://api.github.com/repos/replicatedhq/troubleshoot/releases/latest | \
grep '"tag_name":' | \
sed -E 's/.*"(v[^"]+)".*/\1/'
)"
curl -fsLO "https://github.com/replicatedhq/troubleshoot/releases/download/${RELEASE}/support-bundle_linux_amd64.tar.gz"
tar xzf support-bundle_linux_amd64.tar.gz
- name: Install via Helm as subchart in production mode
run: |
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
COUNTER=1
while ! kubectl get secret/replicated-instance-report; do
((COUNTER += 1))
if [ $COUNTER -gt 60 ]; then
echo "Did not create replicated-instance-report secret"
exit 1
fi
sleep 1
done
- name: Validate endpoints
uses: ./.github/actions/validate-endpoints
with:
license-id: ${{ env.LICENSE_ID }}
license-fields: ${{ env.LICENSE_FIELDS }}
integration-enabled: 'false'
is-airgap: 'true'

- name: Validate support bundle instance report
run: |
./support-bundle --load-cluster-specs --interactive=false
tar xzf support-bundle-*.tar.gz
if ! ls support-bundle-*/secrets/*/replicated-instance-report/report.json; then
echo "Did not find replicated-instance-report in support bundle"
exit 1
fi
rm -rf support-bundle-*
- name: Uninstall test-chart via Helm
run: |
helm uninstall test-chart --wait --timeout 2m
COUNTER=1
while kubectl get secret/replicated-instance-report; do
((COUNTER += 1))
if [ $COUNTER -gt 60 ]; then
echo "Did not delete replicated-instance-report secret"
exit 1
fi
sleep 1
done
- name: Install via kubectl as subchart in production mode
run: |
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 -
kubectl rollout status deployment test-chart --timeout=2m
kubectl rollout status deployment replicated --timeout=2m
COUNTER=1
while ! kubectl get secret/replicated-instance-report; do
((COUNTER += 1))
if [ $COUNTER -gt 60 ]; then
echo "Did not create replicated-instance-report secret"
exit 1
fi
sleep 1
done
- name: Validate endpoints
uses: ./.github/actions/validate-endpoints
with:
license-id: ${{ env.LICENSE_ID }}
license-fields: ${{ env.LICENSE_FIELDS }}
integration-enabled: 'false'
deployed-via-kubectl: 'true'
is-airgap: 'true'

- name: Validate support bundle instance report
run: |
./support-bundle --load-cluster-specs --interactive=false
tar xzf support-bundle-*.tar.gz
if ! ls support-bundle-*/secrets/*/replicated-instance-report/report.json; then
echo "Did not find replicated-instance-report in support bundle"
exit 1
fi
rm -rf support-bundle-*
- name: Uninstall test-chart via kubectl
run: |
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 -
kubectl wait --for=delete deployment/test-chart --timeout=2m
kubectl wait --for=delete deployment/replicated --timeout=2m
COUNTER=1
while kubectl get secret/replicated-instance-report; do
((COUNTER += 1))
if [ $COUNTER -gt 60 ]; then
echo "Did not delete replicated-instance-report secret"
exit 1
fi
sleep 1
done
- name: Remove Cluster
uses: replicatedhq/replicated-actions/remove-cluster@v1.1.1
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,5 @@ cmd/replicated/__debug_bin

# pact
pact_logs/
pact/log/
pacts/
7 changes: 7 additions & 0 deletions chart/templates/replicated-role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ rules:
- 'get'
- 'list'
- 'watch'
- apiGroups:
- ''
resources:
- 'secrets'
verbs:
- 'create'
- apiGroups:
- ''
resources:
Expand All @@ -23,4 +29,5 @@ rules:
- 'update'
resourceNames:
- {{ include "replicated.secretName" . }}
- replicated-instance-report
{{ end }}
5 changes: 5 additions & 0 deletions chart/templates/replicated-supportbundle.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ stringData:
headers:
User-Agent: "troubleshoot.sh/support-bundle"
timeout: 5s
- secret:
namespace: {{ include "replicated.namespace" . }}
name: replicated-instance-report
includeValue: true
key: report
analyzers:
- jsonCompare:
checkName: Replicated SDK App Status
Expand Down
39 changes: 39 additions & 0 deletions pkg/heartbeat/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import (
"encoding/json"
"fmt"
"net/http"
"time"

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

heartbeatInfo := GetHeartbeatInfo(sdkStore)

if util.IsAirgap() {
return SendAirgapHeartbeat(clientset, sdkStore.GetNamespace(), license.Spec.LicenseID, heartbeatInfo)
}

return SendOnlineHeartbeat(license, heartbeatInfo)
}

func SendAirgapHeartbeat(clientset kubernetes.Interface, namespace string, licenseID string, heartbeatInfo *types.HeartbeatInfo) error {
event := types.InstanceReportEvent{
ReportedAt: time.Now().UTC().UnixMilli(),
LicenseID: licenseID,
InstanceID: heartbeatInfo.InstanceID,
ClusterID: heartbeatInfo.ClusterID,
AppStatus: heartbeatInfo.AppStatus,
K8sVersion: heartbeatInfo.K8sVersion,
K8sDistribution: heartbeatInfo.K8sDistribution,
DownstreamChannelID: heartbeatInfo.ChannelID,
DownstreamChannelName: heartbeatInfo.ChannelName,
DownstreamChannelSequence: heartbeatInfo.ChannelSequence,
}

if heartbeatInfo.ResourceStates != nil {
marshalledRS, err := json.Marshal(heartbeatInfo.ResourceStates)
if err != nil {
return errors.Wrap(err, "failed to marshal resource states")
}
event.ResourceStates = string(marshalledRS)
}

if err := CreateInstanceReportEvent(clientset, namespace, event); err != nil {
return errors.Wrap(err, "failed to create airgap heartbeat")
}

return nil
}

func SendOnlineHeartbeat(license *v1beta1.License, heartbeatInfo *types.HeartbeatInfo) error {
// build the request body
reqPayload := map[string]interface{}{}
if err := InjectHeartbeatInfoPayload(reqPayload, heartbeatInfo); err != nil {
Expand Down
141 changes: 141 additions & 0 deletions pkg/heartbeat/app_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package heartbeat

import (
"net/http"
"net/http/httptest"
"testing"

"github.com/golang/mock/gomock"
"github.com/gorilla/mux"
"github.com/replicatedhq/kotskinds/apis/kots/v1beta1"
appstatetypes "github.com/replicatedhq/replicated-sdk/pkg/appstate/types"
"github.com/replicatedhq/replicated-sdk/pkg/k8sutil"
"github.com/replicatedhq/replicated-sdk/pkg/store"
mock_store "github.com/replicatedhq/replicated-sdk/pkg/store/mock"
"github.com/replicatedhq/replicated-sdk/pkg/util"
"github.com/stretchr/testify/require"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/kubernetes/fake"
)

func Test_SendAppHeartbeat(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockStore := mock_store.NewMockStore(ctrl)

respRecorder := httptest.NewRecorder()
mockRouter := mux.NewRouter()
mockServer := httptest.NewServer(mockRouter)
defer mockServer.Close()
mockRouter.Methods("POST").Path("/kots_metrics/license_instance/info").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
respRecorder.Write([]byte("received heartbeat"))
w.WriteHeader(http.StatusOK)
})

type args struct {
clientset kubernetes.Interface
sdkStore store.Store
}
tests := []struct {
name string
args args
env map[string]string
isAirgap bool
mockStoreExpectations func()
}{
{
name: "online heartbeat",
args: args{
clientset: fake.NewSimpleClientset(
k8sutil.CreateTestDeployment(util.GetReplicatedDeploymentName(), "test-namespace", "1", map[string]string{"app": "test-app"}),
k8sutil.CreateTestReplicaSet("test-replicaset", "test-namespace", "1"),
k8sutil.CreateTestPod("test-pod", "test-namespace", "test-replicaset", map[string]string{"app": "test-app"}),
),
sdkStore: mockStore,
},
env: map[string]string{
"DISABLE_OUTBOUND_CONNECTIONS": "false",
"REPLICATED_POD_NAME": "test-pod",
},
isAirgap: false,
mockStoreExpectations: func() {
mockStore.EXPECT().GetLicense().Return(&v1beta1.License{
Spec: v1beta1.LicenseSpec{
LicenseID: "test-license-id",
Endpoint: mockServer.URL,
},
})
mockStore.EXPECT().GetNamespace().Return("test-namespace")
mockStore.EXPECT().GetReplicatedID().Return("test-cluster-id")
mockStore.EXPECT().GetAppID().Return("test-app")
mockStore.EXPECT().GetChannelID().Return("test-app-nightly")
mockStore.EXPECT().GetChannelName().Return("Test Channel")
mockStore.EXPECT().GetChannelSequence().Return(int64(1))
mockStore.EXPECT().GetAppStatus().Times(2).Return(appstatetypes.AppStatus{
AppSlug: "test-app",
Sequence: 1,
State: appstatetypes.StateMissing,
ResourceStates: []appstatetypes.ResourceState{},
})
},
},
{
name: "airgap heartbeat",
args: args{
clientset: fake.NewSimpleClientset(
k8sutil.CreateTestDeployment(util.GetReplicatedDeploymentName(), "test-namespace", "1", map[string]string{"app": "test-app"}),
k8sutil.CreateTestReplicaSet("test-replicaset", "test-namespace", "1"),
k8sutil.CreateTestPod("test-pod", "test-namespace", "test-replicaset", map[string]string{"app": "test-app"}),
),
sdkStore: mockStore,
},
env: map[string]string{
"DISABLE_OUTBOUND_CONNECTIONS": "true",
"REPLICATED_POD_NAME": "test-pod",
},
isAirgap: true,
mockStoreExpectations: func() {
mockStore.EXPECT().GetLicense().Return(&v1beta1.License{
Spec: v1beta1.LicenseSpec{
LicenseID: "test-license-id",
Endpoint: mockServer.URL,
},
})
mockStore.EXPECT().GetNamespace().Times(2).Return("test-namespace")
mockStore.EXPECT().GetReplicatedID().Return("test-cluster-id")
mockStore.EXPECT().GetAppID().Return("test-app")
mockStore.EXPECT().GetChannelID().Return("test-app-nightly")
mockStore.EXPECT().GetChannelName().Return("Test Channel")
mockStore.EXPECT().GetChannelSequence().Return(int64(1))
mockStore.EXPECT().GetAppStatus().Times(2).Return(appstatetypes.AppStatus{
AppSlug: "test-app",
Sequence: 1,
State: appstatetypes.StateMissing,
ResourceStates: []appstatetypes.ResourceState{},
})
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := require.New(t)

for k, v := range tt.env {
t.Setenv(k, v)
}

respRecorder.Body.Reset()

tt.mockStoreExpectations()

err := SendAppHeartbeat(tt.args.clientset, tt.args.sdkStore)
req.NoError(err)

if !tt.isAirgap {
req.Equal("received heartbeat", respRecorder.Body.String())
} else {
req.Equal("", respRecorder.Body.String())
}
})
}
}
Loading

0 comments on commit bae9ccc

Please sign in to comment.