Skip to content

[Integration Test] Ensure that upgrading a FIPS-capable Agent results in a FIPS-capable Agent #7804

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 44 commits into from
Jun 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
b0a52e2
Adding skeleton for FIPS-to-FIPS upgrade test
ycombinator Apr 7, 2025
0e69b61
Expose FIPS compliance in GRPC client Version call response
ycombinator Apr 9, 2025
a95ecd2
Test upgrade from FIPS to FIPS artifact
ycombinator Apr 9, 2025
11196de
Change assert to require
ycombinator Apr 11, 2025
53105cb
Add postWatcherSuccessHook to upgrade test
ycombinator Apr 11, 2025
539b4ec
Refactor standalone upgrade test to take upgradeOpts
ycombinator Apr 11, 2025
7cdebd1
Fix up FIPS upgrade test to use postWatcherSuccessHook to test FIPS c…
ycombinator Apr 11, 2025
07acc3f
Add version constraint to test
ycombinator Apr 14, 2025
40c1c50
s/compliant/capable/
ycombinator Apr 17, 2025
845a980
s/compliant/capable/
ycombinator Apr 24, 2025
513ab42
Append -fips to artifact name if current release of Agent is FIPS-cap…
ycombinator Apr 25, 2025
1231b9e
Enable FIPS-capable to FIPS-capable Agent upgrades
ycombinator Apr 25, 2025
40876e8
Running mage fmt
ycombinator Apr 25, 2025
1af3dc5
Adding test to ensure FIPS-capable Agent cannot be upgraded to non-FI…
ycombinator Apr 25, 2025
7af341a
Adding return value
ycombinator Apr 26, 2025
df9d1dd
Fixing function calls
ycombinator Apr 26, 2025
9acd072
Remove post-upgrade success hook since we expect upgrade to fail
ycombinator Apr 26, 2025
731856d
Add minimum FIPS version check for start version
ycombinator Apr 26, 2025
ed1f419
Simplify upgradeOpts initialization
ycombinator Apr 26, 2025
71a628c
Add version equality comparison method
ycombinator Apr 28, 2025
837ab5b
Fix version checks in tests
ycombinator Apr 28, 2025
1dd1574
Refactor version check into own helper function
ycombinator Apr 28, 2025
d875271
Fixing args
ycombinator Apr 28, 2025
9b32e02
No need to pass testing.T
ycombinator Apr 28, 2025
98de896
Remove redundant test case
ycombinator Apr 29, 2025
f65f937
Restrict FIPS integration tests to Linux
ycombinator May 2, 2025
9697e04
Add Fleet-managed Agent FIPS upgrade test
ycombinator May 6, 2025
a163575
Remove integration test trying to upgrade FIPS to non-FIPS
ycombinator May 6, 2025
a8fbb18
Fix type of argument
ycombinator May 6, 2025
ed656cf
Refactoring: extract common logic into helper function
ycombinator May 6, 2025
ab9df22
Remove old code
ycombinator May 6, 2025
06cdd21
Require no error
ycombinator May 6, 2025
fb4f52f
Fixing syntax errors
ycombinator May 6, 2025
39451df
Define tests as needing a FIPS environment
ycombinator May 7, 2025
19db0a9
FIPS upgrade tests should only run on Linux
ycombinator May 7, 2025
6529eec
FIPS upgrade tests should start with FIPS-capable version
ycombinator May 7, 2025
bb516df
Fixing comment + skip message
ycombinator May 7, 2025
7a053e9
Fix syntax errors
ycombinator May 7, 2025
a14d574
Removing TestStandaloneUpgradeFIPStoFIPS test
ycombinator Jun 4, 2025
443d3e3
Removing TestFleetManagedUpgradePrivilegedFIPS test
ycombinator Jun 4, 2025
5713cda
Add back accidentally-deleted function
ycombinator Jun 5, 2025
531c3f3
Combine less and equal unit tests
ycombinator Jun 6, 2025
d770d77
Hash replaceToken only if its non-empty
ycombinator Jun 11, 2025
b99a92f
Use startFixture
ycombinator Jun 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
// or more contributor license agreements. Licensed under the Elastic License 2.0;
// you may not use this file except in compliance with the Elastic License 2.0.

