Skip to content

Commit cc5e8a4

Browse files
committed
Adds tests
1 parent 92d9f1d commit cc5e8a4

5 files changed

Lines changed: 288 additions & 18 deletions

File tree

internal/cleanup/cleanup.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import (
66
"path/filepath"
77
"strconv"
88

9-
"github.com/Azure/azure-extension-platform/pkg/utils"
9+
"github.com/!azure/azure-extension-platform/pkg/utils"
1010
"github.com/Azure/run-command-handler-linux/internal/constants"
1111
"github.com/Azure/run-command-handler-linux/internal/types"
1212
"github.com/Azure/run-command-handler-linux/pkg/linuxutils"

internal/immediateruncommand/immediateruncommand.go

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package immediateruncommand
22

33
import (
4+
"errors"
45
"fmt"
56
"math"
67
"time"
78

9+
"github.com/Azure/azure-extension-platform/vmextension"
810
"github.com/Azure/run-command-handler-linux/internal/constants"
911
"github.com/Azure/run-command-handler-linux/internal/goalstate"
1012
"github.com/Azure/run-command-handler-linux/internal/handlersettings"
@@ -22,6 +24,24 @@ const (
2224
maxConcurrentTasks int32 = 5
2325
)
2426

27+
// ---- test seams (override in *_test.go) ----
28+
var (
29+
getImmediateGoalStatesFn = goalstate.GetImmediateRunCommandGoalStates
30+
handleImmediateGoalStateFn = goalstate.HandleImmediateGoalState
31+
reportFinalStatusFn = goalstate.ReportFinalStatusForImmediateGoalState
32+
33+
// signature validation seam (lets tests bypass crypto fields on ImmediateExtensionGoalState)
34+
validateSignatureFn = func(el hostgacommunicator.ImmediateExtensionGoalState) (bool, error) {
35+
return el.ValidateSignature()
36+
}
37+
38+
// goroutine seam (lets tests run synchronously)
39+
spawnFn = func(f func()) { go f() }
40+
41+
// time seam
42+
nowFn = func() time.Time { return time.Now().UTC() }
43+
)
44+
2545
var executingTasks counterutil.AtomicCount
2646

2747
// goalStateEventObserver is an observer that listens for status changes in goal states.
@@ -74,7 +94,7 @@ func processImmediateRunCommandGoalStates(ctx *log.Context, communicator hostgac
7494
return lastProcessedETag, nil
7595
}
7696

77-
goalStates, newEtag, err := goalstate.GetImmediateRunCommandGoalStates(ctx, &communicator, lastProcessedETag)
97+
goalStates, newEtag, err := getImmediateGoalStatesFn(ctx, &communicator, lastProcessedETag)
7898
if err != nil {
7999
return newEtag, handlersettings.InternalWrapErrorWithClarification(err, "could not retrieve goal states for immediate run command")
80100
}
@@ -102,7 +122,9 @@ func processImmediateRunCommandGoalStates(ctx *log.Context, communicator hostgac
102122
ctx.Log("message", fmt.Sprintf("trying to launch %v goal states concurrently", len(newGoalStates)))
103123

104124
for idx := range newGoalStates {
105-
go func(state settings.SettingsCommon) {
125+
st := newGoalStates[idx]
126+
spawnFn(func() {
127+
state := st
106128
ctx.Log("message", "launching new goal state. Incrementing executing tasks counter")
107129
executingTasks.Increment()
108130

@@ -114,8 +136,8 @@ func processImmediateRunCommandGoalStates(ctx *log.Context, communicator hostgac
114136
notifier := &observer.Notifier{}
115137
notifier.Register(&goalStateEventObserver)
116138
notifier.Notify(status)
117-
startTime := time.Now().UTC().Format(time.RFC3339)
118-
exitCode, err := goalstate.HandleImmediateGoalState(ctx, state, notifier)
139+
startTime := nowFn().Format(time.RFC3339)
140+
exitCode, err := handleImmediateGoalStateFn(ctx, state, notifier)
119141

120142
ctx.Log("message", "goal state has exited. Decrementing executing tasks counter")
121143
executingTasks.Decrement()
@@ -124,19 +146,27 @@ func processImmediateRunCommandGoalStates(ctx *log.Context, communicator hostgac
124146
// For successful goal states, the status is reported by the usual workflow
125147
if err != nil {
126148
ctx.Log("error", "failed to execute goal state", "message", err)
149+
150+
var ewc *vmextension.ErrorWithClarification
151+
errorCode := 0
152+
if errors.As(err, &ewc) && ewc != nil {
153+
errorCode = ewc.ErrorCode
154+
}
155+
127156
instView := types.RunCommandInstanceView{
128-
ExecutionState: types.Failed,
129-
ExecutionMessage: "Execution failed",
130-
ExitCode: exitCode,
131-
Output: "",
132-
Error: err.Error(),
133-
StartTime: startTime,
134-
EndTime: time.Now().UTC().Format(time.RFC3339),
157+
ExecutionState: types.Failed,
158+
ExecutionMessage: "Execution failed",
159+
ExitCode: exitCode,
160+
Output: "",
161+
Error: err.Error(),
162+
StartTime: startTime,
163+
EndTime: nowFn().Format(time.RFC3339),
164+
ErrorClarificationValue: errorCode,
135165
}
136-
goalstate.ReportFinalStatusForImmediateGoalState(ctx, notifier, statusKey, types.StatusError, &instView)
137166

167+
reportFinalStatusFn(ctx, notifier, statusKey, types.StatusError, &instView)
138168
}
139-
}(newGoalStates[idx])
169+
})
140170
}
141171

142172
ctx.Log("message", "finished launching goal states")
@@ -161,7 +191,7 @@ func processImmediateRunCommandGoalStates(ctx *log.Context, communicator hostgac
161191
StartTime: time.Now().UTC().Format(time.RFC3339),
162192
EndTime: time.Now().UTC().Format(time.RFC3339),
163193
}
164-
goalstate.ReportFinalStatusForImmediateGoalState(ctx, notifier, statusKey, types.StatusSkipped, &instView)
194+
reportFinalStatusFn(ctx, notifier, statusKey, types.StatusSkipped, &instView)
165195
}
166196
} else {
167197
ctx.Log("message", "no goal states were skipped")
@@ -175,7 +205,7 @@ func getGoalStatesToProcess(goalStates []hostgacommunicator.ImmediateExtensionGo
175205
var newGoalStates []settings.SettingsCommon
176206
var skippedGoalStates []settings.SettingsCommon
177207
for _, el := range goalStates {
178-
validSignature, err := el.ValidateSignature()
208+
validSignature, err := validateSignatureFn(el)
179209
if err != nil {
180210
return nil, nil, err
181211
}
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
package immediateruncommand
2+
3+
import (
4+
"errors"
5+
"os"
6+
"testing"
7+
"time"
8+
9+
"github.com/Azure/run-command-handler-linux/internal/constants"
10+
"github.com/Azure/run-command-handler-linux/internal/hostgacommunicator"
11+
"github.com/Azure/run-command-handler-linux/internal/observer"
12+
"github.com/Azure/run-command-handler-linux/internal/settings"
13+
"github.com/Azure/run-command-handler-linux/internal/status"
14+
"github.com/Azure/run-command-handler-linux/internal/types"
15+
"github.com/go-kit/kit/log"
16+
"github.com/stretchr/testify/require"
17+
)
18+
19+
// ---- helpers ----
20+
21+
func ptrString(s string) *string { return &s }
22+
func ptrInt(i int) *int { return &i }
23+
func ptrInt32(i int32) *int32 { return &i }
24+
25+
func mkSetting(ext string, seq int, state string) settings.SettingsCommon {
26+
extCopy := ext
27+
seqCopy := seq
28+
stateCopy := state
29+
return settings.SettingsCommon{
30+
ExtensionName: &extCopy,
31+
SeqNo: &seqCopy,
32+
ExtensionState: &stateCopy,
33+
}
34+
}
35+
36+
// ---- tests for getGoalStatesToProcess ----
37+
38+
func TestGetGoalStatesToProcess_ValidateSignatureError(t *testing.T) {
39+
orig := validateSignatureFn
40+
defer func() { validateSignatureFn = orig }()
41+
validateSignatureFn = func(_ hostgacommunicator.ImmediateExtensionGoalState) (bool, error) {
42+
return false, errors.New("boom")
43+
}
44+
45+
gs := []hostgacommunicator.ImmediateExtensionGoalState{
46+
{Settings: []settings.SettingsCommon{mkSetting("RunCommand", 1, "state")}},
47+
}
48+
49+
_, _, err := getGoalStatesToProcess(gs, 10)
50+
require.NotNil(t, err, "expected error, got nil")
51+
}
52+
53+
func TestGetGoalStatesToProcess_InvalidSignatureSkipsAll(t *testing.T) {
54+
orig := validateSignatureFn
55+
defer func() { validateSignatureFn = orig }()
56+
validateSignatureFn = func(_ hostgacommunicator.ImmediateExtensionGoalState) (bool, error) {
57+
return false, nil
58+
}
59+
60+
gs := []hostgacommunicator.ImmediateExtensionGoalState{
61+
{Settings: []settings.SettingsCommon{
62+
mkSetting("RunCommand", 1, "A"),
63+
mkSetting("RunCommand", 2, "B"),
64+
}},
65+
}
66+
67+
newOnes, skipped, err := getGoalStatesToProcess(gs, 10)
68+
require.Nil(t, err, "unexpected err: %v", err)
69+
require.True(t, len(newOnes) == 0, "expected none, got new=%d", len(newOnes))
70+
require.True(t, len(skipped) == 0, "expected none, got skipped=%d", len(skipped))
71+
}
72+
73+
func TestGetGoalStatesToProcess_RespectsMaxTasksToFetch(t *testing.T) {
74+
orig := validateSignatureFn
75+
defer func() { validateSignatureFn = orig }()
76+
validateSignatureFn = func(_ hostgacommunicator.ImmediateExtensionGoalState) (bool, error) {
77+
return true, nil
78+
}
79+
80+
gs := []hostgacommunicator.ImmediateExtensionGoalState{
81+
{Settings: []settings.SettingsCommon{
82+
mkSetting("RunCommand", 1, "A"),
83+
mkSetting("RunCommand", 2, "B"),
84+
mkSetting("RunCommand", 3, "C"),
85+
}},
86+
}
87+
88+
newOnes, skipped, err := getGoalStatesToProcess(gs, 2)
89+
require.Nil(t, err, "unexpected err: %v", err)
90+
require.Equal(t, 2, len(newOnes), "expected 2 new, got %d", len(newOnes))
91+
require.Equal(t, 1, len(skipped), "expected 1 skipped, got %d", len(skipped))
92+
}
93+
94+
// ---- tests for processImmediateRunCommandGoalStates ----
95+
96+
func TestProcessImmediateRunCommandGoalStates_WhenAtCapacity_DoesNotFetch(t *testing.T) {
97+
// Arrange
98+
executingTasks = 0
99+
for i := int32(0); i < maxConcurrentTasks; i++ {
100+
executingTasks.Increment()
101+
}
102+
defer func() {
103+
// reset counter
104+
for executingTasks.Get() > 0 {
105+
executingTasks.Decrement()
106+
}
107+
}()
108+
109+
origGet := getImmediateGoalStatesFn
110+
defer func() { getImmediateGoalStatesFn = origGet }()
111+
getImmediateGoalStatesFn = func(_ *log.Context, _ hostgacommunicator.IHostGACommunicator, _ string) ([]hostgacommunicator.ImmediateExtensionGoalState, string, error) {
112+
t.Fatalf("should not be called when at capacity")
113+
return nil, "", nil
114+
}
115+
116+
ctx := log.NewContext(log.NewSyncLogger(log.NewLogfmtLogger(os.Stdout))).With("time", log.DefaultTimestamp)
117+
var comm hostgacommunicator.HostGACommunicator // zero value ok for this test
118+
119+
etag, err := processImmediateRunCommandGoalStates(ctx, comm, "etag-old")
120+
require.Nil(t, err, "unexpected err: %v", err)
121+
require.Equal(t, "etag-old", etag, "expected etag unchanged, got %q", etag)
122+
}
123+
124+
func TestProcessImmediateRunCommandGoalStates_WhenEtagUnchanged_NoWork(t *testing.T) {
125+
// Arrange
126+
for executingTasks.Get() > 0 {
127+
executingTasks.Decrement()
128+
}
129+
130+
origGet := getImmediateGoalStatesFn
131+
defer func() { getImmediateGoalStatesFn = origGet }()
132+
getImmediateGoalStatesFn = func(_ *log.Context, _ hostgacommunicator.IHostGACommunicator, last string) ([]hostgacommunicator.ImmediateExtensionGoalState, string, error) {
133+
return nil, last, nil // unchanged
134+
}
135+
136+
ctx := log.NewContext(log.NewSyncLogger(log.NewLogfmtLogger(os.Stdout))).With("time", log.DefaultTimestamp)
137+
var comm hostgacommunicator.HostGACommunicator
138+
139+
etag, err := processImmediateRunCommandGoalStates(ctx, comm, "same")
140+
require.Nil(t, err, "unexpected err: %v", err)
141+
require.Equal(t, "same", etag, "expected same etag, got %q", etag)
142+
}
143+
144+
func TestProcessImmediateRunCommandGoalStates_LaunchesAndReportsSkipped(t *testing.T) {
145+
// Make deterministic: run "goroutines" inline.
146+
origSpawn := spawnFn
147+
defer func() { spawnFn = origSpawn }()
148+
spawnFn = func(f func()) { f() }
149+
150+
// Make deterministic time.
151+
origNow := nowFn
152+
defer func() { nowFn = origNow }()
153+
fixed := time.Date(2025, 12, 23, 10, 0, 0, 0, time.UTC)
154+
nowFn = func() time.Time { return fixed }
155+
156+
// Signature validation: true
157+
origValidate := validateSignatureFn
158+
defer func() { validateSignatureFn = origValidate }()
159+
validateSignatureFn = func(_ hostgacommunicator.ImmediateExtensionGoalState) (bool, error) { return true, nil }
160+
161+
// Return 3 goal states; max tasks should be 5 in empty case,
162+
// but we’ll artificially fill executingTasks to force maxTasksToFetch=1.
163+
for executingTasks.Get() > 0 {
164+
executingTasks.Decrement()
165+
}
166+
// executing=4 => maxTasksToFetch=1
167+
for i := 0; i < 4; i++ {
168+
executingTasks.Increment()
169+
}
170+
defer func() {
171+
for executingTasks.Get() > 0 {
172+
executingTasks.Decrement()
173+
}
174+
}()
175+
176+
gs := []hostgacommunicator.ImmediateExtensionGoalState{
177+
{Settings: []settings.SettingsCommon{
178+
mkSetting("RunCommand", 1, "A"),
179+
mkSetting("RunCommand", 2, "B"),
180+
mkSetting("RunCommand", 3, "C"),
181+
}},
182+
}
183+
184+
origGet := getImmediateGoalStatesFn
185+
defer func() { getImmediateGoalStatesFn = origGet }()
186+
getImmediateGoalStatesFn = func(_ *log.Context, _ hostgacommunicator.IHostGACommunicator, _ string) ([]hostgacommunicator.ImmediateExtensionGoalState, string, error) {
187+
return gs, "etag-new", nil
188+
}
189+
190+
// HandleImmediateGoalState called exactly once (maxTasksToFetch=1),
191+
// and we’ll return success (no final report for success).
192+
handleCalls := 0
193+
origHandle := handleImmediateGoalStateFn
194+
defer func() { handleImmediateGoalStateFn = origHandle }()
195+
handleImmediateGoalStateFn = func(_ *log.Context, _ settings.SettingsCommon, _ *observer.Notifier) (int, error) {
196+
handleCalls++
197+
return 0, nil
198+
}
199+
200+
// ReportFinalStatus called for skipped items (2 of them).
201+
reportCalls := 0
202+
origReport := reportFinalStatusFn
203+
defer func() { reportFinalStatusFn = origReport }()
204+
reportFinalStatusFn = func(_ *log.Context, _ *observer.Notifier, _ types.GoalStateKey, statusType types.StatusType, instView *types.RunCommandInstanceView) error {
205+
require.Equal(t, types.StatusSkipped, statusType, "expected StatusSkipped report, got %v", statusType)
206+
require.Equal(t, constants.ImmediateRC_CommandSkipped, instView.ExitCode, "expected skipped exit code, got %d", instView.ExitCode)
207+
reportCalls++
208+
return nil
209+
}
210+
211+
ctx := log.NewContext(log.NewSyncLogger(log.NewLogfmtLogger(os.Stdout))).With("time", log.DefaultTimestamp)
212+
goalStateEventObserver.Initialize(ctx)
213+
goalStateEventObserver.ReportImmediateStatusFn = func(s status.ImmediateTopLevelStatus) error {
214+
return nil
215+
}
216+
217+
tmpDir := t.TempDir()
218+
t.Setenv(constants.ExtensionPathEnvName, tmpDir)
219+
220+
var comm hostgacommunicator.HostGACommunicator
221+
222+
newEtag, err := processImmediateRunCommandGoalStates(ctx, comm, "etag-old")
223+
require.Nil(t, err, "unexpected err: %v", err)
224+
require.Equal(t, "etag-new", newEtag, "expected etag-new, got %q", newEtag)
225+
require.Equal(t, 1, handleCalls, "expected 1 handled call, got %d", handleCalls)
226+
require.Equal(t, 2, reportCalls, "expected 2 skipped reports, got %d", reportCalls)
227+
}

internal/status/immediatestatus.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,16 +44,23 @@ type StatusObserver struct {
4444

4545
// Reporter is the status Reporter
4646
Reporter statusreporter.IGuestInformationServiceClient
47+
48+
ReportImmediateStatusFn func(s ImmediateTopLevelStatus) error
4749
}
4850

4951
func (o *StatusObserver) Initialize(ctx *log.Context) {
5052
o.goalStateEventMap = sync.Map{}
5153
o.ctx = ctx
5254
o.Reporter = statusreporter.NewGuestInformationServiceClient(hostgacommunicator.WireServerFallbackAddress)
55+
56+
o.ReportImmediateStatusFn = func(s ImmediateTopLevelStatus) error {
57+
return o.reportImmediateStatus(s)
58+
}
5359
}
5460

5561
func (o *StatusObserver) OnDemandNotify() error {
56-
return o.reportImmediateStatus(o.getImmediateTopLevelStatusToReport())
62+
status := o.getImmediateTopLevelStatusToReport()
63+
return o.ReportImmediateStatusFn(status)
5764
}
5865

5966
func (o *StatusObserver) OnNotify(status types.StatusEventArgs) error {
@@ -151,11 +158,12 @@ func (o *StatusObserver) reportImmediateStatus(immediateStatus ImmediateTopLevel
151158
o.ctx.Log("message", "create request to upload status to: "+o.Reporter.GetPutStatusUri())
152159
response, err := o.Reporter.ReportStatus(o.ctx, string(rootStatusJson))
153160

154-
o.ctx.Log("message", fmt.Sprintf("Status received from request to %v: %v", response.Request.URL, response.Status))
155161
if err != nil {
156162
return errors.Wrap(err, "failed to report status to HGAP")
157163
}
158164

165+
o.ctx.Log("message", fmt.Sprintf("Status received from request to %v: %v", response.Request.URL, response.Status))
166+
159167
if response.StatusCode != 200 {
160168
return errors.New("failed to report status with error code " + response.Status)
161169
}

0 commit comments

Comments
 (0)