Android software ingestion (#33826)

> Closes #33581 


<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)


## Testing

- [x] Added/updated automated tests

- [x] QA'd all new/changed functionality manually

## Database migrations

- [x] Checked table schema to confirm autoupdate
- [x] Checked schema for all modified table for columns that will
auto-update timestamps during migration.
- [x] Ensured the correct collation is explicitly set for character
columns (`COLLATE utf8mb4_unicode_ci`).

---------

Co-authored-by: RachelElysia <rachel@fleetdm.com>
This commit is contained in:
Jahziel Villasana-Espinoza 2025-10-08 10:24:38 -04:00 committed by GitHub
parent e01f8c7ebd
commit 0a3c6c35d3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 762 additions and 148 deletions

View file

@ -0,0 +1 @@
- Adds support for software inventory on Android hosts.

View file

@ -785,6 +785,7 @@ the way that the Fleet server works.
svc,
config.License.Key,
config.Server.PrivateKey,
ds,
)
if err != nil {
initFatal(err, "initializing android service")

View file

@ -128,8 +128,7 @@ const HostSoftware = ({
}: IHostSoftwareProps) => {
const { isPremiumTier } = useContext(AppContext);
const isUnsupported =
isAndroid(platform) || (isIPadOrIPhone(platform) && queryParams.vulnerable); // no Android software and no vulnerable software for iOS
const isUnsupported = isIPadOrIPhone(platform) && queryParams.vulnerable; // no Android software and no vulnerable software for iOS
const [showSoftwareFiltersModal, setShowSoftwareFiltersModal] = useState(
false

View file

@ -47,17 +47,6 @@ describe("HostSoftwareTable", () => {
expect(screen.getByText(/no software detected/i)).toBeInTheDocument();
});
it("renders the Android not supported state", () => {
renderWithContext({ platform: "android" });
expect(
screen.getByText(/software is not supported for this host/i)
).toBeInTheDocument();
expect(screen.getByText(/let us know/i)).toBeInTheDocument();
expect(
screen.queryByText(/Software installed on this host/i)
).not.toBeInTheDocument();
});
it("renders custom filter button when filters are applied", () => {
renderWithContext({
vulnFilters: { vulnerable: true },

View file

@ -178,20 +178,6 @@ const HostSoftwareTable = ({
[onShowInventoryVersions]
);
if (isAndroid(platform)) {
return (
<EmptyTable
header="Software is not supported for this host"
info={
<>
Interested in viewing software for Android hosts?{" "}
<CustomLink url={SUPPORT_LINK} text="Let us know" newTab />
</>
}
/>
);
}
const renderCustomFiltersButton = () => {
return (
<TooltipWrapper

54
go.mod
View file

@ -3,7 +3,7 @@ module github.com/fleetdm/fleet/v4
go 1.25.1
require (
cloud.google.com/go/pubsub v1.45.1
cloud.google.com/go/pubsub v1.49.0
fyne.io/systray v1.10.1-0.20240111184411-11c585fff98d
github.com/AbGuthrie/goquery/v2 v2.0.1
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358
@ -142,25 +142,25 @@ require (
go.elastic.co/apm/v2 v2.7.0
go.etcd.io/bbolt v1.3.10
go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux v0.60.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0
go.opentelemetry.io/otel v1.37.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0
go.opentelemetry.io/otel/sdk v1.37.0
go.opentelemetry.io/otel/trace v1.37.0
golang.org/x/crypto v0.39.0
golang.org/x/crypto v0.41.0
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56
golang.org/x/image v0.18.0
golang.org/x/mod v0.25.0
golang.org/x/net v0.41.0
golang.org/x/mod v0.26.0
golang.org/x/net v0.43.0
golang.org/x/oauth2 v0.30.0
golang.org/x/sync v0.15.0
golang.org/x/sys v0.33.0
golang.org/x/term v0.32.0
golang.org/x/text v0.26.0
golang.org/x/tools v0.33.0
google.golang.org/api v0.215.0
google.golang.org/grpc v1.73.0
golang.org/x/sync v0.16.0
golang.org/x/sys v0.35.0
golang.org/x/term v0.34.0
golang.org/x/text v0.28.0
golang.org/x/tools v0.35.0
google.golang.org/api v0.249.0
google.golang.org/grpc v1.75.0
gopkg.in/guregu/null.v3 v3.5.0
gopkg.in/ini.v1 v1.67.0
gopkg.in/natefinch/lumberjack.v2 v2.0.0
@ -171,11 +171,11 @@ require (
)
require (
cloud.google.com/go v0.116.0 // indirect
cloud.google.com/go/auth v0.13.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
cloud.google.com/go/compute/metadata v0.6.0 // indirect
cloud.google.com/go/iam v1.2.2 // indirect
cloud.google.com/go v0.120.0 // indirect
cloud.google.com/go/auth v0.16.5 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.8.0 // indirect
cloud.google.com/go/iam v1.5.2 // indirect
dario.cat/mergo v1.0.0 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect
@ -248,15 +248,15 @@ require (
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/glog v1.2.4 // indirect
github.com/golang/glog v1.2.5 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/go-tpm-tools v0.4.5 // indirect
github.com/google/s2a-go v0.1.8 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/goreleaser/chglog v0.4.2 // indirect
github.com/goreleaser/fileglob v1.3.0 // indirect
github.com/gorilla/schema v1.4.1 // indirect
@ -333,15 +333,15 @@ require (
go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352 // indirect
go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect
go.opentelemetry.io/proto/otlp v1.7.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/time v0.11.0 // indirect
google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
google.golang.org/protobuf v1.36.6 // indirect
golang.org/x/time v0.12.0 // indirect
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect
google.golang.org/protobuf v1.36.8 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
sigs.k8s.io/yaml v1.4.0 // indirect
)

126
go.sum
View file

@ -1,20 +1,20 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
cloud.google.com/go/auth v0.13.0 h1:8Fu8TZy167JkW8Tj3q7dIkr2v4cndv41ouecJx0PAHs=
cloud.google.com/go/auth v0.13.0/go.mod h1:COOjD9gwfKNKz+IIduatIhYJQIc0mG3H102r/EMxX6Q=
cloud.google.com/go/auth/oauth2adapt v0.2.6 h1:V6a6XDu2lTwPZWOawrAa9HUK+DB2zfJyTuciBG5hFkU=
cloud.google.com/go/auth/oauth2adapt v0.2.6/go.mod h1:AlmsELtlEBnaNTL7jCj8VQFLy6mbZv0s4Q7NGBeQ5E8=
cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
cloud.google.com/go/iam v1.2.2 h1:ozUSofHUGf/F4tCNy/mu9tHLTaxZFLOUiKzjcgWHGIA=
cloud.google.com/go/iam v1.2.2/go.mod h1:0Ys8ccaZHdI1dEUilwzqng/6ps2YB6vRsjIe00/+6JY=
cloud.google.com/go/kms v1.20.1 h1:og29Wv59uf2FVaZlesaiDAqHFzHaoUyHI3HYp9VUHVg=
cloud.google.com/go/kms v1.20.1/go.mod h1:LywpNiVCvzYNJWS9JUcGJSVTNSwPwi0vBAotzDqn2nc=
cloud.google.com/go/longrunning v0.6.2 h1:xjDfh1pQcWPEvnfjZmwjKQEcHnpz6lHjfy7Fo0MK+hc=
cloud.google.com/go/longrunning v0.6.2/go.mod h1:k/vIs83RN4bE3YCswdXC5PFfWVILjm3hpEUlSko4PiI=
cloud.google.com/go/pubsub v1.45.1 h1:ZC/UzYcrmK12THWn1P72z+Pnp2vu/zCZRXyhAfP1hJY=
cloud.google.com/go/pubsub v1.45.1/go.mod h1:3bn7fTmzZFwaUjllitv1WlsNMkqBgGUb3UdMhI54eCc=
cloud.google.com/go v0.120.0 h1:wc6bgG9DHyKqF5/vQvX1CiZrtHnxJjBlKUyF9nP6meA=
cloud.google.com/go v0.120.0/go.mod h1:/beW32s8/pGRuj4IILWQNd4uuebeT4dkOhKmkfit64Q=
cloud.google.com/go/auth v0.16.5 h1:mFWNQ2FEVWAliEQWpAdH80omXFokmrnbDhUS9cBywsI=
cloud.google.com/go/auth v0.16.5/go.mod h1:utzRfHMP+Vv0mpOkTRQoWD2q3BatTOoWbA7gCc2dUhQ=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.8.0 h1:HxMRIbao8w17ZX6wBnjhcDkW6lTFpgcaobyVfZWqRLA=
cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw=
cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8=
cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE=
cloud.google.com/go/kms v1.22.0 h1:dBRIj7+GDeeEvatJeTB19oYZNV0aj6wEqSIT/7gLqtk=
cloud.google.com/go/kms v1.22.0/go.mod h1:U7mf8Sva5jpOb4bxYZdtw/9zsbIjrklYwPcvMk34AL8=
cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE=
cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY=
cloud.google.com/go/pubsub v1.49.0 h1:5054IkbslnrMCgA2MAEPcsN3Ky+AyMpEZcii/DoySPo=
cloud.google.com/go/pubsub v1.49.0/go.mod h1:K1FswTWP+C1tI/nfi3HQecoVeFvL4HUOB1tdaNXKhUY=
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
@ -392,8 +392,8 @@ github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXe
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/glog v1.2.4 h1:CNNw5U8lSiiBk7druxtSHHTsRWcxKoac6kZKm2peBBc=
github.com/golang/glog v1.2.4/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/golang/glog v1.2.5 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I=
github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
@ -459,17 +459,17 @@ github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/logger v1.1.1 h1:+6Z2geNxc9G+4D4oDO9njjjn2d0wN5d7uOo0vOIW1NQ=
github.com/google/logger v1.1.1/go.mod h1:BkeJZ+1FhQ+/d087r4dzojEg1u2ZX+ZqG1jTUrLM+zQ=
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v0.0.0-20161128191214-064e2069ce9c/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
github.com/goreleaser/chglog v0.4.2 h1:afmbT1d7lX/q+GF8wv3a1Dofs2j/Y9YkiCpGemWR6mI=
@ -883,8 +883,8 @@ github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs=
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
gitlab.com/digitalxero/go-conventional-commit v1.0.7 h1:8/dO6WWG+98PMhlZowt/YjuiKhqhGlOCwlIV8SqqGh8=
gitlab.com/digitalxero/go-conventional-commit v1.0.7/go.mod h1:05Xc2BFsSyC5tKhK0y+P3bs0AwUtNuTp+mTpbCU/DZ0=
go.einride.tech/aip v0.68.0 h1:4seM66oLzTpz50u4K1zlJyOXQ3tCzcJN7I22tKkjipw=
go.einride.tech/aip v0.68.0/go.mod h1:7y9FF8VtPWqpxuAxl0KQWqaULxW4zFIesD6zF5RIHHg=
go.einride.tech/aip v0.68.1 h1:16/AfSxcQISGN5z9C5lM+0mLYXihrHbQ1onvYTr93aQ=
go.einride.tech/aip v0.68.1/go.mod h1:XaFtaj4HuA3Zwk9xoBtTWgNubZ0ZZXv9BZJCkuKuWbg=
go.elastic.co/apm/module/apmgorilla/v2 v2.6.2 h1:/myBx0D/JiwTUjFkVFG3zXmDfGPfQjP/cg27qcBbdfU=
go.elastic.co/apm/module/apmgorilla/v2 v2.6.2/go.mod h1:uONZzSIh/cKjQ2rZmINR1VXVOJDq5eWOzKrCY+bu00w=
go.elastic.co/apm/module/apmhttp/v2 v2.7.1-0.20250407084155-22ab1be21948 h1:FS1GGVsZoIxezIGL2N3ExjQJzBA3Ne9hxp6HKvUhcRo=
@ -906,10 +906,10 @@ go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJyS
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux v0.60.0 h1:iLuogsToNW6QaOYPcbIwhkdRTkc0gvXzuiajObXc6WY=
go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux v0.60.0/go.mod h1:XNSNQBtSOifFUw0aQUyBN0Ff+0NddEnbSATy2QlFgm8=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM=
@ -922,8 +922,8 @@ go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/Wgbsd
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os=
@ -942,8 +942,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
@ -956,8 +956,8 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180811021610-c39426892332/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@ -980,8 +980,8 @@ golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
@ -993,8 +993,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -1040,14 +1040,14 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@ -1055,10 +1055,10 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@ -1071,14 +1071,16 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.215.0 h1:jdYF4qnyczlEz2ReWIsosNLDuzXyvFHJtI5gcr0J7t0=
google.golang.org/api v0.215.0/go.mod h1:fta3CVtuJYOEdugLNWm6WodzOS8KdFckABwN4I40hzY=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.249.0 h1:0VrsWAKzIZi058aeq+I86uIXbNhm9GxSHpbmZ92a38w=
google.golang.org/api v0.249.0/go.mod h1:dGk9qyI0UYPwO/cjt2q06LG/EhUpwZGdAbYF14wHHrQ=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.2/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
@ -1086,20 +1088,20 @@ google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoA
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 h1:ToEetK57OidYuqD4Q5w+vfEnPvPpuTwedCNVohYJfNk=
google.golang.org/genproto v0.0.0-20241118233622-e639e219e697/go.mod h1:JJrvXBWRZaFMxBufik1a4RpFw4HhgVtBBWQeQgUj2cc=
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY=
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s=
google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 h1:FiusG7LWj+4byqhbvmB+Q93B/mOxJLN2DTozDuZm4EU=
google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:kXqgZtrWaf6qS3jZOCnCH7WYfrvFjkC51bM8fz3RsCA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@ -1111,8 +1113,8 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View file

@ -0,0 +1,56 @@
package tables
import (
"database/sql"
"fmt"
)
func init() {
MigrationClient.AddMigration(Up_20251003094629, Down_20251003094629)
}
func Up_20251003094629(tx *sql.Tx) error {
_, err := tx.Exec(`ALTER TABLE software_titles ADD COLUMN application_id VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL`)
if err != nil {
return fmt.Errorf("failed to add software_titles.application_id column: %w", err)
}
_, err = tx.Exec(`ALTER TABLE software ADD COLUMN application_id VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL`)
if err != nil {
return fmt.Errorf("failed to add software.application_id column: %w", err)
}
if _, err := tx.Exec(`
ALTER TABLE software_titles
DROP INDEX idx_unique_sw_titles
`); err != nil {
return fmt.Errorf("failed to drop unique index: %w", err)
}
// Drop the column to update the definition
_, err = tx.Exec(`ALTER TABLE software_titles DROP COLUMN unique_identifier`)
if err != nil {
return fmt.Errorf("failed to drop software_titles.unique_identifier column: %w", err)
}
_, err = tx.Exec(`
ALTER TABLE software_titles
ADD COLUMN unique_identifier VARCHAR(255) GENERATED ALWAYS AS (COALESCE(bundle_identifier, application_id, name)) VIRTUAL;
`)
if err != nil {
return fmt.Errorf("failed to add generated column unique_identifier: %w", err)
}
if _, err := tx.Exec(`
ALTER TABLE software_titles
ADD UNIQUE INDEX idx_unique_sw_titles (unique_identifier, source, extension_for);
`); err != nil {
return fmt.Errorf("failed to add unique index: %w", err)
}
return nil
}
func Down_20251003094629(tx *sql.Tx) error {
return nil
}

View file

@ -0,0 +1,82 @@
package tables
import (
"testing"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/stretchr/testify/require"
"github.com/tj/assert"
)
func TestUp_20251003094629(t *testing.T) {
db := applyUpToPrev(t)
// Add some non-Android software. The unique_identifier should be the bundle_identifier for the macOS software and the name for the Windows software.
stIDMac := execNoErrLastID(t, db, `INSERT INTO software_titles (name, source, bundle_identifier) VALUES ("iTerm.app", "apps", "com.googlecode.iterm2")`)
stIDWindows := execNoErrLastID(t, db, `INSERT INTO software_titles (name, source) VALUES ("Notepad", "programs")`)
// Apply current migration.
applyNext(t, db)
// Now that the application_id column exists, we can add some Android software.
stIDAndroid := execNoErrLastID(t, db, `INSERT INTO software_titles (name, source, application_id) VALUES ("YouTube", "android_apps", "com.google.youtube")`)
cases := []struct {
name string
titleID int64
expectedBundleID *string
expectedApplicationID *string
expectedUniqueIdentifier string
}{
{
name: "macOS software title",
titleID: stIDMac,
expectedBundleID: ptr.String("com.googlecode.iterm2"),
expectedUniqueIdentifier: "com.googlecode.iterm2",
},
{
name: "android software title",
titleID: stIDAndroid,
expectedApplicationID: ptr.String("com.google.youtube"),
expectedUniqueIdentifier: "com.google.youtube",
},
{
name: "windows software title",
titleID: stIDWindows,
expectedUniqueIdentifier: "Notepad",
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
var title fleet.SoftwareTitle
err := db.Get(&title, "SELECT id, name, source, extension_for, application_id, bundle_identifier FROM software_titles WHERE id = ?", tt.titleID)
require.NoError(t, err)
switch {
case tt.expectedBundleID == nil:
require.Nil(t, title.BundleIdentifier)
case tt.expectedBundleID != nil:
require.NotNil(t, tt.expectedBundleID)
assert.Equal(t, *tt.expectedBundleID, *title.BundleIdentifier)
case tt.expectedApplicationID == nil:
require.Nil(t, title.ApplicationID)
case tt.expectedApplicationID != nil:
require.NotNil(t, title.ApplicationID)
assert.Equal(t, tt.expectedApplicationID, title.ApplicationID)
}
var gotUniqueID string
err = db.Get(&gotUniqueID, "SELECT unique_identifier FROM software_titles WHERE id = ?", tt.titleID)
require.NoError(t, err)
assert.Equal(t, tt.expectedUniqueIdentifier, gotUniqueID)
})
}
}

File diff suppressed because one or more lines are too long

View file

@ -964,6 +964,9 @@ func (ds *Datastore) preInsertSoftwareInventory(
if sw.BundleIdentifier != "" {
st.BundleIdentifier = ptr.String(sw.BundleIdentifier)
}
if sw.ApplicationID != nil && *sw.ApplicationID != "" {
st.ApplicationID = sw.ApplicationID
}
newTitlesNeeded[checksum] = st
}
}
@ -1003,12 +1006,12 @@ func (ds *Datastore) preInsertSoftwareInventory(
// Insert software titles
const numberOfArgsPerSoftwareTitles = 5
titlesValues := strings.TrimSuffix(strings.Repeat("(?,?,?,?,?),", len(uniqueTitlesToInsert)), ",")
titlesStmt := fmt.Sprintf("INSERT IGNORE INTO software_titles (name, source, extension_for, bundle_identifier, is_kernel) VALUES %s", titlesValues)
titlesValues := strings.TrimSuffix(strings.Repeat("(?,?,?,?,?,?),", len(uniqueTitlesToInsert)), ",")
titlesStmt := fmt.Sprintf("INSERT IGNORE INTO software_titles (name, source, extension_for, bundle_identifier, is_kernel, application_id) VALUES %s", titlesValues)
titlesArgs := make([]any, 0, len(uniqueTitlesToInsert)*numberOfArgsPerSoftwareTitles)
for _, title := range uniqueTitlesToInsert {
titlesArgs = append(titlesArgs, title.Name, title.Source, title.ExtensionFor, title.BundleIdentifier, title.IsKernel)
titlesArgs = append(titlesArgs, title.Name, title.Source, title.ExtensionFor, title.BundleIdentifier, title.IsKernel, title.ApplicationID)
}
if _, err := tx.ExecContext(ctx, titlesStmt, titlesArgs...); err != nil {
@ -1066,7 +1069,7 @@ func (ds *Datastore) preInsertSoftwareInventory(
// Insert software entries
const numberOfArgsPerSoftware = 11
values := strings.TrimSuffix(
strings.Repeat("(?,?,?,?,?,?,?,?,?,?,?),", len(batchKeys)), ",",
strings.Repeat("(?,?,?,?,?,?,?,?,?,?,?,?),", len(batchKeys)), ",",
)
stmt := fmt.Sprintf(
`INSERT IGNORE INTO software (
@ -1080,7 +1083,8 @@ func (ds *Datastore) preInsertSoftwareInventory(
extension_id,
extension_for,
title_id,
checksum
checksum,
application_id
) VALUES %s`,
values,
)
@ -1100,7 +1104,7 @@ func (ds *Datastore) preInsertSoftwareInventory(
}
args = append(
args, sw.Name, sw.Version, sw.Source, sw.Release, sw.Vendor, sw.Arch,
sw.BundleIdentifier, sw.ExtensionID, sw.ExtensionFor, titleID, checksum,
sw.BundleIdentifier, sw.ExtensionID, sw.ExtensionFor, titleID, checksum, sw.ApplicationID,
)
}
@ -1478,6 +1482,7 @@ func selectSoftwareSQL(opts fleet.SoftwareListOptions) (string, []interface{}, e
"s.release",
"s.vendor",
"s.arch",
"s.application_id",
goqu.I("scp.cpe").As("generated_cpe"),
).
// Include this in the sub-query in case we want to sort by 'generated_cpe'
@ -1658,6 +1663,7 @@ func selectSoftwareSQL(opts fleet.SoftwareListOptions) (string, []interface{}, e
"s.release",
"s.vendor",
"s.arch",
"s.application_id",
goqu.COALESCE(goqu.I("s.generated_cpe"), "").As("generated_cpe"),
"scv.cve",
"scv.created_at",
@ -4766,7 +4772,7 @@ func (ds *Datastore) SetHostSoftwareInstallResult(ctx context.Context, result *f
func (ds *Datastore) CreateIntermediateInstallFailureRecord(ctx context.Context, result *fleet.HostSoftwareInstallResultPayload) (string, *fleet.HostSoftwareInstallerResult, bool, error) {
// Get the original installation details first, including software title and package info
const getDetailsStmt = `
SELECT
SELECT
hsi.software_installer_id,
hsi.user_id,
hsi.policy_id,

View file

@ -3567,6 +3567,7 @@ func testVerifySoftwareChecksum(t *testing.T, ds *Datastore) {
{Name: "foo", Version: "0.0.1", Source: "test", ExtensionFor: "firefox"},
{Name: "foo", Version: "0.0.1", Source: "test", ExtensionID: "ext"},
{Name: "foo", Version: "0.0.2", Source: "test"},
{Name: "foo", Version: "0.0.2", Source: "test", ApplicationID: ptr.String("foo.bar.baz")},
}
_, err := ds.UpdateHostSoftware(ctx, host.ID, software)
@ -3582,7 +3583,7 @@ func testVerifySoftwareChecksum(t *testing.T, ds *Datastore) {
var got fleet.Software
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &got,
`SELECT name, version, source, bundle_identifier, `+"`release`"+`, arch, vendor, extension_for, extension_id FROM software WHERE checksum = UNHEX(?)`, cs)
`SELECT name, version, source, bundle_identifier, `+"`release`"+`, arch, vendor, extension_for, extension_id, application_id FROM software WHERE checksum = UNHEX(?)`, cs)
})
require.Equal(t, software[i], got)
}
@ -5641,7 +5642,7 @@ func testCreateIntermediateInstallFailureRecord(t *testing.T, ds *Datastore) {
originalCreatedAt := time.Now().Add(-1 * time.Hour).UTC().Truncate(time.Microsecond) // Set to 1 hour ago
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `
INSERT INTO host_software_installs (execution_id, host_id, software_installer_id, user_id, policy_id, self_service, created_at)
INSERT INTO host_software_installs (execution_id, host_id, software_installer_id, user_id, policy_id, self_service, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
"original-uuid", host.ID, installerID, user.ID, nil, false, originalCreatedAt)
return err

View file

@ -44,6 +44,7 @@ SELECT
st.source,
st.extension_for,
st.bundle_identifier,
st.application_id,
COALESCE(sthc.hosts_count, 0) AS hosts_count,
MAX(sthc.updated_at) AS counts_updated_at,
COUNT(si.id) as software_installers_count,
@ -364,6 +365,7 @@ SELECT
,st.source
,st.extension_for
,st.bundle_identifier
,st.application_id
,MAX(COALESCE(sthc.hosts_count, 0)) as hosts_count
,MAX(COALESCE(sthc.updated_at, date('0001-01-01 00:00:00'))) as counts_updated_at
{{if hasTeamID .}}
@ -387,10 +389,10 @@ FROM software_titles st
{{$installerJoin := printf "%s JOIN software_installers si ON si.title_id = st.id AND si.global_or_team_id = %d" (yesNo .PackagesOnly "INNER" "LEFT") (teamID .)}}
{{$installerJoin}}
LEFT JOIN vpp_apps vap ON vap.title_id = st.id AND {{yesNo .PackagesOnly "FALSE" "TRUE"}}
LEFT JOIN vpp_apps_teams vat ON vat.adam_id = vap.adam_id AND vat.platform = vap.platform AND
LEFT JOIN vpp_apps_teams vat ON vat.adam_id = vap.adam_id AND vat.platform = vap.platform AND
{{if .PackagesOnly}} FALSE {{else}} vat.global_or_team_id = {{teamID .}}{{end}}
{{end}}
LEFT JOIN software_titles_host_counts sthc ON sthc.software_title_id = st.id AND
LEFT JOIN software_titles_host_counts sthc ON sthc.software_title_id = st.id AND
(sthc.team_id = {{teamID .}} AND sthc.global_stats = {{if hasTeamID .}} 0 {{else}} 1 {{end}})
{{with $softwareJoin := " "}}
{{if or $.ListOptions.MatchQuery $.VulnerableOnly}}
@ -415,7 +417,7 @@ FROM software_titles st
{{end}}
{{$softwareJoin}}
{{end}}
WHERE
WHERE
{{with $additionalWhere := "TRUE"}}
{{if $.ListOptions.MatchQuery}}
{{$additionalWhere = "(st.name LIKE ? OR scve.cve LIKE ?)"}}
@ -437,7 +439,7 @@ WHERE
{{end}}
AND ({{$defFilter}})
{{end}}
GROUP BY
GROUP BY
st.id
{{if hasTeamID .}}
,package_self_service

View file

@ -2438,6 +2438,12 @@ type AndroidDatastore interface {
// ListHostMDMAndroidProfilesPendingInstallWithVersion returns a list of all android profiles that are pending install, and where version is less than or equals to the policyVersion.
ListHostMDMAndroidProfilesPendingInstallWithVersion(ctx context.Context, hostUUID string, policyVersion int64) ([]*MDMAndroidProfilePayload, error)
GetAndroidPolicyRequestByUUID(ctx context.Context, requestUUID string) (*MDMAndroidPolicyRequest, error)
// UpdateHostSoftware updates the software list of a host.
// The update consists of deleting existing entries that are not in the given `software`
// slice, updating existing entries and inserting new entries.
// Returns a struct with the current installed software on the host (pre-mutations) plus all
// mutations performed: what was inserted and what was removed.
UpdateHostSoftware(ctx context.Context, hostID uint, software []Software) (*UpdateHostSoftwareDBResult, error)
}
// MDMAppleStore wraps nanomdm's storage and adds methods to deal with

View file

@ -96,7 +96,10 @@ type Software struct {
// TODO: should we create a separate type? Feels like this field shouldn't be here since it's
// just used for VPP install verification.
Installed bool `json:"-"`
IsKernel bool `json:"-"`
// IsKernel indicates if this software is a Linux kernel.
IsKernel bool `json:"-"`
// ApplicationID is the unique identifier for Android software. Equivalent to the BundleIdentifier on Apple software.
ApplicationID *string `json:"application_id,omitempty" db:"application_id"`
}
func (Software) AuthzType() string {
@ -136,6 +139,9 @@ func (s Software) ToUniqueStr() string {
if s.ExtensionID != "" || s.ExtensionFor != "" {
ss = append(ss, s.ExtensionID, s.ExtensionFor)
}
if s.ApplicationID != nil && *s.ApplicationID != "" {
ss = append(ss, *s.ApplicationID)
}
return strings.Join(ss, SoftwareFieldSeparator)
}
@ -149,6 +155,9 @@ func (s Software) ComputeRawChecksum() ([]byte, error) {
if s.Source != "apps" {
cols = append([]string{s.Name}, cols...)
}
if s.ApplicationID != nil && *s.ApplicationID != "" {
cols = append(cols, *s.ApplicationID)
}
_, err := fmt.Fprint(h, strings.Join(cols, "\x00"))
if err != nil {
return nil, err
@ -238,6 +247,8 @@ type SoftwareTitle struct {
BundleIdentifier *string `json:"bundle_identifier,omitempty" db:"bundle_identifier"`
// IsKernel indicates if the software title is a Linux kernel.
IsKernel bool `json:"-" db:"is_kernel"`
// ApplicationID is the unique identifier for Android software. Equivalent to the BundleIdentifier on Apple software.
ApplicationID *string `json:"application_id,omitempty" db:"application_id"`
}
// populateBrowserField populates the browser field for backwards compatibility
@ -317,6 +328,8 @@ type SoftwareTitleListResult struct {
// with existing software entries.
BundleIdentifier *string `json:"bundle_identifier,omitempty" db:"bundle_identifier"`
HashSHA256 *string `json:"hash_sha256,omitempty" db:"package_storage_id"`
// ApplicationID is the unique identifier for Android software. Equivalent to the BundleIdentifier on Apple software.
ApplicationID *string `json:"application_id,omitempty" db:"application_id"`
}
type SoftwareTitleListOptions struct {

View file

@ -33,6 +33,12 @@ type EnterprisesListFunc func(ctx context.Context, serverURL string) ([]*android
type SetAuthenticationSecretFunc func(secret string) error
type EnterprisesPoliciesModifyPolicyApplicationsFunc func(ctx context.Context, policyName string, policy *androidmanagement.ApplicationPolicy) (*androidmanagement.Policy, error)
type EnterprisesPoliciesRemovePolicyApplicationsFunc func(ctx context.Context, policyName string, appIDs []string) (*androidmanagement.Policy, error)
type EnterprisesApplicationsFunc func(ctx context.Context, enterpriseName string, packageName string) (*androidmanagement.Application, error)
type Client struct {
SignupURLsCreateFunc SignupURLsCreateFunc
SignupURLsCreateFuncInvoked bool
@ -64,6 +70,15 @@ type Client struct {
SetAuthenticationSecretFunc SetAuthenticationSecretFunc
SetAuthenticationSecretFuncInvoked bool
EnterprisesPoliciesModifyPolicyApplicationsFunc EnterprisesPoliciesModifyPolicyApplicationsFunc
EnterprisesPoliciesModifyPolicyApplicationsFuncInvoked bool
EnterprisesPoliciesRemovePolicyApplicationsFunc EnterprisesPoliciesRemovePolicyApplicationsFunc
EnterprisesPoliciesRemovePolicyApplicationsFuncInvoked bool
EnterprisesApplicationsFunc EnterprisesApplicationsFunc
EnterprisesApplicationsFuncInvoked bool
mu sync.Mutex
}
@ -136,3 +151,24 @@ func (p *Client) SetAuthenticationSecret(secret string) error {
p.mu.Unlock()
return p.SetAuthenticationSecretFunc(secret)
}
func (p *Client) EnterprisesPoliciesModifyPolicyApplications(ctx context.Context, policyName string, policy *androidmanagement.ApplicationPolicy) (*androidmanagement.Policy, error) {
p.mu.Lock()
p.EnterprisesPoliciesModifyPolicyApplicationsFuncInvoked = true
p.mu.Unlock()
return p.EnterprisesPoliciesModifyPolicyApplicationsFunc(ctx, policyName, policy)
}
func (p *Client) EnterprisesPoliciesRemovePolicyApplications(ctx context.Context, policyName string, appIDs []string) (*androidmanagement.Policy, error) {
p.mu.Lock()
p.EnterprisesPoliciesRemovePolicyApplicationsFuncInvoked = true
p.mu.Unlock()
return p.EnterprisesPoliciesRemovePolicyApplicationsFunc(ctx, policyName, appIDs)
}
func (p *Client) EnterprisesApplications(ctx context.Context, enterpriseName string, packageName string) (*androidmanagement.Application, error) {
p.mu.Lock()
p.EnterprisesApplicationsFuncInvoked = true
p.mu.Unlock()
return p.EnterprisesApplicationsFunc(ctx, enterpriseName, packageName)
}

View file

@ -29,7 +29,7 @@ func TestEnterprisesAuth(t *testing.T) {
logger := kitlog.NewLogfmtLogger(os.Stdout)
fleetDS := InitCommonDSMocks()
fleetSvc := mockService{}
svc, err := NewServiceWithClient(logger, fleetDS, &androidAPIClient, &fleetSvc, "test-private-key")
svc, err := NewServiceWithClient(logger, fleetDS, &androidAPIClient, &fleetSvc, "test-private-key", &fleetDS.DataStore)
require.NoError(t, err)
testCases := []struct {
@ -128,7 +128,7 @@ func TestEnterpriseSignupMissingPrivateKey(t *testing.T) {
fleetDS := InitCommonDSMocks()
fleetSvc := mockService{}
svc, err := NewServiceWithClient(logger, fleetDS, &androidAPIClient, &fleetSvc, "test-private-key")
svc, err := NewServiceWithClient(logger, fleetDS, &androidAPIClient, &fleetSvc, "test-private-key", &fleetDS.DataStore)
require.NoError(t, err)
user := &fleet.User{ID: 1, GlobalRole: ptr.String(fleet.RoleAdmin)}
@ -243,7 +243,7 @@ func TestGetEnterprise(t *testing.T) {
fleetDS := InitCommonDSMocks()
fleetSvc := mockService{}
svc, err := NewServiceWithClient(logger, fleetDS, &androidAPIClient, &fleetSvc, "test-private-key")
svc, err := NewServiceWithClient(logger, fleetDS, &androidAPIClient, &fleetSvc, "test-private-key", &fleetDS.DataStore)
require.NoError(t, err)
enterprise, err := svc.GetEnterprise(ctx)
@ -280,7 +280,7 @@ func TestGetEnterprise(t *testing.T) {
}
fleetSvc := mockService{}
svc, err := NewServiceWithClient(logger, fleetDS, &androidAPIClient, &fleetSvc, "test-private-key")
svc, err := NewServiceWithClient(logger, fleetDS, &androidAPIClient, &fleetSvc, "test-private-key", &fleetDS.DataStore)
require.NoError(t, err)
enterprise, err := svc.GetEnterprise(ctx)
@ -330,7 +330,7 @@ func TestGetEnterprise(t *testing.T) {
}
fleetSvc := mockService{}
svc, err := NewServiceWithClient(logger, fleetDS, &androidAPIClient, &fleetSvc, "test-private-key")
svc, err := NewServiceWithClient(logger, fleetDS, &androidAPIClient, &fleetSvc, "test-private-key", &fleetDS.DataStore)
require.NoError(t, err)
enterprise, err := svc.GetEnterprise(ctx)
@ -372,7 +372,7 @@ func TestGetEnterprise(t *testing.T) {
}
fleetSvc := mockService{}
svc, err := NewServiceWithClient(logger, fleetDS, &androidAPIClient, &fleetSvc, "test-private-key")
svc, err := NewServiceWithClient(logger, fleetDS, &androidAPIClient, &fleetSvc, "test-private-key", &fleetDS.DataStore)
require.NoError(t, err)
enterprise, err := svc.GetEnterprise(ctx)
@ -414,7 +414,7 @@ func TestGetEnterprise(t *testing.T) {
}
fleetSvc := mockService{}
svc, err := NewServiceWithClient(logger, fleetDS, &androidAPIClient, &fleetSvc, "test-private-key")
svc, err := NewServiceWithClient(logger, fleetDS, &androidAPIClient, &fleetSvc, "test-private-key", &fleetDS.DataStore)
require.NoError(t, err)
enterprise, err := svc.GetEnterprise(ctx)
@ -482,7 +482,7 @@ func TestGetEnterprise(t *testing.T) {
}
fleetSvc := mockService{}
svc, err := NewServiceWithClient(logger, fleetDS, &androidAPIClient, &fleetSvc, "test-private-key")
svc, err := NewServiceWithClient(logger, fleetDS, &androidAPIClient, &fleetSvc, "test-private-key", &fleetDS.DataStore)
require.NoError(t, err)
enterprise, err := svc.GetEnterprise(ctx)
@ -514,7 +514,7 @@ func TestGetEnterprise(t *testing.T) {
}
fleetSvc := mockService{}
svc, err := NewServiceWithClient(logger, fleetDS, &androidAPIClient, &fleetSvc, "test-private-key")
svc, err := NewServiceWithClient(logger, fleetDS, &androidAPIClient, &fleetSvc, "test-private-key", &fleetDS.DataStore)
require.NoError(t, err)
enterprise, err := svc.GetEnterprise(ctx)

View file

@ -34,7 +34,7 @@ func attachFleetAPIRoutes(r *mux.Router, fleetSvc fleet.Service, svc android.Ser
ne.GET("/api/_version_/fleet/android_enterprise/connect/{token}", enterpriseSignupCallbackEndpoint, enterpriseSignupCallbackRequest{})
ne.GET("/api/_version_/fleet/android_enterprise/enrollment_token", enrollmentTokenEndpoint, enrollmentTokenRequest{})
ne.POST(pubSubPushPath, pubSubPushEndpoint, pubSubPushRequest{})
ne.POST(pubSubPushPath, pubSubPushEndpoint, PubSubPushRequest{})
}
func apiVersions() []string {

View file

@ -18,13 +18,13 @@ import (
"google.golang.org/api/androidmanagement/v1"
)
type pubSubPushRequest struct {
type PubSubPushRequest struct {
Token string `query:"token"`
android.PubSubMessage `json:"message"`
}
func pubSubPushEndpoint(ctx context.Context, request interface{}, svc android.Service) fleet.Errorer {
req := request.(*pubSubPushRequest)
req := request.(*PubSubPushRequest)
err := svc.ProcessPubSubPush(ctx, req.Token, &req.PubSubMessage)
return android.DefaultResponse{Err: err}
}
@ -181,6 +181,50 @@ func (svc *Service) handlePubSubStatusReport(ctx context.Context, token string,
level.Debug(svc.logger).Log("msg", "Error updating Android host", "data", rawData)
return ctxerr.Wrap(ctx, err, "enrolling Android host")
}
err = svc.updateHostSoftware(ctx, &device, host)
if err != nil {
return ctxerr.Wrap(ctx, err, "updating Android host software")
}
return nil
}
// largely based on refetch apps code from Apple MDM service methods
func (svc *Service) updateHostSoftware(ctx context.Context, device *androidmanagement.Device, host *fleet.AndroidHost) error {
// Do nothing if no app reports returned
if len(device.ApplicationReports) == 0 {
return nil
}
truncateString := func(item any, length int) string {
str, ok := item.(string)
if !ok {
return ""
}
runes := []rune(str)
if len(runes) > length {
return string(runes[:length])
}
return str
}
software := []fleet.Software{}
for _, app := range device.ApplicationReports {
if app.State != "INSTALLED" {
continue
}
sw := fleet.Software{
Name: truncateString(app.DisplayName, fleet.SoftwareNameMaxLength),
Version: truncateString(app.VersionName, fleet.SoftwareVersionMaxLength),
ApplicationID: ptr.String(truncateString(app.PackageName, fleet.SoftwareBundleIdentifierMaxLength)),
Source: "android_apps",
Installed: true,
}
software = append(software, sw)
}
_, err := svc.fleetDS.UpdateHostSoftware(ctx, host.Host.ID, software)
if err != nil {
return ctxerr.Wrap(ctx, err, "updating Android host software")
}
return nil
}

View file

@ -26,7 +26,7 @@ func createAndroidService(t *testing.T) (android.Service, *AndroidMockDS) {
logger := kitlog.NewLogfmtLogger(os.Stdout)
mockDS := InitCommonDSMocks()
fleetSvc := mockService{}
svc, err := NewServiceWithClient(logger, mockDS, &androidAPIClient, &fleetSvc, "test-private-key")
svc, err := NewServiceWithClient(logger, mockDS, &androidAPIClient, &fleetSvc, "test-private-key", &mockDS.DataStore)
require.NoError(t, err)
return svc, mockDS

View file

@ -41,6 +41,7 @@ type Service struct {
logger kitlog.Logger
authz *authz.Authorizer
ds fleet.AndroidDatastore
fleetDS fleet.Datastore
androidAPIClient androidmgmt.Client
fleetSvc fleet.Service
serverPrivateKey string
@ -58,9 +59,10 @@ func NewService(
fleetSvc fleet.Service,
licenseKey string,
serverPrivateKey string,
fleetDS fleet.Datastore,
) (android.Service, error) {
client := newAMAPIClient(ctx, logger, licenseKey)
return NewServiceWithClient(logger, ds, client, fleetSvc, serverPrivateKey)
return NewServiceWithClient(logger, ds, client, fleetSvc, serverPrivateKey, fleetDS)
}
func NewServiceWithClient(
@ -69,6 +71,7 @@ func NewServiceWithClient(
client androidmgmt.Client,
fleetSvc fleet.Service,
serverPrivateKey string,
fleetDS fleet.Datastore,
) (android.Service, error) {
authorizer, err := authz.NewAuthorizer()
if err != nil {
@ -83,6 +86,7 @@ func NewServiceWithClient(
fleetSvc: fleetSvc,
serverPrivateKey: serverPrivateKey,
SignupSSEInterval: DefaultSignupSSEInterval,
fleetDS: fleetDS,
}, nil
}

View file

@ -0,0 +1,383 @@
package tests
import (
"context"
"encoding/base64"
"fmt"
"net/http"
"strings"
"testing"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/android"
"github.com/fleetdm/fleet/v4/server/mdm/android/service"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/go-json-experiment/json"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"google.golang.org/api/androidmanagement/v1"
)
func TestServiceSoftware(t *testing.T) {
testingSuite := new(softwareTestSuite)
suite.Run(t, testingSuite)
}
type softwareTestSuite struct {
WithServer
}
func (s *softwareTestSuite) SetupSuite() {
s.WithServer.SetupSuite(s.T(), "androidSoftwareTestSuite")
s.Token = "testtoken"
}
func (s *softwareTestSuite) TestAndroidSoftwareIngestion() {
ctx := context.Background()
t := s.T()
// Create enterprise
var signupResp android.EnterpriseSignupResponse
s.DoJSON("GET", "/api/v1/fleet/android_enterprise/signup_url", nil, http.StatusOK, &signupResp)
assert.Equal(s.T(), EnterpriseSignupURL, signupResp.Url)
s.T().Logf("callbackURL: %s", s.ProxyCallbackURL)
s.FleetSvc.On("NewActivity", mock.Anything, mock.Anything, mock.AnythingOfType("fleet.ActivityTypeEnabledAndroidMDM")).Return(nil)
const enterpriseToken = "enterpriseToken"
s.Do("GET", s.ProxyCallbackURL, nil, http.StatusOK, "enterpriseToken", enterpriseToken)
// Update the LIST mock to return the enterprise after "creation"
s.AndroidAPIClient.EnterprisesListFunc = func(_ context.Context, _ string) ([]*androidmanagement.Enterprise, error) {
return []*androidmanagement.Enterprise{
{Name: "enterprises/" + EnterpriseID},
}, nil
}
resp := android.GetEnterpriseResponse{}
s.DoJSON("GET", "/api/v1/fleet/android_enterprise", nil, http.StatusOK, &resp)
assert.Equal(t, EnterpriseID, resp.EnterpriseID)
// Need to set this because app config is mocked in this setup
s.AppConfig.MDM.AndroidEnabledAndConfigured = true
err := s.DS.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: "enrollsecret"}})
require.NoError(t, err)
secrets, err := s.DS.Datastore.GetEnrollSecrets(ctx, nil)
require.NoError(t, err)
require.Len(t, secrets, 1)
assets, err := s.DS.Datastore.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetAndroidPubSubToken}, nil)
require.NoError(t, err)
pubsubToken := assets[fleet.MDMAssetAndroidPubSubToken]
require.NotEmpty(t, pubsubToken.Value)
deviceID1 := createAndroidDeviceID("test-android")
deviceID2 := createAndroidDeviceID("test-android-2")
enterpriseSpecificID1 := strings.ToUpper(uuid.New().String())
enterpriseSpecificID2 := strings.ToUpper(uuid.New().String())
var req service.PubSubPushRequest
for _, d := range []struct {
id string
esi string
}{{deviceID1, enterpriseSpecificID1}, {deviceID2, enterpriseSpecificID2}} {
enrollmentMessage := enrollmentMessageWithEnterpriseSpecificID(
t,
androidmanagement.Device{
Name: d.id,
EnrollmentTokenData: fmt.Sprintf(`{"EnrollSecret": "%s"}`, secrets[0].Secret),
},
d.esi,
)
req = service.PubSubPushRequest{
PubSubMessage: *enrollmentMessage,
}
s.Do("POST", "/api/v1/fleet/android_enterprise/pubsub", &req, http.StatusOK, "token", string(pubsubToken.Value))
}
// Send device data including software for device 1
software1 := []*androidmanagement.ApplicationReport{{
DisplayName: "Google Chrome",
PackageName: "com.google.chrome",
VersionName: "1.0.0",
State: "INSTALLED",
}}
deviceData1 := createAndroidDeviceWithSoftware(enterpriseSpecificID1, "test-android", createAndroidDeviceID("test-policy"), ptr.Int(1), nil, software1)
req = service.PubSubPushRequest{
PubSubMessage: createStatusReportMessageFromDevice(t, deviceData1),
}
s.Do("POST", "/api/v1/fleet/android_enterprise/pubsub", &req, http.StatusOK, "token", string(pubsubToken.Value))
// Send device data including software for device 2
software2 := []*androidmanagement.ApplicationReport{{
DisplayName: "Google Chrome",
PackageName: "com.google.chrome",
VersionName: "2.0.0",
State: "INSTALLED",
}}
deviceData2 := createAndroidDeviceWithSoftware(enterpriseSpecificID2, "test-android-2", createAndroidDeviceID("test-policy"), ptr.Int(1), nil, software2)
req = service.PubSubPushRequest{
PubSubMessage: createStatusReportMessageFromDevice(t, deviceData2),
}
s.Do("POST", "/api/v1/fleet/android_enterprise/pubsub", &req, http.StatusOK, "token", string(pubsubToken.Value))
mysql.ExecAdhocSQL(t, s.DS.Datastore, func(q sqlx.ExtContext) error {
// Check software table for correct values, we should have as many rows as there were ApplicationReports sent
var software []*fleet.Software
err := sqlx.SelectContext(ctx, q, &software, "SELECT id, name, application_id, source, title_id FROM software")
require.NoError(t, err)
assert.Len(t, software, len(deviceData1.ApplicationReports)+len(deviceData2.ApplicationReports))
// Check software_titles, we should have fewer rows here, because some ApplicationRows map to the same title.
var titles []fleet.SoftwareTitle
err = sqlx.SelectContext(ctx, q, &titles, "SELECT id, name, application_id, source FROM software_titles")
require.NoError(t, err)
require.Len(t, titles, 1)
for _, got := range software {
// Validate that both softwares map to the same title
assert.Equal(t, *got.TitleID, titles[0].ID)
// Check other fields are as expected
assert.Equal(t, "com.google.chrome", *got.ApplicationID)
assert.Equal(t, "Google Chrome", got.Name)
assert.Equal(t, "android_apps", got.Source)
}
return nil
})
// Add some new software for the first device
software1 = []*androidmanagement.ApplicationReport{
{
DisplayName: "Google Chrome",
PackageName: "com.google.chrome",
VersionName: "1.0.0",
State: "INSTALLED",
},
{
DisplayName: "YouTube",
PackageName: "com.google.youtube",
VersionName: "1.0.0",
State: "INSTALLED",
},
{
DisplayName: "Google Drive",
PackageName: "com.google.drive",
VersionName: "1.0.0",
State: "INSTALLED",
},
}
deviceData1 = createAndroidDeviceWithSoftware(enterpriseSpecificID1, "test-android", createAndroidDeviceID("test-policy"), ptr.Int(1), nil, software1)
req = service.PubSubPushRequest{
PubSubMessage: createStatusReportMessageFromDevice(t, deviceData1),
}
s.Do("POST", "/api/v1/fleet/android_enterprise/pubsub", &req, http.StatusOK, "token", string(pubsubToken.Value))
var hostID1 uint
mysql.ExecAdhocSQL(t, s.DS.Datastore, func(q sqlx.ExtContext) error {
// Get host id
err := sqlx.GetContext(ctx, q, &hostID1, "SELECT id FROM hosts WHERE uuid = ?", enterpriseSpecificID1)
require.NoError(t, err)
var software []*fleet.Software
err = sqlx.SelectContext(ctx, q, &software, "SELECT id, name, application_id, source, title_id FROM software JOIN host_software ON host_software.software_id = software.id WHERE host_software.host_id = ?", hostID1)
require.NoError(t, err)
assert.Len(t, software, len(deviceData1.ApplicationReports)) // chrome, youtube, google drive
expectedSW := map[string]fleet.Software{
"com.google.chrome": {ApplicationID: ptr.String("com.google.chrome"), Name: "Google Chrome", Source: "android_apps"},
"com.google.youtube": {ApplicationID: ptr.String("com.google.youtube"), Name: "YouTube", Source: "android_apps"},
"com.google.drive": {ApplicationID: ptr.String("com.google.drive"), Name: "Google Drive", Source: "android_apps"},
}
for _, got := range software {
// Check other fields are as expected
require.NotNil(t, got.ApplicationID)
expected, ok := expectedSW[*got.ApplicationID]
require.Truef(t, ok, "got unexpected software with application id %s", got.ApplicationID)
assert.Equal(t, *expected.ApplicationID, *got.ApplicationID)
assert.Equal(t, expected.Name, got.Name)
assert.Equal(t, expected.Source, got.Source)
}
return nil
})
// Remove some software from the first device
software1 = []*androidmanagement.ApplicationReport{
{
DisplayName: "YouTube",
PackageName: "com.google.youtube",
VersionName: "1.0.0",
State: "INSTALLED",
},
}
deviceData1 = createAndroidDeviceWithSoftware(enterpriseSpecificID1, "test-android", createAndroidDeviceID("test-policy"), ptr.Int(1), nil, software1)
req = service.PubSubPushRequest{
PubSubMessage: createStatusReportMessageFromDevice(t, deviceData1),
}
s.Do("POST", "/api/v1/fleet/android_enterprise/pubsub", &req, http.StatusOK, "token", string(pubsubToken.Value))
mysql.ExecAdhocSQL(t, s.DS.Datastore, func(q sqlx.ExtContext) error {
var software []*fleet.Software
err = sqlx.SelectContext(ctx, q, &software, "SELECT id, name, application_id, source, title_id FROM software JOIN host_software ON host_software.software_id = software.id WHERE host_software.host_id = ?", hostID1)
require.NoError(t, err)
assert.Len(t, software, len(deviceData1.ApplicationReports)) // just youtube now
expectedSW := map[string]fleet.Software{
"com.google.youtube": {ApplicationID: ptr.String("com.google.youtube"), Name: "YouTube", Source: "android_apps"},
}
for _, got := range software {
// Check other fields are as expected
require.NotNil(t, got.ApplicationID)
expected, ok := expectedSW[*got.ApplicationID]
require.Truef(t, ok, "got unexpected software with application id %s", got.ApplicationID)
assert.Equal(t, *expected.ApplicationID, *got.ApplicationID)
assert.Equal(t, expected.Name, got.Name)
assert.Equal(t, expected.Source, got.Source)
}
return nil
})
// Send the same software again, nothing changes
deviceData1 = createAndroidDeviceWithSoftware(enterpriseSpecificID1, "test-android", createAndroidDeviceID("test-policy"), ptr.Int(1), nil, software1)
req = service.PubSubPushRequest{
PubSubMessage: createStatusReportMessageFromDevice(t, deviceData1),
}
s.Do("POST", "/api/v1/fleet/android_enterprise/pubsub", &req, http.StatusOK, "token", string(pubsubToken.Value))
mysql.ExecAdhocSQL(t, s.DS.Datastore, func(q sqlx.ExtContext) error {
var software []*fleet.Software
err = sqlx.SelectContext(ctx, q, &software, "SELECT id, name, application_id, source, title_id FROM software JOIN host_software ON host_software.software_id = software.id WHERE host_software.host_id = ?", hostID1)
require.NoError(t, err)
assert.Len(t, software, len(deviceData1.ApplicationReports)) // just youtube now
expectedSW := map[string]fleet.Software{
"com.google.youtube": {ApplicationID: ptr.String("com.google.youtube"), Name: "YouTube", Source: "android_apps"},
}
for _, got := range software {
// Check other fields are as expected
require.NotNil(t, got.ApplicationID)
expected, ok := expectedSW[*got.ApplicationID]
require.Truef(t, ok, "got unexpected software with application id %s", got.ApplicationID)
assert.Equal(t, expected.ApplicationID, got.ApplicationID)
assert.Equal(t, expected.Name, got.Name)
assert.Equal(t, expected.Source, got.Source)
}
return nil
})
}
func enrollmentMessageWithEnterpriseSpecificID(t *testing.T, deviceInfo androidmanagement.Device, enterpriseSpecificID string) *android.PubSubMessage {
deviceInfo.HardwareInfo = &androidmanagement.HardwareInfo{
EnterpriseSpecificId: enterpriseSpecificID,
Brand: "TestBrand",
Model: "TestModel",
SerialNumber: "test-serial",
Hardware: "test-hardware",
}
deviceInfo.SoftwareInfo = &androidmanagement.SoftwareInfo{
AndroidBuildNumber: "test-build",
AndroidVersion: "1",
}
deviceInfo.MemoryInfo = &androidmanagement.MemoryInfo{
TotalRam: int64(8 * 1024 * 1024 * 1024), // 8GB RAM in bytes
TotalInternalStorage: int64(64 * 1024 * 1024 * 1024), // 64GB system partition
}
deviceInfo.MemoryEvents = []*androidmanagement.MemoryEvent{
{
EventType: "EXTERNAL_STORAGE_DETECTED",
ByteCount: int64(64 * 1024 * 1024 * 1024), // 64GB external/built-in storage total capacity
CreateTime: "2024-01-15T09:00:00Z",
},
{
EventType: "INTERNAL_STORAGE_MEASURED",
ByteCount: int64(10 * 1024 * 1024 * 1024), // 10GB free in system partition
CreateTime: "2024-01-15T10:00:00Z",
},
{
EventType: "EXTERNAL_STORAGE_MEASURED",
ByteCount: int64(25 * 1024 * 1024 * 1024), // 25GB free in external/built-in storage
CreateTime: "2024-01-15T10:00:00Z",
},
}
data, err := json.Marshal(deviceInfo)
require.NoError(t, err)
encodedData := base64.StdEncoding.EncodeToString(data)
return &android.PubSubMessage{
Attributes: map[string]string{
"notificationType": string(android.PubSubEnrollment),
},
Data: encodedData,
}
}
func createAndroidDeviceID(name string) string {
return "enterprises/mock-enterprise-id/devices/" + name
}
func createAndroidDeviceWithSoftware(deviceId, name, policyName string, policyVersion *int, nonComplianceDetails []*androidmanagement.NonComplianceDetail, software []*androidmanagement.ApplicationReport) androidmanagement.Device {
return androidmanagement.Device{
Name: createAndroidDeviceID(name),
NonComplianceDetails: nonComplianceDetails,
HardwareInfo: &androidmanagement.HardwareInfo{
EnterpriseSpecificId: deviceId,
Brand: "TestBrand",
Model: "TestModel",
SerialNumber: "test-serial",
Hardware: "test-hardware",
},
SoftwareInfo: &androidmanagement.SoftwareInfo{
AndroidBuildNumber: "test-build",
AndroidVersion: "1",
},
MemoryInfo: &androidmanagement.MemoryInfo{
TotalRam: int64(8 * 1024 * 1024 * 1024), // 8GB RAM in bytes
TotalInternalStorage: int64(64 * 1024 * 1024 * 1024), // 64GB system partition
},
AppliedPolicyName: policyName,
AppliedPolicyVersion: int64(*policyVersion),
LastPolicySyncTime: "2001-01-01T00:00:00Z",
ApplicationReports: software,
}
}
func createStatusReportMessageFromDevice(t *testing.T, device androidmanagement.Device) android.PubSubMessage {
data, err := json.Marshal(device)
require.NoError(t, err)
encodedData := base64.StdEncoding.EncodeToString(data)
return android.PubSubMessage{
Attributes: map[string]string{
"notificationType": string(android.PubSubStatusReport),
},
Data: encodedData,
}
}

View file

@ -109,7 +109,7 @@ func (ts *WithServer) SetupSuite(t *testing.T, dbName string) {
ts.createCommonProxyMocks(t)
logger := kitlog.NewLogfmtLogger(os.Stdout)
svc, err := service.NewServiceWithClient(logger, &ts.DS, &ts.AndroidAPIClient, &ts.FleetSvc, "test-private-key")
svc, err := service.NewServiceWithClient(logger, &ts.DS, &ts.AndroidAPIClient, &ts.FleetSvc, "test-private-key", ts.DS.Datastore)
require.NoError(t, err)
ts.Svc = svc

View file

@ -30,6 +30,7 @@ func SetUpSuite(t *testing.T, uniqueTestName string) *Suite {
&proxy,
fleetSvc,
"test-private-key",
ds,
)
require.NoError(t, err)
androidSvc.(*android_service.Service).AllowLocalhostServerURL = true