package artifact

import (
"testing"

"github.com/stretchr/testify/require"

agtversion "github.com/elastic/elastic-agent/pkg/version"
)

func TestGetArtifactName(t *testing.T) {
version, err := agtversion.ParseVersion("9.1.0")
require.NoError(t, err)

tests := map[string]struct {
a Artifact
version agtversion.ParsedSemVer
operatingSystem string
arch string
expectedName string
expectedErr string
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: not used anymore in these tests, also version, operatingSystem and arch do not differ in the below testcases, so might be able to tidy this up a bit.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}{
"no_fips": {
a: Artifact{Cmd: "elastic-agent"},
version: *version,
operatingSystem: "linux",
arch: "arm64",
expectedName: "elastic-agent-9.1.0-linux-arm64.tar.gz",
},
"fips": {
a: Artifact{Cmd: "elastic-agent-fips"},
version: *version,
operatingSystem: "linux",
arch: "arm64",
expectedName: "elastic-agent-fips-9.1.0-linux-arm64.tar.gz",
},
}

for name, test := range tests {
t.Run(name, func(t *testing.T) {
artifactName, err := GetArtifactName(test.a, test.version, test.operatingSystem, test.arch)
if test.expectedErr == "" {
require.NoError(t, err)
require.Equal(t, test.expectedName, artifactName)
} else {
require.Empty(t, artifactName)
require.Equal(t, test.expectedErr, err.Error())
}
})
}

}
5 changes: 4 additions & 1 deletion internal/pkg/agent/application/upgrade/step_unpack.go
Original file line number Diff line number Diff line change
Expand Up @@ -599,7 +599,10 @@ func readCommitHash(reader io.Reader) (string, error) {
}

func getFileNamePrefix(archivePath string) string {
return strings.TrimSuffix(filepath.Base(archivePath), ".tar.gz") + "/" // omitting `elastic-agent-{version}-{os}-{arch}/` in filename
prefix := strings.TrimSuffix(filepath.Base(archivePath), ".tar.gz") + "/" // omitting `elastic-agent-{version}-{os}-{arch}/` in filename
prefix = strings.Replace(prefix, fipsPrefix, "", 1)

return prefix
}

