mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-24 16:41:30 +02:00
[test] Add e2e coverage for remote-jobs opt-in and bundle params
Adds an e2e suite (e2e/remotejobs) that runs on the container harness and exercises the two stacked PRs end-to-end against a live management server and a real client: - Remote-jobs opt-in (#7153): a peer that ran plain `netbird up` reports remote_jobs_allowed=false via the peers API, and the client refuses a streamed job ("remote jobs are not enabled on this peer"). After `netbird up --allow-remote-jobs`, the flag flips to true on the API and the same job is accepted for execution. - Bundle job parameters (#7147): an unknown anonymize_level is rejected at job creation, and a messy-but-valid value (' Strict ') is normalized to 'strict' in the stored job the API returns. Adds a small harness helper, Client.Up(extraArgs...), to re-run `netbird up` with flags so the opt-in can be toggled mid-test without recreating the container.
This commit is contained in:
@@ -120,6 +120,26 @@ func (cl *Client) Restart(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Up re-runs `netbird up` inside the client with the given extra flags (e.g.
|
||||
// "--allow-remote-jobs"), bouncing the connection first so the new config is
|
||||
// picked up and re-synced to management. Used to toggle peer options that ride
|
||||
// on the login/sync request without recreating the container.
|
||||
func (cl *Client) Up(ctx context.Context, extraArgs ...string) error {
|
||||
if _, _, err := cl.container.Exec(ctx, []string{"netbird", "down"}, tcexec.Multiplexed()); err != nil {
|
||||
return fmt.Errorf("netbird down: %w", err)
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
code, reader, err := cl.container.Exec(ctx, append([]string{"netbird", "up"}, extraArgs...), tcexec.Multiplexed())
|
||||
if err != nil {
|
||||
return fmt.Errorf("netbird up: %w", err)
|
||||
}
|
||||
if code != 0 {
|
||||
out, _ := io.ReadAll(reader)
|
||||
return fmt.Errorf("netbird up %v exited %d: %s", extraArgs, code, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Status returns `netbird status` output from inside the client.
|
||||
func (cl *Client) Status(ctx context.Context) (string, error) {
|
||||
code, reader, err := cl.container.Exec(ctx, []string{"netbird", "status"}, tcexec.Multiplexed())
|
||||
|
||||
47
e2e/remotejobs/main_test.go
Normal file
47
e2e/remotejobs/main_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
//go:build e2e
|
||||
|
||||
// Package remotejobs holds the container-based e2e suite for the remote-jobs
|
||||
// opt-in (PR #7153) and the debug-bundle job parameters anonymize_level /
|
||||
// upload_url (PR #7147). A combined server is built and bootstrapped once per
|
||||
// package run (TestMain) and shared via srv; each test registers its own client
|
||||
// and cleans it up.
|
||||
package remotejobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/e2e/harness"
|
||||
)
|
||||
|
||||
// srv is the shared combined server for the package, PAT-authenticated by the
|
||||
// time any Test runs.
|
||||
var srv *harness.Combined
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
os.Exit(run(m))
|
||||
}
|
||||
|
||||
func run(m *testing.M) int {
|
||||
// Generous timeout to cover a cold image build on first run.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
var err error
|
||||
srv, err = harness.StartCombined(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "e2e: start combined server: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
defer func() { _ = srv.Terminate(context.Background()) }()
|
||||
|
||||
if _, err := srv.Bootstrap(ctx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "e2e: bootstrap admin PAT: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
return m.Run()
|
||||
}
|
||||
183
e2e/remotejobs/remotejobs_test.go
Normal file
183
e2e/remotejobs/remotejobs_test.go
Normal file
@@ -0,0 +1,183 @@
|
||||
//go:build e2e
|
||||
|
||||
package remotejobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/e2e/harness"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
const refusedReason = "remote jobs are not enabled on this peer"
|
||||
|
||||
// TestRemoteJobsOptInAndBundleParams exercises the two PRs end-to-end against a
|
||||
// live management server and a real client:
|
||||
//
|
||||
// - #7153: the peer's remote-jobs opt-in defaults off, is reported to
|
||||
// management (visible via the peers API as remote_jobs_allowed), and gates
|
||||
// job execution on the client — a streamed job is refused until the peer
|
||||
// opts in with `netbird up --allow-remote-jobs`, after which it runs.
|
||||
// - #7147: the debug-bundle job's anonymize_level is validated (an unknown
|
||||
// value is rejected at creation) and normalized (trimmed + lowercased) in
|
||||
// the stored job the API returns.
|
||||
func TestRemoteJobsOptInAndBundleParams(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// A group for the setup key to auto-assign; peers must land in some group.
|
||||
grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-remotejobs"})
|
||||
require.NoError(t, err, "create group")
|
||||
t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) })
|
||||
|
||||
sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{
|
||||
Name: "e2e-remotejobs",
|
||||
Type: "reusable",
|
||||
ExpiresIn: 86400,
|
||||
UsageLimit: 0,
|
||||
AutoGroups: []string{grp.Id},
|
||||
})
|
||||
require.NoError(t, err, "mint setup key")
|
||||
require.NotEmpty(t, sk.Key, "setup key plaintext")
|
||||
|
||||
// Start the client with a plain `netbird up` (remote jobs NOT enabled).
|
||||
cl, err := harness.StartClient(ctx, srv, sk.Key)
|
||||
require.NoError(t, err, "start client")
|
||||
t.Cleanup(func() { _ = cl.Terminate(context.Background()) })
|
||||
require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management")
|
||||
|
||||
peerID := waitForPeer(ctx, t)
|
||||
|
||||
t.Run("opt-in flag defaults to false and is reported to management (#7153)", func(t *testing.T) {
|
||||
p, err := srv.API().Peers.Get(ctx, peerID)
|
||||
require.NoError(t, err)
|
||||
allowed := remoteJobsAllowed(p)
|
||||
require.NotNil(t, allowed, "remote_jobs_allowed must be present on the peer API")
|
||||
assert.False(t, *allowed, "a peer that ran plain `netbird up` must default to opt-out")
|
||||
})
|
||||
|
||||
t.Run("anonymize_level is validated and normalized (#7147)", func(t *testing.T) {
|
||||
// Unknown level is rejected at job creation.
|
||||
_, err := srv.API().Peers.Jobs(peerID).Create(ctx, bundleJob("bogus", "https://uploads.example.com/bundle"))
|
||||
require.Error(t, err, "an unknown anonymize_level must be rejected")
|
||||
assert.Contains(t, strings.ToLower(err.Error()), "anonymize_level",
|
||||
"the rejection must name the offending field")
|
||||
|
||||
// A messy but valid level is normalized (trimmed + lowercased) in the
|
||||
// stored job the API echoes back.
|
||||
job, err := srv.API().Peers.Jobs(peerID).Create(ctx, bundleJob(" Strict ", "https://uploads.example.com/bundle"))
|
||||
require.NoError(t, err, "a valid anonymize_level must be accepted")
|
||||
bw, err := job.Workload.AsBundleWorkloadResponse()
|
||||
require.NoError(t, err, "job workload must be a bundle")
|
||||
require.NotNil(t, bw.Parameters.AnonymizeLevel)
|
||||
assert.Equal(t, "strict", *bw.Parameters.AnonymizeLevel,
|
||||
"anonymize_level must be normalized to trimmed lowercase")
|
||||
waitForJobTerminal(ctx, t, peerID, job.Id) // let it settle before the next create
|
||||
})
|
||||
|
||||
t.Run("a job is refused while the peer has not opted in (#7153 enforcement)", func(t *testing.T) {
|
||||
job, err := srv.API().Peers.Jobs(peerID).Create(ctx, bundleJob("default", "https://uploads.example.com/bundle"))
|
||||
require.NoError(t, err, "job creation itself is allowed; enforcement is on the client")
|
||||
final := waitForJobTerminal(ctx, t, peerID, job.Id)
|
||||
assert.Equal(t, api.JobResponseStatusFailed, final.Status, "the client must refuse the job")
|
||||
require.NotNil(t, final.FailedReason)
|
||||
assert.Contains(t, *final.FailedReason, refusedReason,
|
||||
"the failure must be the opt-out refusal, not some other error")
|
||||
})
|
||||
|
||||
t.Run("opting in flips the flag and lets the job run (#7153)", func(t *testing.T) {
|
||||
require.NoError(t, cl.Up(ctx, "--allow-remote-jobs"), "re-run up with --allow-remote-jobs")
|
||||
require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must reconnect")
|
||||
|
||||
// The new opt-in must round-trip to management and surface on the API.
|
||||
require.Eventually(t, func() bool {
|
||||
p, err := srv.API().Peers.Get(ctx, peerID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
allowed := remoteJobsAllowed(p)
|
||||
return allowed != nil && *allowed
|
||||
}, 60*time.Second, 2*time.Second, "remote_jobs_allowed must become true after opt-in")
|
||||
|
||||
// The same job that was refused before must now be accepted for
|
||||
// execution: whatever its outcome, it must NOT be the opt-out refusal.
|
||||
job, err := srv.API().Peers.Jobs(peerID).Create(ctx, bundleJob("default", "https://uploads.example.com/bundle"))
|
||||
require.NoError(t, err)
|
||||
final := waitForJobTerminal(ctx, t, peerID, job.Id)
|
||||
if final.Status == api.JobResponseStatusFailed && final.FailedReason != nil {
|
||||
assert.NotContains(t, *final.FailedReason, refusedReason,
|
||||
"once opted in, the job must not be refused for opt-out; any failure must be for another reason (e.g. upload)")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// remoteJobsAllowed returns the peer's remote-jobs opt-in flag from the API
|
||||
// response (nil if the peer or its local flags are absent).
|
||||
func remoteJobsAllowed(p *api.Peer) *bool {
|
||||
if p == nil || p.LocalFlags == nil {
|
||||
return nil
|
||||
}
|
||||
return p.LocalFlags.RemoteJobsAllowed
|
||||
}
|
||||
|
||||
// bundleJob builds a debug-bundle job request with the given anonymize_level
|
||||
// (omitted when empty) and upload_url (omitted when empty).
|
||||
func bundleJob(anonymizeLevel, uploadURL string) api.JobRequest {
|
||||
params := api.BundleParameters{
|
||||
Anonymize: true,
|
||||
LogFileCount: 1,
|
||||
}
|
||||
if anonymizeLevel != "" {
|
||||
params.AnonymizeLevel = &anonymizeLevel
|
||||
}
|
||||
if uploadURL != "" {
|
||||
params.UploadUrl = &uploadURL
|
||||
}
|
||||
var wl api.WorkloadRequest
|
||||
// FromBundleWorkloadRequest cannot fail for a well-formed value.
|
||||
_ = wl.FromBundleWorkloadRequest(api.BundleWorkloadRequest{
|
||||
Type: api.WorkloadTypeBundle,
|
||||
Parameters: params,
|
||||
})
|
||||
return api.JobRequest{Workload: wl}
|
||||
}
|
||||
|
||||
// waitForPeer polls the peers API until the single registered client appears
|
||||
// and returns its ID.
|
||||
func waitForPeer(ctx context.Context, t *testing.T) string {
|
||||
t.Helper()
|
||||
var peerID string
|
||||
require.Eventually(t, func() bool {
|
||||
peers, err := srv.API().Peers.List(ctx)
|
||||
if err != nil || len(peers) == 0 {
|
||||
return false
|
||||
}
|
||||
peerID = peers[0].Id
|
||||
return true
|
||||
}, 60*time.Second, 2*time.Second, "the client peer must register with management")
|
||||
return peerID
|
||||
}
|
||||
|
||||
// waitForJobTerminal polls a job until it leaves the pending state, then returns
|
||||
// the final response.
|
||||
func waitForJobTerminal(ctx context.Context, t *testing.T, peerID, jobID string) *api.JobResponse {
|
||||
t.Helper()
|
||||
var final *api.JobResponse
|
||||
require.Eventually(t, func() bool {
|
||||
j, err := srv.API().Peers.Jobs(peerID).Get(ctx, jobID)
|
||||
if err != nil || j == nil {
|
||||
return false
|
||||
}
|
||||
if j.Status == api.JobResponseStatusPending {
|
||||
return false
|
||||
}
|
||||
final = j
|
||||
return true
|
||||
}, 120*time.Second, 2*time.Second, "job must reach a terminal state")
|
||||
return final
|
||||
}
|
||||
Reference in New Issue
Block a user