func validFileName(p string) bool {
Expand Down
24 changes: 24 additions & 0 deletions internal/pkg/agent/application/upgrade/step_unpack_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -625,3 +625,27 @@ func addEntryToZipArchive(af files, writer *zip.Writer) error {

return nil
}

func TestGetFileNamePrefix(t *testing.T) {
tests := map[string]struct {
archivePath string
expectedPrefix string
}{
"fips": {
archivePath: "/foo/bar/elastic-agent-fips-9.1.0-SNAPSHOT-linux-arm64.tar.gz",
expectedPrefix: "elastic-agent-9.1.0-SNAPSHOT-linux-arm64/",
},
"no_fips": {
archivePath: "/foo/bar/elastic-agent-9.1.0-SNAPSHOT-linux-arm64.tar.gz",
expectedPrefix: "elastic-agent-9.1.0-SNAPSHOT-linux-arm64/",
},
}

for name, test := range tests {
t.Run(name, func(t *testing.T) {
prefix := getFileNamePrefix(test.archivePath)
require.Equal(t, test.expectedPrefix, prefix)
})
}

}
9 changes: 9 additions & 0 deletions internal/pkg/agent/application/upgrade/upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const (
runDirMod = 0770
snapshotSuffix = "-SNAPSHOT"
watcherMaxWaitTime = 30 * time.Second
fipsPrefix = "-fips"
)

var agentArtifact = artifact.Artifact{
Expand All @@ -61,6 +62,12 @@ var (
ErrFipsToNonFips = errors.New("cannot switch to non-fips mode when upgrading")
)

func init() {
if release.FIPSDistribution() {
agentArtifact.Cmd += fipsPrefix
}
}

// Upgrader performs an upgrade
type Upgrader struct {
log *logger.Logger
Expand Down Expand Up @@ -174,10 +181,12 @@ func checkUpgrade(log *logger.Logger, currentVersion, newVersion agentVersion, m
}

if currentVersion.fips && !metadata.manifest.Package.Fips {
log.Warnf("Upgrade action skipped because FIPS-capable Agent cannot be upgraded to non-FIPS-capable Agent")
return ErrFipsToNonFips
}

if !currentVersion.fips && metadata.manifest.Package.Fips {
log.Warnf("Upgrade action skipped because non-FIPS-capable Agent cannot be upgraded to FIPS-capable Agent")
return ErrNonFipsToFips
}

Expand Down
14 changes: 11 additions & 3 deletions internal/pkg/agent/cmd/enroll_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -1051,10 +1051,18 @@ func createFleetConfigFromEnroll(accessAPIKey string, enrollmentToken string, re
if err != nil {
return nil, errors.New(err, "failed to generate enrollment hash", errors.TypeConfig)
}
cfg.ReplaceTokenHash, err = fleetHashToken(replaceToken)
if err != nil {
return nil, errors.New(err, "failed to generate replace token hash", errors.TypeConfig)

// Hash replaceToken if provided; it is not expected to be provided when an Agent
// is being enrolled for the very first time. Hashing an empty replaceToken with the
// FIPS-capable build of Elastic Agent results in an "invalid key length" error from
// OpenSSL's FIPS provider.
if replaceToken != "" {
cfg.ReplaceTokenHash, err = fleetHashToken(replaceToken)
if err != nil {
return nil, errors.New(err, "failed to generate replace token hash", errors.TypeConfig)
}
}

if err := cfg.Valid(); err != nil {
return nil, errors.New(err, "invalid enrollment options", errors.TypeConfig)
}
Expand Down
1 change: 1 addition & 0 deletions pkg/control/v2/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ func (c *client) Version(ctx context.Context) (Version, error) {
Commit: res.Commit,
BuildTime: bt,
Snapshot: res.Snapshot,
Fips: res.Fips,
}, nil
}

Expand Down
43 changes: 28 additions & 15 deletions pkg/testing/define/define.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import (
"sync"
"testing"

"github.com/stretchr/testify/require"

"github.com/gofrs/uuid/v5"

"github.com/elastic/elastic-agent-libs/kibana"
Expand Down Expand Up @@ -83,33 +85,31 @@ func Version() string {
// NewFixtureFromLocalBuild returns a new Elastic Agent testing fixture with a LocalFetcher and
// the agent logging to the test logger.
func NewFixtureFromLocalBuild(t *testing.T, version string, opts ...atesting.FixtureOpt) (*atesting.Fixture, error) {
buildsDir := os.Getenv("AGENT_BUILD_DIR")
if buildsDir == "" {
projectDir, err := findProjectRoot()
if err != nil {
return nil, err
}
buildsDir = filepath.Join(projectDir, "build", "distributions")
}

return NewFixtureWithBinary(t, version, "elastic-agent", buildsDir, opts...)
return NewFixtureWithBinary(t, version, "elastic-agent", buildsDir(t), false, opts...)
}

// NewFixtureFromLocalFIPSBuild returns a new FIPS-capable Elastic Agent testing fixture with a LocalFetcher
// and the agent logging to the test logger.
func NewFixtureFromLocalFIPSBuild(t *testing.T, version string, opts ...atesting.FixtureOpt) (*atesting.Fixture, error) {
return NewFixtureWithBinary(t, version, "elastic-agent", buildsDir(t), true, opts...)
}

// NewFixtureWithBinary returns a new Elastic Agent testing fixture with a LocalFetcher and
// the agent logging to the test logger.
func NewFixtureWithBinary(t *testing.T, version string, binary string, buildsDir string, opts ...atesting.FixtureOpt) (*atesting.Fixture, error) {
func NewFixtureWithBinary(t *testing.T, version string, binary string, buildsDir string, fips bool, opts ...atesting.FixtureOpt) (*atesting.Fixture, error) {
ver, err := semver.ParseVersion(version)
if err != nil {
return nil, fmt.Errorf("%q is an invalid agent version: %w", version, err)
}

var binFetcher atesting.Fetcher
localFetcherOpts := []atesting.LocalFetcherOpt{atesting.WithCustomBinaryName(binary)}
if ver.IsSnapshot() {
binFetcher = atesting.LocalFetcher(buildsDir, atesting.WithLocalSnapshotOnly(), atesting.WithCustomBinaryName(binary))
} else {
binFetcher = atesting.LocalFetcher(buildsDir, atesting.WithCustomBinaryName(binary))
localFetcherOpts = append(localFetcherOpts, atesting.WithLocalSnapshotOnly())
}
if fips {
localFetcherOpts = append(localFetcherOpts, atesting.WithLocalFIPSOnly())
}
binFetcher := atesting.LocalFetcher(buildsDir, localFetcherOpts...)

opts = append(opts, atesting.WithFetcher(binFetcher), atesting.WithLogOutput())
if binary != "elastic-agent" {
Expand Down Expand Up @@ -301,3 +301,16 @@ func getKibanaClient() (*kibana.Client, error) {
}
return c, nil
}

func buildsDir(t *testing.T) string {
t.Helper()

buildsDir := os.Getenv("AGENT_BUILD_DIR")
if buildsDir == "" {
projectDir, err := findProjectRoot()
require.NoError(t, err)
buildsDir = filepath.Join(projectDir, "build", "distributions")
}

return buildsDir
}
23 changes: 16 additions & 7 deletions pkg/testing/fetcher_local.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,27 +18,35 @@ import (
type localFetcher struct {
dir string
snapshotOnly bool
fipsOnly bool
binaryName string
}

type localFetcherOpt func(f *localFetcher)
type LocalFetcherOpt func(f *localFetcher)

// WithLocalSnapshotOnly sets the LocalFetcher to only pull the snapshot build.
func WithLocalSnapshotOnly() localFetcherOpt {
func WithLocalSnapshotOnly() LocalFetcherOpt {
return func(f *localFetcher) {
f.snapshotOnly = true
}
}

// WithLocalFIPSOnly sets the LocalFetcher to only pull a FIPS-compliant build.
func WithLocalFIPSOnly() LocalFetcherOpt {
return func(f *localFetcher) {
f.fipsOnly = true
}
}

// WithCustomBinaryName sets the binary to a custom name, the default is `elastic-agent`
func WithCustomBinaryName(name string) localFetcherOpt {
func WithCustomBinaryName(name string) LocalFetcherOpt {
return func(f *localFetcher) {
f.binaryName = name
}
}

// LocalFetcher returns a fetcher that pulls the binary of the Elastic Agent from a local location.
func LocalFetcher(dir string, opts ...localFetcherOpt) Fetcher {
func LocalFetcher(dir string, opts ...LocalFetcherOpt) Fetcher {
f := &localFetcher{
dir: dir,
binaryName: "elastic-agent",
Expand All @@ -56,6 +64,7 @@ func (f *localFetcher) Name() string {

// Fetch fetches the Elastic Agent and places the resulting binary at the path.
func (f *localFetcher) Fetch(_ context.Context, operatingSystem string, architecture string, version string, packageFormat string) (FetcherResult, error) {
prefix := GetPackagePrefix(f.fipsOnly)
suffix, err := GetPackageSuffix(operatingSystem, architecture, packageFormat)
if err != nil {
return nil, err
Expand All @@ -66,7 +75,7 @@ func (f *localFetcher) Fetch(_ context.Context, operatingSystem string, architec
return nil, fmt.Errorf("invalid version: %q: %w", ver, err)
}

mainBuildfmt := "%s-%s-%s"
mainBuildfmt := "%s-%s%s-%s"
if f.snapshotOnly && !ver.IsSnapshot() {
if ver.Prerelease() == "" {
ver = semver.NewParsedSemVer(ver.Major(), ver.Minor(), ver.Patch(), "SNAPSHOT", ver.BuildMetadata())
Expand All @@ -85,10 +94,10 @@ func (f *localFetcher) Fetch(_ context.Context, operatingSystem string, architec
}

if ver.IsSnapshot() && !matchesEarlyReleaseVersion {
build := fmt.Sprintf(mainBuildfmt, f.binaryName, ver.VersionWithPrerelease(), suffix)
build := fmt.Sprintf(mainBuildfmt, f.binaryName, prefix, ver.VersionWithPrerelease(), suffix)
buildPath = filepath.Join(ver.BuildMetadata(), build)
} else {
buildPath = fmt.Sprintf(mainBuildfmt, f.binaryName, ver.String(), suffix)
buildPath = fmt.Sprintf(mainBuildfmt, f.binaryName, prefix, ver.String(), suffix)
}

fullPath := filepath.Join(f.dir, buildPath)
Expand Down
8 changes: 4 additions & 4 deletions pkg/testing/fetcher_local_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ func TestLocalFetcher(t *testing.T) {
tcs := []struct {
name string
version string
opts []localFetcherOpt
opts []LocalFetcherOpt
want []byte
wantHash []byte
}{
Expand All @@ -69,7 +69,7 @@ func TestLocalFetcher(t *testing.T) {
}, {
name: "SnapshotOnly",
version: baseVersion,
opts: []localFetcherOpt{WithLocalSnapshotOnly()},
opts: []LocalFetcherOpt{WithLocalSnapshotOnly()},
want: snapshotContent,
wantHash: snapshotContentHash,
}, {
Expand All @@ -80,13 +80,13 @@ func TestLocalFetcher(t *testing.T) {
}, {
name: "version with snapshot and SnapshotOnly",
version: baseVersion + "-SNAPSHOT",
opts: []localFetcherOpt{WithLocalSnapshotOnly()},
opts: []LocalFetcherOpt{WithLocalSnapshotOnly()},
want: snapshotContent,
wantHash: snapshotContentHash,
}, {
name: "version with snapshot and build ID",
version: baseVersion + "-SNAPSHOT+l5snflwr",
opts: []localFetcherOpt{},
opts: []LocalFetcherOpt{},
want: snapshotContent,
wantHash: snapshotContentHash,
},
Expand Down
7 changes: 7 additions & 0 deletions pkg/testing/fixture.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ type Fixture struct {
binaryName string
runLength time.Duration
additionalArgs []string
fipsArtifact bool

srcPackage string
workDir string
Expand Down Expand Up @@ -145,6 +146,12 @@ func WithAdditionalArgs(args []string) FixtureOpt {
}
}

func WithFIPSArtifact() FixtureOpt {
return func(f *Fixture) {
f.fipsArtifact = true
}
}

// NewFixture creates a new fixture to setup and manage Elastic Agent.
func NewFixture(t *testing.T, version string, opts ...FixtureOpt) (*Fixture, error) {
// we store the caller so the fixture can find the cache directory for the artifacts that
Expand Down
4 changes: 4 additions & 0 deletions pkg/version/version_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,10 @@ func (psv ParsedSemVer) IndependentBuildID() string {
return ""
}

func (psv ParsedSemVer) Equal(other ParsedSemVer) bool {
return !psv.Less(other) && !other.Less(psv)
}

func (psv ParsedSemVer) Less(other ParsedSemVer) bool {
// compare major version
if psv.major != other.major {
Expand Down
Loading