diff --git a/.github/workflows/build-and-check-fleetctl-docker-and-deps.yml b/.github/workflows/build-and-check-fleetctl-docker-and-deps.yml index 50f4e58f13ae..ff20260409ac 100644 --- a/.github/workflows/build-and-check-fleetctl-docker-and-deps.yml +++ b/.github/workflows/build-and-check-fleetctl-docker-and-deps.yml @@ -47,7 +47,7 @@ jobs: - name: Set up Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ vars.GO_VERSION }} + go-version-file: 'go.mod' - name: Install Go Dependencies run: make deps-go diff --git a/.github/workflows/build-binaries.yaml b/.github/workflows/build-binaries.yaml index ed18437c74a5..278f958b2842 100644 --- a/.github/workflows/build-binaries.yaml +++ b/.github/workflows/build-binaries.yaml @@ -29,10 +29,13 @@ jobs: with: egress-policy: audit + - name: Checkout Code + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - name: Install Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ vars.GO_VERSION }} + go-version-file: 'go.mod' # Set the Node.js version - name: Set up Node.js ${{ vars.NODE_VERSION }} @@ -40,9 +43,6 @@ jobs: with: node-version: ${{ vars.NODE_VERSION }} - - name: Checkout Code - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - - name: JS Dependency Cache id: js-cache uses: actions/cache@69d9d449aced6a2ede0bc19182fadc3a0a42d2b0 # v2 diff --git a/.github/workflows/build-orbit.yaml b/.github/workflows/build-orbit.yaml index 09f296aece9e..002d2657f6ed 100644 --- a/.github/workflows/build-orbit.yaml +++ b/.github/workflows/build-orbit.yaml @@ -59,7 +59,7 @@ jobs: - name: Set up Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ vars.GO_VERSION }} + go-version-file: 'go.mod' - name: Build, codesign and notarize orbit run: go run ./orbit/tools/build/build.go diff --git a/.github/workflows/check-automated-doc.yml b/.github/workflows/check-automated-doc.yml index c654e7ae4fdc..d289c55318de 100644 --- a/.github/workflows/check-automated-doc.yml +++ b/.github/workflows/check-automated-doc.yml @@ -36,15 +36,16 @@ jobs: with: egress-policy: audit - - name: Install Go - uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 - with: - go-version: ${{ vars.GO_VERSION }} - name: Checkout Code uses: actions/checkout@629c2de402a417ea7690ca6ce3f33229e27606a5 # v2 with: fetch-depth: 0 + - name: Install Go + uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 + with: + go-version-file: 'go.mod' + - name: Verify golang generated documentation is up-to-date run: | make generate-doc diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 246c6418a170..c69888f874db 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -56,7 +56,7 @@ jobs: - name: Set up Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ vars.GO_VERSION }} + go-version-file: 'go.mod' # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/deploy-fleet-website.yml b/.github/workflows/deploy-fleet-website.yml index 9fc044e13b3f..371a0014f075 100644 --- a/.github/workflows/deploy-fleet-website.yml +++ b/.github/workflows/deploy-fleet-website.yml @@ -64,7 +64,7 @@ jobs: - name: Set up Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ vars.GO_VERSION }} + go-version-file: 'go.mod' # Download top-level dependencies and build Storybook in the website's assets/ folder - run: npm install --legacy-peer-deps && npm run build-storybook -- -o ./website/assets/storybook --loglevel verbose diff --git a/.github/workflows/dogfood-deploy.yml b/.github/workflows/dogfood-deploy.yml index f9d8cff07156..f17768eec7c5 100644 --- a/.github/workflows/dogfood-deploy.yml +++ b/.github/workflows/dogfood-deploy.yml @@ -51,14 +51,17 @@ jobs: - id: fail-on-main run: "false" if: ${{ github.ref == 'main' }} + - uses: aws-actions/configure-aws-credentials@67fbcbb121271f7775d2e7715933280b06314838 # v1.7.0 with: role-to-assume: ${{env.AWS_IAM_ROLE}} aws-region: ${{ env.AWS_REGION }} + - name: Set up Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ vars.GO_VERSION }} + go-version-file: 'go.mod' + - uses: hashicorp/setup-terraform@633666f66e0061ca3b725c73b2ec20cd13a8fdd1 # v2.0.3 with: terraform_version: 1.6.3 diff --git a/.github/workflows/dogfood-gitops.yml b/.github/workflows/dogfood-gitops.yml index 8b2e3217c035..487cdac1ad11 100644 --- a/.github/workflows/dogfood-gitops.yml +++ b/.github/workflows/dogfood-gitops.yml @@ -69,6 +69,7 @@ jobs: DOGFOOD_GLOBAL_ENROLL_SECRET: ${{ secrets.DOGFOOD_GLOBAL_ENROLL_SECRET }} DOGFOOD_SSO_ISSUER_URI: ${{ secrets.DOGFOOD_SSO_ISSUER_URI }} DOGFOOD_SSO_METADATA: ${{ secrets.DOGFOOD_SSO_METADATA }} + DOGFOOD_MDM_SSO_METADATA_URL: ${{ secrets.DOGFOOD_MDM_SSO_METADATA_URL }} DOGFOOD_FAILING_POLICIES_WEBHOOK_URL: ${{ secrets.DOGFOOD_FAILING_POLICIES_WEBHOOK_URL }} DOGFOOD_VULNERABILITIES_WEBHOOK_URL: ${{ secrets.DOGFOOD_VULNERABILITIES_WEBHOOK_URL }} DOGFOOD_WORKSTATIONS_ENROLL_SECRET: ${{ secrets.DOGFOOD_WORKSTATIONS_ENROLL_SECRET }} diff --git a/.github/workflows/fleet-and-orbit.yml b/.github/workflows/fleet-and-orbit.yml index 571d59d067e0..f4dfb2780eb7 100644 --- a/.github/workflows/fleet-and-orbit.yml +++ b/.github/workflows/fleet-and-orbit.yml @@ -62,7 +62,6 @@ jobs: timeout-minutes: 60 strategy: matrix: - go-version: ["${{ vars.GO_VERSION }}"] mysql: ["mysql:8.0.36"] runs-on: ubuntu-latest needs: gen @@ -72,10 +71,13 @@ jobs: with: egress-policy: audit + - name: Checkout Code + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - name: Install Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ matrix.go-version }} + go-version-file: 'go.mod' # Set the Node.js version - name: Set up Node.js ${{ vars.NODE_VERSION }} @@ -83,9 +85,6 @@ jobs: with: node-version: ${{ vars.NODE_VERSION }} - - name: Checkout Code - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - - name: Start tunnel env: CERT_PEM: ${{ secrets.CLOUDFLARE_TUNNEL_FLEETUEM_CERT_B64 }} @@ -175,9 +174,6 @@ jobs: # This job also makes sure the Fleet server is up and running. set-enroll-secret: timeout-minutes: 60 - strategy: - matrix: - go-version: ["${{ vars.GO_VERSION }}"] runs-on: ubuntu-latest needs: gen steps: @@ -186,13 +182,13 @@ jobs: with: egress-policy: audit + - name: Checkout Code + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - name: Install Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ matrix.go-version }} - - - name: Checkout Code - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + go-version-file: 'go.mod' - name: Build Fleetctl run: make fleetctl @@ -218,9 +214,6 @@ jobs: # Here we generate the Fleet Desktop and osqueryd targets for # macOS which can only be generated from a macOS host. build-macos-targets: - strategy: - matrix: - go-version: ["${{ vars.GO_VERSION }}"] # Set macOS version to '12' (current equivalent to macos-latest) for # building the binary. This ensures compatibility with macOS version 13 and # later, avoiding runtime errors on systems using macOS 13 or newer. @@ -234,13 +227,13 @@ jobs: with: egress-policy: audit + - name: Checkout Code + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - name: Install Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ matrix.go-version }} - - - name: Checkout Code - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + go-version-file: 'go.mod' - name: Build desktop.app.tar.gz and osqueryd.app.tar.gz run: | @@ -269,9 +262,6 @@ jobs: # installed, and installing it is time consuming and unreliable. run-tuf-and-gen-pkgs: timeout-minutes: 60 - strategy: - matrix: - go-version: ["${{ vars.GO_VERSION }}"] runs-on: ubuntu-latest needs: [gen, build-macos-targets] steps: @@ -280,13 +270,13 @@ jobs: with: egress-policy: audit + - name: Checkout Code + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - name: Install Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ matrix.go-version }} - - - name: Checkout Code - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + go-version-file: 'go.mod' - name: Download macos pre-built apps id: download diff --git a/.github/workflows/fleetctl-preview-latest.yml b/.github/workflows/fleetctl-preview-latest.yml index dda4e0f73c2d..630cfd1dc325 100644 --- a/.github/workflows/fleetctl-preview-latest.yml +++ b/.github/workflows/fleetctl-preview-latest.yml @@ -53,7 +53,6 @@ jobs: # - Unattended installation of Docker on macOS fails. (see # https://github.com/docker/for-mac/issues/6450) os: [ubuntu-latest] - go-version: ['${{ vars.GO_VERSION }}'] runs-on: ${{ matrix.os }} steps: @@ -62,13 +61,13 @@ jobs: with: egress-policy: audit + - name: Checkout Code + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - name: Install Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ matrix.go-version }} - - - name: Checkout Code - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + go-version-file: 'go.mod' - name: Build Fleetctl run: make fleetctl diff --git a/.github/workflows/fleetd-tuf.yml b/.github/workflows/fleetd-tuf.yml index ebeca889daa1..7641589f102b 100644 --- a/.github/workflows/fleetd-tuf.yml +++ b/.github/workflows/fleetd-tuf.yml @@ -30,16 +30,16 @@ jobs: with: egress-policy: audit - - name: Install Go - uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 - with: - go-version: ${{ vars.GO_VERSION }} - - name: Checkout Code uses: actions/checkout@629c2de402a417ea7690ca6ce3f33229e27606a5 # v2 with: fetch-depth: 0 + - name: Install Go + uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 + with: + go-version-file: 'go.mod' + - name: Update orbit/TUF.md run: | make fleetd-tuf diff --git a/.github/workflows/generate-desktop-targets.yml b/.github/workflows/generate-desktop-targets.yml index 67313ea76286..d7324c9bf0ba 100644 --- a/.github/workflows/generate-desktop-targets.yml +++ b/.github/workflows/generate-desktop-targets.yml @@ -45,13 +45,13 @@ jobs: with: egress-policy: audit + - name: Checkout + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - name: Install Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ vars.GO_VERSION }} - - - name: Checkout - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + go-version-file: 'go.mod' - name: Import signing keys env: @@ -98,13 +98,13 @@ jobs: with: egress-policy: audit + - name: Checkout + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - name: Install Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ vars.GO_VERSION }} - - - name: Checkout - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + go-version-file: 'go.mod' - name: Generate fleet-desktop.exe run: | @@ -139,13 +139,13 @@ jobs: with: egress-policy: audit + - name: Checkout + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - name: Install Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ vars.GO_VERSION }} - - - name: Checkout - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + go-version-file: 'go.mod' - name: Generate desktop.tar.gz run: | @@ -167,13 +167,13 @@ jobs: with: egress-policy: audit + - name: Checkout + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - name: Install Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ vars.GO_VERSION }} - - - name: Checkout - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + go-version-file: 'go.mod' - name: Generate desktop.tar.gz run: | diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index df6b9792b73d..3d3e95ed2c8c 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -38,7 +38,6 @@ jobs: matrix: # See #9943, we just need to add windows-latest here once all issues are fixed. os: [ubuntu-latest, macos-latest] - go-version: ['${{ vars.GO_VERSION }}'] runs-on: ${{ matrix.os }} steps: - name: Harden Runner @@ -52,7 +51,7 @@ jobs: - name: Install Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ matrix.go-version }} + go-version-file: 'go.mod' - name: Install dependencies (Linux) if: matrix.os == 'ubuntu-latest' diff --git a/.github/workflows/goreleaser-fleet.yaml b/.github/workflows/goreleaser-fleet.yaml index f4224907e0fa..6ba9aff8f08f 100644 --- a/.github/workflows/goreleaser-fleet.yaml +++ b/.github/workflows/goreleaser-fleet.yaml @@ -44,7 +44,7 @@ jobs: - name: Set up Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ vars.GO_VERSION }} + go-version-file: 'go.mod' # Set the Node.js version - name: Set up Node.js ${{ vars.NODE_VERSION }} diff --git a/.github/workflows/goreleaser-orbit.yaml b/.github/workflows/goreleaser-orbit.yaml index 666f28112051..54e16752b335 100644 --- a/.github/workflows/goreleaser-orbit.yaml +++ b/.github/workflows/goreleaser-orbit.yaml @@ -56,7 +56,7 @@ jobs: - name: Set up Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ vars.GO_VERSION }} + go-version-file: 'go.mod' - name: Run GoReleaser run: go run github.com/goreleaser/goreleaser@56c9d09a1b925e2549631c6d180b0a1c2ebfac82 release --debug --rm-dist --skip-publish -f orbit/goreleaser-macos.yml # v1.20.0 @@ -95,7 +95,7 @@ jobs: - name: Set up Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ vars.GO_VERSION }} + go-version-file: 'go.mod' - name: Run GoReleaser run: go run github.com/goreleaser/goreleaser@56c9d09a1b925e2549631c6d180b0a1c2ebfac82 release --debug --rm-dist --skip-publish -f orbit/goreleaser-linux.yml # v1.20.0 @@ -128,7 +128,7 @@ jobs: - name: Set up Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ vars.GO_VERSION }} + go-version-file: 'go.mod' - name: Run GoReleaser run: go run github.com/goreleaser/goreleaser@56c9d09a1b925e2549631c6d180b0a1c2ebfac82 release --debug --rm-dist --skip-publish -f orbit/goreleaser-linux-arm64.yml # v1.20.0 @@ -161,7 +161,7 @@ jobs: - name: Set up Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ vars.GO_VERSION }} + go-version-file: 'go.mod' - name: Run GoReleaser run: go run github.com/goreleaser/goreleaser@56c9d09a1b925e2549631c6d180b0a1c2ebfac82 release --debug --rm-dist --skip-publish -f orbit/goreleaser-windows.yml # v1.20.0 diff --git a/.github/workflows/goreleaser-snapshot-fleet.yaml b/.github/workflows/goreleaser-snapshot-fleet.yaml index 46c1da4193c1..927cf31be1da 100644 --- a/.github/workflows/goreleaser-snapshot-fleet.yaml +++ b/.github/workflows/goreleaser-snapshot-fleet.yaml @@ -57,7 +57,7 @@ jobs: - name: Set up Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ vars.GO_VERSION }} + go-version-file: 'go.mod' # Set the Node.js version - name: Set up Node.js ${{ vars.NODE_VERSION }} diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 015a464b4b84..98c9cd3a5973 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -264,13 +264,13 @@ jobs: npm install -g fleetctl fleetctl config set --address ${{ needs.gen.outputs.address }} --token ${{ needs.login.outputs.token }} + - name: Checkout Code + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - name: Install Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ vars.GO_VERSION }} - - - name: Checkout Code - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + go-version-file: 'go.mod' - name: Build Fleetctl run: make fleetctl diff --git a/.github/workflows/release-fleetctl-docker-deps.yaml b/.github/workflows/release-fleetctl-docker-deps.yaml index 8fc698f6ac38..c751655d9353 100644 --- a/.github/workflows/release-fleetctl-docker-deps.yaml +++ b/.github/workflows/release-fleetctl-docker-deps.yaml @@ -36,13 +36,13 @@ jobs: with: egress-policy: audit + - name: Checkout Code + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - name: Install Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ vars.GO_VERSION }} - - - name: Checkout Code - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + go-version-file: 'go.mod' - name: Login to Docker Hub uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a diff --git a/.github/workflows/release-fleetd-base.yml b/.github/workflows/release-fleetd-base.yml index d7b02cfcf7fa..99099019641c 100644 --- a/.github/workflows/release-fleetd-base.yml +++ b/.github/workflows/release-fleetd-base.yml @@ -51,16 +51,16 @@ jobs: with: egress-policy: audit - - name: Install Go - uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0 - with: - go-version: ${{ vars.GO_VERSION }} - - name: Checkout Code uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 with: fetch-depth: 0 + - name: Install Go + uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0 + with: + go-version-file: 'go.mod' + - name: Check for fleetd component updates id: check-for-fleetd-component-updates run: | diff --git a/.github/workflows/test-db-changes.yml b/.github/workflows/test-db-changes.yml index ecfe464072fe..2bd89ab82b1e 100644 --- a/.github/workflows/test-db-changes.yml +++ b/.github/workflows/test-db-changes.yml @@ -10,7 +10,7 @@ on: paths: - '**.go' - 'server/datastore/mysql/schema.sql' - - '.github/workflows/test-schema-changes.yml' + - '.github/workflows/test-db-changes.yml' workflow_dispatch: # Manual # This allows a subsequently queued workflow run to interrupt previous runs @@ -35,19 +35,29 @@ jobs: with: egress-policy: audit - - name: Install Go - uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 - with: - go-version: ${{ vars.GO_VERSION }} - name: Checkout Code uses: actions/checkout@629c2de402a417ea7690ca6ce3f33229e27606a5 # v2 with: fetch-depth: 0 + - name: Install Go + uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 + with: + go-version-file: 'go.mod' + - name: Start Infra Dependencies # Use & to background this run: docker compose up -d mysql_test & + - name: Wait for mysql + run: | + echo "waiting for mysql..." + until docker compose exec -T mysql_test sh -c "mysql -uroot -p\"\${MYSQL_ROOT_PASSWORD}\" -e \"SELECT 1=1\" fleet" &> /dev/null; do + echo "." + sleep 1 + done + echo "mysql is ready" + - name: Verify test schema changes run: | make dump-test-schema diff --git a/.github/workflows/test-fleetd-chrome.yml b/.github/workflows/test-fleetd-chrome.yml index 47ba496ebbb7..8cbb0125f9b8 100644 --- a/.github/workflows/test-fleetd-chrome.yml +++ b/.github/workflows/test-fleetd-chrome.yml @@ -66,7 +66,8 @@ jobs: npm test - name: Upload to Codecov - uses: codecov/codecov-action@d9f34f8cd5cb3b3eb79b3e4b5dae3a16df499a70 # v3.1.1 + uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # v4.5.0 with: + token: ${{ secrets.CODECOV_TOKEN }} directory: ./ee/fleetd-chrome/coverage flags: fleetd-chrome diff --git a/.github/workflows/test-go.yaml b/.github/workflows/test-go.yaml index 9feba50d8e1e..b5f2b8fe943c 100644 --- a/.github/workflows/test-go.yaml +++ b/.github/workflows/test-go.yaml @@ -44,7 +44,6 @@ jobs: matrix: suite: ["integration", "core"] os: [ubuntu-latest] - go-version: ['${{ vars.GO_VERSION }}'] mysql: ["mysql:8.0.36", "mysql:8.4.2"] continue-on-error: ${{ matrix.suite == 'integration' }} # Since integration tests have a higher chance of failing, often for unrelated reasons, we don't want to fail the whole job if they fail runs-on: ${{ matrix.os }} @@ -65,7 +64,7 @@ jobs: - name: Install Go uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 with: - go-version: ${{ matrix.go-version }} + go-version-file: 'go.mod' # Pre-starting dependencies here means they are ready to go when we need them. - name: Start Infra Dependencies @@ -131,13 +130,17 @@ jobs: NETWORK_TEST_GITHUB_TOKEN=${{ secrets.FLEET_RELEASE_GITHUB_PAT }} \ make test-go 2>&1 | tee /tmp/gotest.log - # note: it's fine to upload multiple reports (one per matrix combination) - # for the same run, see https://docs.codecov.com/docs/merging-reports - - name: Upload to Codecov - uses: codecov/codecov-action@d9f34f8cd5cb3b3eb79b3e4b5dae3a16df499a70 + - name: Create mysql identifier without colon + if: always() + run: | + echo "MATRIX_MYSQL_ID=$(echo ${{ matrix.mysql }} | tr -d ':')" >> $GITHUB_ENV + + - name: Save coverage + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: - files: coverage.txt - flags: backend + name: ${{ matrix.suite }}-${{ env.MATRIX_MYSQL_ID }}-coverage + path: ./coverage.txt + if-no-files-found: error - name: Generate summary of errors if: failure() @@ -167,14 +170,9 @@ jobs: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_G_HELP_ENGINEERING_WEBHOOK_URL }} SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK - - name: Create mysql identifier without colon - if: always() - run: | - echo "MATRIX_MYSQL_ID=$(echo ${{ matrix.mysql }} | tr -d ':')" >> $GITHUB_ENV - - name: Upload test log if: always() - uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v2 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: ${{ matrix.suite }}-${{ env.MATRIX_MYSQL_ID }}-test-log path: /tmp/gotest.log @@ -182,7 +180,24 @@ jobs: - name: Upload summary test log if: always() - uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v2 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: ${{ matrix.suite }}-${{ env.MATRIX_MYSQL_ID }}-summary-test-log path: /tmp/summary.txt + + # We upload all backend coverage in one step so that we're less like to end up in a situation with a partial coverage report. + upload-coverage: + needs: [test-go] + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - name: Download artifacts + uses: actions/download-artifact@9c19ed7fe5d278cd354c7dfd5d3b88589c7e2395 # v4.1.6 + with: + pattern: '*-coverage' + - name: Upload to Codecov + uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # v4.5.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: backend diff --git a/.github/workflows/test-js.yml b/.github/workflows/test-js.yml index 9d635237370c..15b4fd05cee5 100644 --- a/.github/workflows/test-js.yml +++ b/.github/workflows/test-js.yml @@ -69,8 +69,9 @@ jobs: yarn test:ci - name: Upload to Codecov - uses: codecov/codecov-action@d9f34f8cd5cb3b3eb79b3e4b5dae3a16df499a70 + uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # v4.5.0 with: + token: ${{ secrets.CODECOV_TOKEN }} flags: frontend lint-js: diff --git a/.github/workflows/test-native-tooling-packaging.yml b/.github/workflows/test-native-tooling-packaging.yml index 7678e7eeaa0e..ff0dc4abadf6 100644 --- a/.github/workflows/test-native-tooling-packaging.yml +++ b/.github/workflows/test-native-tooling-packaging.yml @@ -41,7 +41,6 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - go-version: ['${{ vars.GO_VERSION }}'] runs-on: ${{ matrix.os }} steps: @@ -50,13 +49,13 @@ jobs: with: egress-policy: audit + - name: Checkout Code + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - name: Install Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ matrix.go-version }} - - - name: Checkout Code - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + go-version-file: 'go.mod' - name: Install Go Dependencies run: make deps-go diff --git a/.github/workflows/test-packaging.yml b/.github/workflows/test-packaging.yml index f9643bd4e9b4..dbe5a96244ea 100644 --- a/.github/workflows/test-packaging.yml +++ b/.github/workflows/test-packaging.yml @@ -47,7 +47,6 @@ jobs: # `macos-latest` uses arm64 by default now, so please be careful when # updating this version. os: [ubuntu-latest, macos-13] - go-version: ['${{ vars.GO_VERSION }}'] runs-on: ${{ matrix.os }} steps: @@ -83,13 +82,13 @@ jobs: brew install colima colima start --mount $TMPDIR:w + - name: Checkout Code + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - name: Install Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ matrix.go-version }} - - - name: Checkout Code - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + go-version-file: 'go.mod' - name: Install wine and wix if: startsWith(matrix.os, 'macos') diff --git a/.github/workflows/test-yml-specs.yml b/.github/workflows/test-yml-specs.yml index 75e46d6af046..fe8f3ecace97 100644 --- a/.github/workflows/test-yml-specs.yml +++ b/.github/workflows/test-yml-specs.yml @@ -33,7 +33,6 @@ jobs: strategy: matrix: os: [ubuntu-latest] - go-version: ['${{ vars.GO_VERSION }}'] runs-on: ${{ matrix.os }} steps: @@ -42,13 +41,13 @@ jobs: with: egress-policy: audit + - name: Checkout Code + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - name: Install Go uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 with: - go-version: ${{ matrix.go-version }} - - - name: Checkout Code - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + go-version-file: 'go.mod' - name: Run apply spec tests run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ff505690d63..24d768a609dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,11 @@ -## Fleet 4.55.1 (Aug 15, 2024) +## Fleet 4.55.2 (Sep 05, 2024) + +### Bug fixes + +* Removed validation of APNS certificate from server startup. This was no longer necessary because we now allow for APNS certificates to be renewed in the UI. +* Fixed logic to properly catch and log APNs errors. + +## Fleet 4.55.1 (Aug 14, 2024) ### Bug fixes @@ -13,16 +20,11 @@ * Added index to query_results DB table to speed up finding the last query timestamp for a given query and host. * Displayed the label names in case-insensitive alphabetical order in the fleet UI. -## Fleet 4.55.0 (Aug 9, 2024) +## Fleet 4.55.0 (Aug 8, 2024) **NOTE:** Beginning with v4.55.0, Fleet no longer supports MySQL 5.7 because it has reached [end of life](https://mattermost.com/blog/mysql-5-7-reached-eol-upgrade-to-mysql-8-x-today/#:~:text=In%20October%202023%2C%20MySQL%205.7,to%20upgrade%20to%20MySQL%208.). The minimum version supported is MySQL 8.0.36. -**NOTE:** Changes to software field in [GitOps](https://fleetdm.com/docs/using-fleet/gitops): -- `software` field is optional for TEAMs in 4.54.1 and lower -- `software` field should NOT be added to NO-TEAM before 4.55.0 -- `software` field is mandatory for NO-TEAM and TEAMs in 4.55.0 and up - -### Endpoint operations +### Endpoint Operations - Added support for generating `fleetd` packages for Linux ARM64. - Added new `fleetctl package` --arch flag. @@ -32,7 +34,7 @@ - Fleet server watches for potential changes for up to 1 week after original event time. If event is moved forward more than 1 week, then after 1 week Fleet server will check for event changes once every 30 minutes. - **NOTE:** These near real-time updates may add additional load to the Google Calendar API, so it is recommended to use API usage alerts or other monitoring methods. -### Device management +### Device Management - Integrated [Escrow Buddy](https://github.com/macadmins/escrow-buddy) to add enforcement of FileVault during the MacOS Setup Assistant process for hosts that are enrolled into teams (or no team) with disk encryption turned on. Thank you [homebysix](https://github.com/homebysix) and team! @@ -63,7 +65,7 @@ enrolled into teams (or no team) with disk encryption turned on. Thank you [home - Added a special-case to properly name the Notion .exe Windows installer the same as how it will be reported by osquery post-install. - Increased threshold to renew Apple SCEP certificates for MDM enrollments to 180 days. -### Vulnerability management +### Vulnerability Management - Fixed CVEs identified as 'Rejected' in NVD not matching against software. - Fixed false negative vulnerabilities with IntelliJ IDEA CE and PyCharm CE installed via Homebrew. @@ -93,13 +95,13 @@ enrolled into teams (or no team) with disk encryption turned on. Thank you [home ### Bug fixes -* Fixed a startup bug by performing an early restart of orbit if an agent options setting has changed. -* Implemented a small refactor of orbit subsystems. -* Removed the `--version` flag from the `fleetctl package` command. The version of the package can now be controlled by the `--orbit-channel` flag. -* Fixed a bug that set `last_enrolled_at` during orbit re-enrollment, which caused osquery enroll failures when `FLEET_OSQUERY_ENROLL_COOLDOWN` is set . -* In `fleetctl package` command, removed the `--version` flag. The version of the package can be controlled by `--orbit-channel` flag. -* Fixed a bug where Fleet google calendar events generated by Fleet <= 4.53.0 were not correctly processed by 4.54.0. -* Re-enabled cached logins after windows Unlock. +- Fixed a startup bug by performing an early restart of orbit if an agent options setting has changed. +- Implemented a small refactor of orbit subsystems. +- Removed the `--version` flag from the `fleetctl package` command. The version of the package can now be controlled by the `--orbit-channel` flag. +- Fixed a bug that set `last_enrolled_at` during orbit re-enrollment, which caused osquery enroll failures when `FLEET_OSQUERY_ENROLL_COOLDOWN` is set . +- In `fleetctl package` command, removed the `--version` flag. The version of the package can be controlled by `--orbit-channel` flag. +- Fixed a bug where Fleet google calendar events generated by Fleet <= 4.53.0 were not correctly processed by 4.54.0. +- Re-enabled cached logins after windows Unlock. ## Fleet 4.54.0 (Jul 17, 2024) @@ -186,19 +188,19 @@ enrolled into teams (or no team) with disk encryption turned on. Thank you [home ### Bug fixes -* Updated fleetctl get queries/labels/hosts descriptions. -* Fixed exporting CSVs with fields that contain commas to render properly. -* Fixed link to fleetd uninstall instructions in "Delete device" modal. -* Rendered only one banner on the my device page based on priority order. -* Hidden query delete checkboxes from team observers. -* Fixed issue where the Fleet UI could not be used to renew the ABM token after the ABM user who created the token was deleted. -* Fixed an issue where special characters in HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall broke the "installer_utils.ps1 -uninstallOrbit" step in the Windows MSI installer. -* Fixed counts for hosts with low disk space in summary page. -* Fleet UI fixes: Hide CTA on inherited queries/policies from team level users. -* Updated software updated timestamp tooltip. -* Fixed issue where some Windows applications were getting matched against Windows OS vulnerabilities. -* Fixed crash in `fleetd` installer on Windows if there are registry keys with special characters on the system. -* Fixed UI capitalizations. +- Updated fleetctl get queries/labels/hosts descriptions. +- Fixed exporting CSVs with fields that contain commas to render properly. +- Fixed link to fleetd uninstall instructions in "Delete device" modal. +- Rendered only one banner on the my device page based on priority order. +- Hidden query delete checkboxes from team observers. +- Fixed issue where the Fleet UI could not be used to renew the ABM token after the ABM user who created the token was deleted. +- Fixed an issue where special characters in HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall broke the "installer_utils.ps1 -uninstallOrbit" step in the Windows MSI installer. +- Fixed counts for hosts with low disk space in summary page. +- Fleet UI fixes: Hide CTA on inherited queries/policies from team level users. +- Updated software updated timestamp tooltip. +- Fixed issue where some Windows applications were getting matched against Windows OS vulnerabilities. +- Fixed crash in `fleetd` installer on Windows if there are registry keys with special characters on the system. +- Fixed UI capitalizations. ## Fleet 4.53.0 (Jun 25, 2024) @@ -756,7 +758,6 @@ enrolled into teams (or no team) with disk encryption turned on. Thank you [home * Fixed an issue where software from a Parallels VM on a MacOS host would show up in Fleet as if it were the host's software. * Removed unnecessary nested database transactions in batch-setting of MDM profiles. * Added count of upcoming activities to host vitals UI. -* Fixed a bug where the manage query automations modal would lose its state when the user clicks "Preview data". ## Fleet 4.44.0 (Jan 31, 2024) diff --git a/Dockerfile-desktop-linux b/Dockerfile-desktop-linux index c1bd17570d96..c17d3894d06e 100644 --- a/Dockerfile-desktop-linux +++ b/Dockerfile-desktop-linux @@ -1,4 +1,4 @@ -FROM --platform=linux/amd64 golang:1.22.6-bullseye@sha256:37f09f0c199a07c2e72ae2cfd758681fae0681240c71d6fad42d9d090c437c38 +FROM --platform=linux/amd64 golang:1.23.1-bullseye@sha256:45b43371f21ec51276118e6806a22cbb0bca087ddd54c491fdc7149be01035d5 LABEL maintainer="Fleet Developers" RUN mkdir -p /usr/src/fleet diff --git a/articles/discovering-chrome-ai-using-fleet.md b/articles/discovering-chrome-ai-using-fleet.md new file mode 100644 index 000000000000..72b73627d608 --- /dev/null +++ b/articles/discovering-chrome-ai-using-fleet.md @@ -0,0 +1,67 @@ +# Discovering Chrome AI using Fleet + +![Discovering Chrome AI using Fleet](../website/assets/images/articles/discovering-chrome-ai-using-fleet-1600x900@2x.jpg) + +# Discovering AI in Chrome with Fleet + +Staying ahead of technological innovations is crucial for individuals and organizations. Google Chrome, one of the most widely used web browsers, continually evolves to incorporate new features, including artificial intelligence (AI). This article will guide you through detecting if AI capabilities have been enabled in Chrome using Fleet. + +## Introduction to Chrome AI innovations + +Google Chrome has integrated AI to enhance user experience by providing intelligent suggestions, improving search results, and offering in-browser assistance. Visit the [Chrome AI Innovations page](https://www.google.com/chrome/ai-innovations/) for more infomration. + +## Using Fleet to detect AI features in Chrome + +Fleet, a comprehensive device management and security tool, allows you to monitor various aspects of your devices, including software configurations and enabled features. Using Fleet, you can detect whether AI features are enabled in Chrome by querying device settings, specifically in the Chrome "Preferences" JSON file. + +### Step 1: Understanding Chrome's preferences JSON file + +Chrome stores user settings and configurations in a JSON file at the following path: + +``` +/Users/<user>/Library/Application Support/Google/Chrome/Default/Preferences +``` + +### Step 2: Identifying AI-related settings + +AI-related features are stored in the `optimization_guide` section of the preferences. The `tab_organization_setting_state` field will tell you if AI-based tab management features are enabled: + +`> jq` is a lightweight and powerful command-line tool for parsing, filtering, and manipulating JSON data. It allows you to extract specific information from JSON files efficiently. In this case, we use `jq` to locate and read the value of the `tab_organization_setting_state` key within Chrome's preference file which will help us understand how to craft our Fleet query for reporting the state of this setting. + +- If enabled, the setting will return `1`. + +![Chrome settings UI with Chrome AI enabled](../website/assets/images/articles/discovering-chrome-ai-using-fleet-1-1472x370@2x.png) + +``` +% jq '.optimization_guide.tab_organization_setting_state' /Users/<user>/Library/Application\ Support/Google/Chrome/Default/Preferences +1 +``` + +- If disabled, the setting will return `2`. + +![Chrome settings UI with Chrome AI disabled](../website/assets/images/articles/discovering-chrome-ai-using-fleet-2-1474x276@2x.png) + +``` +% jq '.optimization_guide.tab_organization_setting_state' /Users/<user>/Library/Application\ Support/Google/Chrome/Default/Preferences +2 +``` + +### Step 3: Query the JSON file with Fleet + +To query the JSON file and detect AI features using Fleet, you can use the following SQL query: + +``` +SELECT fullkey,path FROM parse_json WHERE path LIKE '/Users/%/Library/Application Support/Google/Chrome/Default/Preferences' AND fullkey='optimization_guide/tab_organization_setting_state'; +``` + +### Conclusion + +Following this guide, you've learned to detect whether AI features are enabled in Google Chrome using Fleet. Fleet's powerful querying abilities allow you to monitor these features across multiple devices, ensuring your organization's preferences and practices align. + +<meta name="articleTitle" value="Discovering Chrome AI using Fleet"> +<meta name="authorFullName" value="Brock Walters"> +<meta name="authorGitHubUsername" value="nonpunctual"> +<meta name="category" value="guides"> +<meta name="publishedOn" value="2024-09-06"> +<meta name="articleImageUrl" value="../website/assets/images/articles/discovering-chrome-ai-using-fleet-1600x900@2x.jpg"> +<meta name="description" value="Use Fleet to detect and monitor settings enabled in Google Chrome by querying Chrome's preferences JSON file."> diff --git a/articles/enforce-os-updates.md b/articles/enforce-os-updates.md index 4b1b0cbe99f1..3db4862a8768 100644 --- a/articles/enforce-os-updates.md +++ b/articles/enforce-os-updates.md @@ -22,6 +22,10 @@ When a minimum version is enforced, the end users see a native macOS notificatio If the host was turned off when the deadline passed, the update will be scheduled an hour after it’s turned on. +For macOS devices that use Automated Device Enrollment (ADE), if the device is below the specified +minimum version, it will be required to update to the very latest OS version during ADE before +device setup and enrollment can proceed. + ### macOS (below version 14.0) End users are encouraged to update macOS (via [Nudge](https://github.com/macadmins/nudge)). @@ -34,6 +38,11 @@ End users are encouraged to update macOS (via [Nudge](https://github.com/macadmi | End user can defer | ✅ | ✅ | ❌ | | Nudge window is dismissible | ✅ | ✅ | ❌ | +### iOS and iPadOS (version 17.0 and above) + +For iOS and iPadOS devices that use Automated Device Enrollment (ADE), if the device is below the specified +minimum version, it will be required to update to the very latest OS version during ADE before device setup and enrollment can proceed. + ### Windows End users are encouraged to update Windows via the native Windows dialog. diff --git a/articles/filtering-software-by-vulnerability.md b/articles/filtering-software-by-vulnerability.md new file mode 100644 index 000000000000..8c8326bbc690 --- /dev/null +++ b/articles/filtering-software-by-vulnerability.md @@ -0,0 +1,44 @@ +# Filtering software by vulnerability in Fleet + +![Filtering software by vulnerability in Fleet](../website/assets/images/articles/discovering-geacon-using-fleet-1600x900@2x.jpg) + +## Introduction + +Fleet has introduced a powerful new feature that allows you to filter software by its associated vulnerabilities, helping you prioritize patches more effectively. Whether you're managing hundreds or thousands of software titles, this feature makes it easier to identify and address the most critical vulnerabilities in your environment. + +This filtering capability is particularly useful in environments where patch management is critical to your security posture. By filtering software based on vulnerability severity and known exploits, you can first ensure that the most critical issues are addressed, enhancing your overall security strategy. + +## Prerequisites + +* Fleet version 4.56 or later +* Premium users have access to advanced filters by severity level and known exploited vulnerabilities + +### Filtering Software by Vulnerability + +1. **Navigate to the Software page**: In your Fleet dashboard, go to the **Software** tab. This will display a list of all the software detected in your environment. + +2. **Add filters**: Click on the **Add Filters** button. This will open options for filtering the software list based on specific criteria. + +3. **Choose severity level**: From the dropdown menu, select the **Severity level** of vulnerabilities you're interested in. This allows you to focus on software with the highest severity of vulnerabilities, such as "Critical" or "High." + +4. **Toggle "Has known exploit"**: You can refine your filter by toggling the **Has known exploit** option. This will filter the software list to show only those with vulnerabilities that have known exploits, enabling you to prioritize these for patching. + +5. **Review filtered results**: Once you've applied your filters, the software list will update to show only the software that meets your criteria. This filtered view will help you prioritize which software needs immediate attention in your patching strategy. + +### Using the REST API to filter software for vulnerabilities + +Fleet provides a REST API to filter software for vulnerabilities, allowing you to integrate this functionality into your automated workflows. Learn more about Fleet's [REST API](https://fleetdm.com/docs/rest-api/rest-api#vulnerabilities). + +## Conclusion + +The new software filtering feature in Fleet makes it easier than ever to manage vulnerabilities in your software environment. You can better protect your organization from potential threats by prioritizing patches based on severity and known exploits. Explore the API capabilities to integrate this feature into your broader security workflows. + +For more tips and detailed guides, don’t forget to check out the Fleet [documentation](https://fleetdm.com/docs/get-started/why-fleet). + +<meta name="articleTitle" value="Filtering software by vulnerability in Fleet"> +<meta name="authorFullName" value="Tim Lee"> +<meta name="authorGitHubUsername" value="mostlikelee"> +<meta name="category" value="guides"> +<meta name="publishedOn" value="2024-08-30"> +<meta name="articleImageUrl" value="../website/assets/images/articles/discovering-geacon-using-fleet-1600x900@2x.jpg"> +<meta name="description" value="Filter software by vulnerability in Fleet to prioritize critical patches and enhance your organization's security posture."> diff --git a/articles/fleet-4.56.0.md b/articles/fleet-4.56.0.md new file mode 100644 index 000000000000..158d78746851 --- /dev/null +++ b/articles/fleet-4.56.0.md @@ -0,0 +1,153 @@ +# Fleet 4.56.0 | Enhanced MDM migration, Exact CVE Search, and Self-Service VPP Apps. + +![Fleet 4.56.0](../website/assets/images/articles/fleet-4.56.0-1600x900@2x.png) + +Fleet 4.56.0 is live. Check out the full [changelog](https://github.com/fleetdm/fleet/releases/tag/fleet-v4.56.0) or continue reading to get the highlights. +For upgrade instructions, see our [upgrade guide](https://fleetdm.com/docs/deploying/upgrading-fleet) in the Fleet docs. + +## Highlights +* Improved end-user MDM migration +* Enforce minimum OS version for MDM enrollment +* Exact match CVE search +* Software vulnerabilities severity filter +* Self-service VPP apps +* Multiple ABM and VPP support + + +### Improved end-user MDM migration + +Fleet has improved the end-user MDM migration workflow on macOS by enabling the migration of hosts manually enrolled in a third-party MDM over to Fleet MDM using the Fleet Desktop application. Previously, this capability was limited to hosts enrolled through Apple's Automated Device Enrollment (ADE), but with this update, manually enrolled hosts can now be seamlessly migrated to Fleet MDM. This feature is specifically available for macOS Sonoma devices (macOS 14 or greater). It makes the migration process more flexible and accessible for organizations looking to centralize their MDM management under Fleet. This enhancement simplifies the transition to Fleet MDM for a broader range of macOS devices, ensuring that all hosts can be managed consistently and securely. + + +### Enforce minimum OS version for MDM enrollment + +Fleet now enforces a minimum operating system (OS) requirement for macOS devices before they can be enrolled into Fleet's MDM. This feature ensures that only devices running a specified minimum macOS version can be enrolled, helping organizations maintain a consistent security and compliance baseline across their fleet. By setting a minimum OS requirement, Fleet prevents older, potentially less secure macOS versions from being managed under its MDM, thereby reducing vulnerabilities and ensuring all enrolled devices meet the organization's standards. This update enhances Fleet's ability to enforce security policies from the outset, ensuring that all devices in the fleet are up-to-date and capable of supporting the latest security and management features. + + +### Exact match CVE search + +Fleet has enhanced its CVE (Common Vulnerabilities and Exposures) search functionality by introducing exact match searching, allowing users to quickly and accurately find specific vulnerabilities across their fleet. This improvement ensures that security teams can pinpoint the exact CVE they are investigating without sifting through irrelevant results, streamlining the vulnerability management process. Additionally, Fleet provides better context in cases where no results are found, helping users understand why a particular CVE might not be present in their environment. This update improves the overall user experience in vulnerability management, making it easier to maintain security and compliance across all managed devices. + + +### Software vulnerabilities severity filter + +Fleet has introduced improved filtering capabilities for vulnerable software, allowing users to filter vulnerabilities by severity level. This enhancement enables security teams to prioritize their response efforts by focusing on the most critical vulnerabilities, ensuring that the highest-risk issues are promptly addressed. By providing a straightforward and efficient way to filter vulnerable software based on severity, Fleet helps organizations streamline their vulnerability management processes, reducing the risk of security incidents. This update aligns with Fleet's commitment to providing powerful tools that enhance the efficiency and effectiveness of security operations across all managed devices. + + +### Self-Service Apple App Store apps + +Fleet enables organizations to assign and install Apple App Store apps purchased through the Volume Purchase Program (VPP) directly via Self-Service using Fleet Desktop. This new feature allows IT administrators to make VPP-purchased apps available to end users seamlessly and flexibly. By integrating VPP app distribution into the Fleet Desktop Self-Service portal, organizations can streamline the deployment of essential software across their macOS devices, ensuring that users have easy access to the tools they need while maintaining control over software distribution. This update enhances the overall user experience and operational efficiency, empowering end users to install approved applications with minimal IT intervention. + + +### Multiple Apple Business Manager and VPP support + +Fleet now enables administrators to add and manage multiple Apple Business Manager (ABM) and Volume Purchase Program (VPP) tokens within a single Fleet instance. This feature is designed for both Managed Service Providers (MSPs) and large enterprises, allowing them to create separate automatic enrollment and App Store app workflows for different clients or divisions, each with their own ABM and VPP tokens. Whether you’re managing devices for multiple customers or supporting large organizations with distinct divisions, this update simplifies the process of handling macOS, iOS, and iPadOS devices. With support for multiple ABM and VPP connections, Fleet streamlines software and device management across varied environments, providing a scalable solution for both MSPs and enterprises looking to centralize control while maintaining flexibility for different user groups. + + +## Changes + +**NOTE:** Beginning with Fleet v4.55.0, Fleet no longer supports MySQL 5.7 because it has reached [end of life](https://mattermost.com/blog/mysql-5-7-reached-eol-upgrade-to-mysql-8-x-today/#:~:text=In%20October%202023%2C%20MySQL%205.7,to%20upgrade%20to%20MySQL%208.). The minimum version supported is MySQL 8.0. + +## Fleet 4.56.0 (Sep 7, 2024) + +### Endpoint operations + +- Added index to `query_results` DB table to speed up finding last query timestamp for a given query and host. +- Added a link in the UI to the error message when a CSR can't be downloaded due to missing private key. +- Added a disabled overlay to the Other Workflows modal on the policy page. +- Improved performance of live queries to accommodate for higher volumes when utilizing zero-trust workflows. +- Improved `fleetctl` gitops error message when trying to change team name to a team that already exists. + +### Device management + +- Added server support for multiple VPP tokens. +- Added new endpoints and updated existing endpoints for managing multiple Apple Business Manager tokens. +- Added support for S3 to store MDM bootstrap packages (uses the same bucket configuration as for software installers). +- Added support to UI for self service VPP software. +- Added backend and gitops support for self service VPP. +- Added ability for MDM migrations if the host is manually enrolled to a 3rd party MDM. +- Added an offline screen to the macOS MDM migration flow. +- Added new ABM page to Fleet UI. +- Added new VPP page to the fleet UI +- Added support to track the Apple Business Manager "terms expired" API error per token, as well as a global flag that gets set as soon as one token has its terms expired. +- Updated the instructions on "My device" for MDM migrations on pre-Sonoma macOS hosts. +- Updated to allow multiple teams to be assigned to the same VPP Token. +- Updated process so that deleting installed software or VPP app now makes it available for re-installation. +- Updated to enforce minimum OS version settings during Apple Automated Device Enrollment (ADE). +- Updated ABM ingestion so that deleted iOS/iPadOS host will continue to report to Fleet as long as host is in Apple Business Manager (ABM). +- Updated so that refetching an offline iOS/iPadOS host will not add new MDM commands to the queue if previous refetch has not completed yet. +- Updated UI so that downloading a software installer package now shows the browser's built-in progress bar. +- Updated relevant documentation to include references to multiple ABM and VPP tokens. +- Consolidated Automatic Enrollment and VPP settings under the MDM settings integration page. +- Cleared apps associated with a VPP token if it's moved off of a team. + +### Vulnerability management + +- Added ALAS bulletins as vulnerability source for Amazon Linux (instead of OVAL for Amazon Linux 2, and adds support for Amazon Linux 1, 2022, and 2023). +- Added matching rules for July and August Microsoft 365 security updates (https://learn.microsoft.com/en-us/officeupdates/microsoft365-apps-security-updates). +- Added the following filters to `/software/titles` and `/software/versions` API endpoints: `exploit: bool`, `min_cvss_score: float`, `max_cvss_score: float`. +- Updated software titles/versions tables to allow for filtering by vulnerabilities including severity and known exploit. +- Updated to use empty CVE description when the NVD CVE feed doesn't include description entries (instead of panicking). +- Updated matching software that is not installed by Fleet so that it shows up as 'Available for install' on host details page. +- Updated base images of `fleetdm/fleetctl`, `fleetdm/bomutils` and `fleetdm/wix` to fix critical vulnerabilities found by Trivy. +- Updated vulnerability scanning to use `macos` SW target for CPEs of homebrew packages. +- Updated vulnerability scanning to not ignore software with non-ASCII en dash and em dash characters. +- Updated `GET /api/v1/fleet/vulnerabilities/{cve}` endpoint to add validation of CVE format, and a 204 response. The 204 response indicates that the vulnerability is known to Fleet but not present on any hosts. +- Updated the UI to add new empty states for searching vulnerabilities: invalid CVE format searched, a known CVE serached but not present on hosts, not a known CVE searched, exploited vulnerability empty state, operating systems empty state, new icons. + +### Bug fixes and improvements + +- Added support for MySQL 8.4.2 LTS. +- Updated Go to go1.22.6. +- Updated Fleet server to now accept arguments via stdin. This is useful for passing secrets that you don't want to expose as env vars, in the command line, or in the config file. +- Updated text for "Turn on MDM" banners in UI. +- Updated ABM host tooltip copy on the manage host page to clarify when host vitals will be available to view. +- Updated copy on auotmatic enrollment modal on my device page. +- Updated host details activities tooltip and empty state copy to reflect recently added capabilities. +- Updated Fleet Free so users see a Premium feature message when clicking to add software. +- Updated usage reporting to report statistics on new AI features, maintenance window, and `fleetd`. +- Fixed bug where configuration profile was still showing the old label name after the name was updated. +- Fixed a bug when a cached prepared statement gets deleted in the MySQL server itself without Fleet knowing. +- Fixed a bug where the wrong API path was used to download a software installer. +- Fixed the failing_host_count so it is never 0. This count is normally updated once an hour during cleanups_then_aggregation cron job. +- Fixed CVE-2024-4030 in Vulncheck feed incorrectly targeting non-Windows hosts. +- Fixed a bug where the "Self-service" filter for the list of software and the list of host's software did not take App Store apps into account. +- Fixed a bug where the "My device" page in Fleet Desktop did not show the self-service software tab when App Store apps were available as self-install. +- Fixed a bug where a software installer (a package or a VPP app) that has been installed on a host still shows up as "Available for install" and can still be requested to be installed after the host is transferred to a different team without that installer (or after the installer is deleted). +- Fixed the "Available for install" filter in the host's software page so that installers that were requested to be installed on the host (regardless of installation status) also show up in the list. +- Fixed UI popup messages bleeding off viewport in some cases. +- Fixed an issue with the scheduling of cron jobs at startup if the job has never run, which caused it to be delayed. +- Fixed UI to display the label names in case-insensitive alphabetical order. + +## Fleet 4.55.2 (Sep 05, 2024) + +### Bug fixes + +* Removed validation of APNS certificate from server startup. This was no longer necessary because we now allow for APNS certificates to be renewed in the UI. +* Fixed logic to properly catch and log APNs errors. + +## Fleet 4.55.1 (Aug 14, 2024) + +### Bug fixes + +* Added a disabled overlay to the Other Workflows modal on the policy page. +* Updated text for "Turn on MDM" banners in UI. +* Fixed a bug when a cached prepared statement got deleted in the MySQL server itself without Fleet knowing. +* Continued with an empty CVE description when the NVD CVE feed didn't include description entries (instead of panicking). +* Scheduled maintenance events are now scheduled over calendar events marked "Free" (not busy) in Google Calendar. +* Fixed a bug where the wrong API path was used to download a software installer. +* Improved fleetctl gitops error message when trying to change team name to a team that already exists. +* Updated ABM (Apple Business Manager) host tooltip copy on the manage host page to clarify when host vitals will be available to view. +* Added index to query_results DB table to speed up finding the last query timestamp for a given query and host. +* Displayed the label names in case-insensitive alphabetical order in the fleet UI. + +## Ready to upgrade? + +Visit our [Upgrade guide](https://fleetdm.com/docs/deploying/upgrading-fleet) in the Fleet docs for instructions on updating to Fleet 4.56.0. + +<meta name="category" value="releases"> +<meta name="authorFullName" value="JD Strong"> +<meta name="authorGitHubUsername" value="spokanemac"> +<meta name="publishedOn" value="2024-09-07"> +<meta name="articleTitle" value="Fleet 4.56.0 | Enhanced MDM migration, Exact CVE Search, and Self-Service VPP Apps."> +<meta name="articleImageUrl" value="../website/assets/images/articles/fleet-4.56.0-1600x900@2x.png"> diff --git a/articles/fleet-usage-statistics.md b/articles/fleet-usage-statistics.md index 3fc5eeb8f146..1db24222dee1 100644 --- a/articles/fleet-usage-statistics.md +++ b/articles/fleet-usage-statistics.md @@ -36,6 +36,11 @@ Below is the JSON payload that is sent to Fleet Device Management Inc: "numHostSoftwareInstalledPaths": 999, "numSoftwareCPEs": 999, "numSoftwareCVEs": 999, + "numHostsNotResponding": 9, + "aiFeaturesDisabled": true, + "maintenanceWindowsEnabled": true, + "maintenanceWindowsConfigured": true, + "numHostsFleetDesktopEnabled": 999, "hostsEnrolledByOperatingSystem": { "darwin": [ { @@ -103,12 +108,7 @@ Below is the JSON payload that is sent to Fleet Device Management Inc: ] }, ... - ], - "numHostsNotResponding": 9, - "aiFeaturesDisabled": true, - "maintenanceWindowsEnabled": true, - "maintenanceWindowsConfigured": true, - "numHostsFleetDesktopEnabled": 999 + ] } ``` diff --git a/articles/fleetctl.md b/articles/fleetctl.md index 453c1f997f60..caa234f845a8 100644 --- a/articles/fleetctl.md +++ b/articles/fleetctl.md @@ -32,6 +32,8 @@ npm install -g fleetctl@latest Much of the functionality available in the Fleet UI is also available in `fleetctl`. You can run queries, add and remove users, generate Fleet's agent (fleetd) to add new hosts, get information about existing hosts, and more! +> Note: Unless a logging infrastructure is configured on your Fleet server, osquery-related logs will be stored locally on each device. Read more [here](https://fleetdm.com/guides/log-destinations) + To see the available commands you can run: ```sh diff --git a/articles/macos-mdm-setup.md b/articles/macos-mdm-setup.md index 7dab8b7de950..bc91ee6a7206 100644 --- a/articles/macos-mdm-setup.md +++ b/articles/macos-mdm-setup.md @@ -4,7 +4,7 @@ To turn on macOS, iOS, and iPadOS MDM features, follow the instructions on this To use automatic enrollment (aka zero-touch) features on macOS, iOS, and iPadOS, follow instructions to connect Fleet with Apple Business Manager (ABM). -To turn on Windows MDM features, head to this [Windows MDM setup article](https://fleetdm.com/guides/windows-mdm-setup). +To turn on Windows MDM features, head to this [Windows MDM setup article](https://fleetdm.com/guides/windows-mdm-setup). ## Apple Push Notification service (APNs) @@ -31,7 +31,7 @@ banner at the top of page reminding you to renew your token. To renew an ABM token: 1. Navigate to the **Settings > Integrations > Mobile device management (MDM)** page. -2. Under "Automatic enrollment", click "Edit", and then fin +2. Under "Automatic enrollment", click "Edit", and then find the token that you want to renew. Token status is indicated in the "Renew date" column: tokens less than 30 days from expiring will have a yellow indicator, and expired tokens will have a red indicator. Click the "Actions" dropdown for the token and then click "Renew". Follow the instructions in the modal to download a new token from Apple Business Manager and then upload the new token to Fleet. After connecting Fleet to ABM, set Fleet to be the MDM for all Macs: @@ -53,6 +53,10 @@ If no default team is set for a host platform (macOS, iOS, or iPadOS), then newl > A host can be transferred to a new (not default) team before it enrolls. In the Fleet UI, you can do this under **Settings** > **Teams**. +### Simple Certificate Enrollment Protocol (SCEP) + +Fleet uses SCEP certificates (1 year expiry) to authenticate the requests hosts make to Fleet. Fleet renews each host's SCEP certificates automatically every 180 days. + <meta name="category" value="guides"> <meta name="authorGitHubUsername" value="zhumo"> <meta name="authorFullName" value="Mo Zhu"> diff --git a/articles/mdm-migration.md b/articles/mdm-migration.md index 76c6254125d3..5eb9e8473d63 100644 --- a/articles/mdm-migration.md +++ b/articles/mdm-migration.md @@ -1,11 +1,15 @@ # MDM migration -This section provides instructions for migrating your hosts away from your old MDM solution to Fleet. +This guide provides instructions for migrating devices from your current MDM solution to Fleet. + +> For seamless MDM migration, [view this guide](https://fleetdm.com/guides/seamless-mdm-migration). ## Requirements -1. A [deployed Fleet instance](https://fleetdm.com/docs/deploy/deploy-fleet) -2. [Fleet connected to Apple](https://fleetdm.com/guides/macos-mdm-setup) + +- A [deployed Fleet instance](https://fleetdm.com/docs/deploy/deploy-fleet) +- Fleet is connected to Apple Push Notification service (APNs) and Apple Business Manager (ABM). [See macOS MDM setup](https://fleetdm.com/guides/macos-mdm-setup) + ## Migrate manually enrolled hosts diff --git a/articles/queries.md b/articles/queries.md index 7eb5946d309b..0379bca00434 100644 --- a/articles/queries.md +++ b/articles/queries.md @@ -2,6 +2,8 @@ Queries in Fleet allow you to ask questions to help you manage, monitor, and identify threats on your devices. This guide will walk you through how to create, schedule, and run a query. +> Note: Unless a logging infrastructure is configured on your Fleet server, osquery-related logs will be stored locally on each device. Read more [here](https://fleetdm.com/guides/log-destinations) + > New users may find it helpful to start with Fleet's policies. You can find policies and queries from the community in Fleet's [query library](https://fleetdm.com/queries). To learn more about policies, see [What are Fleet policies?](https://fleetdm.com/securing/what-are-fleet-policies) and [Understanding the intricacies of Fleet policies](https://fleetdm.com/guides/understanding-the-intricacies-of-fleet-policies). ### In this guide: diff --git a/articles/teams.md b/articles/teams.md index 0f4444aff24a..ea688bc08b0c 100644 --- a/articles/teams.md +++ b/articles/teams.md @@ -2,7 +2,7 @@ _Available in Fleet Premium_ -In Fleet, you can group hosts together in a "team" in Fleet. This way, you can apply queries, policies, scripts, and more that are tailored to the hosts' risk/compliance needs. +In Fleet, you can group hosts together in a "team" in Fleet. This way, you can apply queries, policies, scripts, and more that are tailored to a host's risk/compliance needs. A host can only belong to one team. @@ -30,7 +30,7 @@ You can add hosts to a new team in Fleet by either enrolling the host with a tea ## Advanced -You can automatically enroll hosts to a specific team in Fleet by installing a fleetd with a team enroll secret. Learn more [here](./enroll-hosts.md#enroll-host-to-a-specific-team). +You can automatically enroll hosts to a specific team in Fleet by installing a fleetd with a team enroll secret. Learn more [here](https://fleetdm.com/guides/enroll-hosts#enroll-host-to-a-specific-team). Changing the host's enroll secret after enrollment will not cause the host to be transferred to a different team. diff --git a/assets/images/iPadOS-install-profile.png b/assets/images/iPadOS-install-profile.png new file mode 100644 index 000000000000..c398038517f7 Binary files /dev/null and b/assets/images/iPadOS-install-profile.png differ diff --git a/assets/images/iPadOS-profile-downloaded.png b/assets/images/iPadOS-profile-downloaded.png new file mode 100644 index 000000000000..f21b12615f70 Binary files /dev/null and b/assets/images/iPadOS-profile-downloaded.png differ diff --git a/assets/images/ios-install-profile.png b/assets/images/ios-install-profile.png new file mode 100644 index 000000000000..8266c55201eb Binary files /dev/null and b/assets/images/ios-install-profile.png differ diff --git a/assets/images/ios-profile-downloaded.png b/assets/images/ios-profile-downloaded.png new file mode 100644 index 000000000000..7941a6581557 Binary files /dev/null and b/assets/images/ios-profile-downloaded.png differ diff --git a/changes/18897-shoe-zeroes b/changes/18897-shoe-zeroes new file mode 100644 index 000000000000..7faddd522dd6 --- /dev/null +++ b/changes/18897-shoe-zeroes @@ -0,0 +1 @@ +Added "0 items" description on empty software tables for UI consistency diff --git a/changes/19551-policy-software-automations b/changes/19551-policy-software-automations new file mode 100644 index 000000000000..4b88cb4c1fba --- /dev/null +++ b/changes/19551-policy-software-automations @@ -0,0 +1 @@ +* Implement features allowing automatic installation of software on hosts that fail policies. diff --git a/changes/20757-profiles-batch-activity b/changes/20757-profiles-batch-activity new file mode 100644 index 000000000000..6b110b87c768 --- /dev/null +++ b/changes/20757-profiles-batch-activity @@ -0,0 +1 @@ +API endpoint `/api/v1/fleet/mdm/profiles/batch` will now not log an activity for profile types that did not change in the database (Apple configuration profiles, Windows configuration profiles, or Apple declarations). diff --git a/changes/20828-better-appid-error b/changes/20828-better-appid-error new file mode 100644 index 000000000000..540c8fcbfa28 --- /dev/null +++ b/changes/20828-better-appid-error @@ -0,0 +1 @@ +- Improve clarity of gitops VPP app ID type errors diff --git a/changes/20846-vuln-virtual-box b/changes/20846-vuln-virtual-box new file mode 100644 index 000000000000..225dd0be2274 --- /dev/null +++ b/changes/20846-vuln-virtual-box @@ -0,0 +1 @@ +- resolved an issue where virtual box for macOS wasn't matching against the vm_virtualbox NVD product name \ No newline at end of file diff --git a/changes/20868-turn-off-mdm b/changes/20868-turn-off-mdm new file mode 100644 index 000000000000..bfcd35d3150b --- /dev/null +++ b/changes/20868-turn-off-mdm @@ -0,0 +1 @@ +- Improves the UX of turning off MDM on an offline host (endpoint doesn't error anymore) \ No newline at end of file diff --git a/changes/20895-policy-software-install-gitops b/changes/20895-policy-software-install-gitops new file mode 100644 index 000000000000..774f6a4bfe35 --- /dev/null +++ b/changes/20895-policy-software-install-gitops @@ -0,0 +1 @@ +* Added support for configuring policy installers via GitOps. diff --git a/changes/21264-fix-reserved-team-names b/changes/21264-fix-reserved-team-names new file mode 100644 index 000000000000..6363b8186977 --- /dev/null +++ b/changes/21264-fix-reserved-team-names @@ -0,0 +1,2 @@ +- Prevents teams with the name "All teams" or "No team" from being created (these are reserved team + names in Fleet). \ No newline at end of file diff --git a/changes/21315-vpp-premium-license b/changes/21315-vpp-premium-license new file mode 100644 index 000000000000..2fd081703e47 --- /dev/null +++ b/changes/21315-vpp-premium-license @@ -0,0 +1 @@ +- Verify user has premium license before uploading VPP tokens diff --git a/changes/21402-improve-windows-mdm-enabled-error-message b/changes/21402-improve-windows-mdm-enabled-error-message new file mode 100644 index 000000000000..36dc6082f6e3 --- /dev/null +++ b/changes/21402-improve-windows-mdm-enabled-error-message @@ -0,0 +1 @@ +- Improve gitops error message about enabling windows MDM diff --git a/changes/21412-remove-node-key-from-server-logs b/changes/21412-remove-node-key-from-server-logs new file mode 100644 index 000000000000..c6555bd5bc99 --- /dev/null +++ b/changes/21412-remove-node-key-from-server-logs @@ -0,0 +1 @@ +* Removed invalid node keys from server logs. diff --git a/changes/21428-policy-automatic-install-software b/changes/21428-policy-automatic-install-software new file mode 100644 index 000000000000..e61dc2a9eadc --- /dev/null +++ b/changes/21428-policy-automatic-install-software @@ -0,0 +1 @@ +* Added automatic installation of software packages using policy automations. diff --git a/changes/21428-prevent-install-when-already-pending b/changes/21428-prevent-install-when-already-pending new file mode 100644 index 000000000000..d01006d6f91d --- /dev/null +++ b/changes/21428-prevent-install-when-already-pending @@ -0,0 +1 @@ +* Added validation to `POST /api/_version_/fleet/hosts/{host_id}/software/install/{software_title_id}` to prevent installing on a host that already has a pending installation for that software title. diff --git a/changes/21557-ota-profile-endpoint b/changes/21557-ota-profile-endpoint new file mode 100644 index 000000000000..4acf2bbcf5e9 --- /dev/null +++ b/changes/21557-ota-profile-endpoint @@ -0,0 +1 @@ +- Adds an endpoint for getting an OTA MDM profile for enrolling iOS and iPadOS hosts. \ No newline at end of file diff --git a/changes/21559-add-end-user-enrolment-page b/changes/21559-add-end-user-enrolment-page new file mode 100644 index 000000000000..427f1c5beb06 --- /dev/null +++ b/changes/21559-add-end-user-enrolment-page @@ -0,0 +1 @@ +- add feature for end users to enroll their device into fleet mdm diff --git a/changes/21683-apns-cert-validation-on-start b/changes/21683-apns-cert-validation-on-start new file mode 100644 index 000000000000..9f1714359931 --- /dev/null +++ b/changes/21683-apns-cert-validation-on-start @@ -0,0 +1,2 @@ +- Removed validation of APNS certificate from server startup. This was no longer necessary because + we now allow for APNS certificates to be renewed in the UI. diff --git a/changes/21757-fix-scheduling-cron-jobs-at-startup b/changes/21757-fix-scheduling-cron-jobs-at-startup new file mode 100644 index 000000000000..b54ae2c84f53 --- /dev/null +++ b/changes/21757-fix-scheduling-cron-jobs-at-startup @@ -0,0 +1 @@ +* Fixed an issue with the scheduling of cron jobs at startup if the job has never run, which caused it to be delayed. diff --git a/changes/21804-vpp-clear-apps-on-move b/changes/21804-vpp-clear-apps-on-move new file mode 100644 index 000000000000..3823274aaa12 --- /dev/null +++ b/changes/21804-vpp-clear-apps-on-move @@ -0,0 +1 @@ +- Clear apps associated with a VPP token if it's moved off of a team diff --git a/changes/7476-fix-ui-overflow-os-settings-table b/changes/7476-fix-ui-overflow-os-settings-table new file mode 100644 index 000000000000..6c95925de8f5 --- /dev/null +++ b/changes/7476-fix-ui-overflow-os-settings-table @@ -0,0 +1 @@ +- fixes UI overflow issues with OS settings table data diff --git a/changes/apns-errors b/changes/apns-errors new file mode 100644 index 000000000000..6de48617a1c1 --- /dev/null +++ b/changes/apns-errors @@ -0,0 +1 @@ +* Fixed logic to properly catch and log APNs errors. diff --git a/changes/update-go1.23.1 b/changes/update-go1.23.1 new file mode 100644 index 000000000000..22a59cdc400b --- /dev/null +++ b/changes/update-go1.23.1 @@ -0,0 +1 @@ +* Updated Go to go1.23.1 diff --git a/cmd/fleet/cron_test.go b/cmd/fleet/cron_test.go index 8f0626f96c07..789b38c40565 100644 --- a/cmd/fleet/cron_test.go +++ b/cmd/fleet/cron_test.go @@ -36,8 +36,6 @@ func TestNewMDMProfileManagerWithoutConfig(t *testing.T) { } func TestMigrateABMTokenDuringDEPCronJob(t *testing.T) { - // FIXME - t.Skip() ctx := context.Background() ds := mysql.CreateMySQLDS(t) @@ -108,9 +106,9 @@ func TestMigrateABMTokenDuringDEPCronJob(t *testing.T) { require.NotEmpty(t, defProf.Token) // no profile UUID was assigned for no-team (because there are no hosts right now) - profUUID, _, err := ds.GetMDMAppleDefaultSetupAssistant(ctx, nil, "") - require.NoError(t, err) - require.Equal(t, "", profUUID) + _, _, err = ds.GetMDMAppleDefaultSetupAssistant(ctx, nil, "") + require.Error(t, err) + require.ErrorAs(t, err, &nfe) // no teams, so no team-specific custom setup assistants teams, err := ds.ListTeams(ctx, fleet.TeamFilter{User: test.UserAdmin}, fleet.ListOptions{}) diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go index bcdaab956762..c8773ba765cd 100644 --- a/cmd/fleet/serve.go +++ b/cmd/fleet/serve.go @@ -22,7 +22,6 @@ import ( "github.com/e-dard/netbug" "github.com/fleetdm/fleet/v4/ee/server/licensing" eeservice "github.com/fleetdm/fleet/v4/ee/server/service" - "github.com/fleetdm/fleet/v4/pkg/certificate" "github.com/fleetdm/fleet/v4/pkg/scripts" "github.com/fleetdm/fleet/v4/server" configpkg "github.com/fleetdm/fleet/v4/server/config" @@ -75,8 +74,10 @@ import ( var allowedURLPrefixRegexp = regexp.MustCompile("^(?:/[a-zA-Z0-9_.~-]+)+$") -const softwareInstallerUploadTimeout = 4 * time.Minute -const liveQueryMemCacheDuration = 1 * time.Second +const ( + softwareInstallerUploadTimeout = 4 * time.Minute + liveQueryMemCacheDuration = 1 * time.Second +) type initializer interface { // Initialize is used to populate a datastore with @@ -126,6 +127,10 @@ the way that the Fleet server works. logger := initLogger(config) + if dev { + createTestBucketForInstallers(&config, logger) + } + // Init tracing if config.Logging.TracingEnabled { ctx := context.Background() @@ -506,7 +511,7 @@ the way that the Fleet server works. initFatal(errors.New("inserting APNs and SCEP assets"), "missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key") } - apnsCert, apnsCertPEM, apnsKeyPEM, err := config.MDM.AppleAPNs() + _, apnsCertPEM, apnsKeyPEM, err := config.MDM.AppleAPNs() if err != nil { initFatal(err, "validate Apple APNs certificate and key") } @@ -516,18 +521,6 @@ the way that the Fleet server works. initFatal(err, "validate Apple SCEP certificate and key") } - const ( - apnsConnectionTimeout = 10 * time.Second - apnsConnectionURL = "https://api.sandbox.push.apple.com" - ) - - // check that the Apple APNs certificate is valid to connect to the API - ctx, cancel := context.WithTimeout(context.Background(), apnsConnectionTimeout) - if err := certificate.ValidateClientAuthTLSConnection(ctx, apnsCert, apnsConnectionURL); err != nil { - initFatal(err, "validate authentication with Apple APNs certificate") - } - cancel() - err = ds.InsertMDMConfigAssets(context.Background(), []fleet.MDMConfigAsset{ {Name: fleet.MDMAssetAPNSCert, Value: apnsCertPEM}, {Name: fleet.MDMAssetAPNSKey, Value: apnsKeyPEM}, @@ -640,6 +633,12 @@ the way that the Fleet server works. appCfg.MDM.AppleBMEnabledAndConfigured = count > 0 } } + if appCfg.MDM.EnabledAndConfigured { + level.Info(logger).Log("msg", "Apple MDM enabled") + } + if appCfg.MDM.AppleBMEnabledAndConfigured { + level.Info(logger).Log("msg", "Apple Business Manager enabled") + } // register the Microsoft MDM services var ( @@ -968,7 +967,7 @@ the way that the Fleet server works. KeyPrefix: "ratelimit::", } - var apiHandler, frontendHandler http.Handler + var apiHandler, frontendHandler, endUserEnrollOTAHandler http.Handler { frontendHandler = service.PrometheusMetricsHandler( "get_frontend", @@ -986,8 +985,10 @@ the way that the Fleet server works. if setupRequired { apiHandler = service.WithSetup(svc, logger, apiHandler) frontendHandler = service.RedirectLoginToSetup(svc, logger, frontendHandler, config.Server.URLPrefix) + endUserEnrollOTAHandler = service.RedirectLoginToSetup(svc, logger, frontendHandler, config.Server.URLPrefix) } else { frontendHandler = service.RedirectSetupToLogin(svc, logger, frontendHandler, config.Server.URLPrefix) + endUserEnrollOTAHandler = service.ServeEndUserEnrollOTA(config.Server.URLPrefix, logger) } } @@ -1122,6 +1123,7 @@ the way that the Fleet server works. } apiHandler.ServeHTTP(rw, req) }) + rootMux.Handle("/enroll", endUserEnrollOTAHandler) rootMux.Handle("/", frontendHandler) debugHandler := &debugMux{ @@ -1406,3 +1408,18 @@ var _ push.Pusher = nopPusher{} func (n nopPusher) Push(context.Context, []string) (map[string]*push.Response, error) { return nil, nil } + +func createTestBucketForInstallers(config *configpkg.FleetConfig, logger log.Logger) { + store, err := s3.NewSoftwareInstallerStore(config.S3) + if err != nil { + initFatal(err, "initializing S3 software installer store") + } + if err := store.CreateTestBucket(config.S3.SoftwareInstallersBucket); err != nil { + // Don't panic, allow devs to run Fleet without minio/S3 dependency. + level.Info(logger).Log( + "err", err, + "msg", "failed to create test bucket", + "name", config.S3.SoftwareInstallersBucket, + ) + } +} diff --git a/cmd/fleetctl/apply.go b/cmd/fleetctl/apply.go index a84ade90f001..b201f5dc9474 100644 --- a/cmd/fleetctl/apply.go +++ b/cmd/fleetctl/apply.go @@ -90,7 +90,7 @@ func applyCommand() *cli.Command { opts.TeamForPolicies = policiesTeamName } baseDir := filepath.Dir(flFilename) - _, err = fleetClient.ApplyGroup(c.Context, specs, baseDir, logf, nil, opts) + _, _, err = fleetClient.ApplyGroup(c.Context, specs, baseDir, logf, nil, opts) if err != nil { return err } diff --git a/cmd/fleetctl/apply_test.go b/cmd/fleetctl/apply_test.go index 04c6fe3b5bde..1010e8ad1eeb 100644 --- a/cmd/fleetctl/apply_test.go +++ b/cmd/fleetctl/apply_test.go @@ -167,12 +167,15 @@ func TestApplyTeamSpecs(t *testing.T) { return nil } - ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration) error { - return nil + ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, + winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.NewActivityFunc = func( @@ -627,8 +630,9 @@ func TestApplyAppConfig(t *testing.T) { return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.LabelIDsByNameFunc = func(ctx context.Context, labels []string) (map[string]uint, error) { @@ -1250,11 +1254,14 @@ func TestApplyAsGitOps(t *testing.T) { teamEnrollSecrets = secrets return nil } - ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration) error { - return nil + ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, + winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.GetMDMAppleSetupAssistantFunc = func(ctx context.Context, teamID *uint) (*fleet.MDMAppleSetupAssistant, error) { return nil, ¬FoundError{} @@ -2224,8 +2231,6 @@ spec: }) t.Run("setup assistant get and apply roundtrip", func(t *testing.T) { - // FIXME - t.Skip() ds := setupServer(t, true) b, err := os.ReadFile(filepath.Join("testdata", "macosSetupExpectedAppConfigEmpty.yml")) diff --git a/cmd/fleetctl/get_test.go b/cmd/fleetctl/get_test.go index 752d3a65d539..fc9dbe03fbca 100644 --- a/cmd/fleetctl/get_test.go +++ b/cmd/fleetctl/get_test.go @@ -2060,10 +2060,13 @@ func TestGetAppleBM(t *testing.T) { assert.Contains(t, err.Error(), expected) }) - t.Run("premium license", func(t *testing.T) { - // FIXME - t.Skip() - runServerWithMockedDS(t, &service.TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}, DEPStorage: depStorage}) + t.Run("premium license, single token", func(t *testing.T) { + _, ds := runServerWithMockedDS(t, &service.TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}, DEPStorage: depStorage}) + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return []*fleet.ABMToken{ + {ID: 1}, + }, nil + } out := runAppForTest(t, []string{"get", "mdm_apple_bm"}) assert.Contains(t, out, "Apple ID:") @@ -2072,6 +2075,29 @@ func TestGetAppleBM(t *testing.T) { assert.Contains(t, out, "Renew date:") assert.Contains(t, out, "Default team:") }) + + t.Run("premium license, no token", func(t *testing.T) { + _, ds := runServerWithMockedDS(t, &service.TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}, DEPStorage: depStorage}) + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return nil, nil + } + + out := runAppForTest(t, []string{"get", "mdm_apple_bm"}) + assert.Contains(t, out, "No Apple Business Manager server token found.") + }) + + t.Run("premium license, multiple tokens", func(t *testing.T) { + _, ds := runServerWithMockedDS(t, &service.TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}, DEPStorage: depStorage}) + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return []*fleet.ABMToken{ + {ID: 1}, + {ID: 2}, + }, nil + } + + _, err := runAppNoChecks([]string{"get", "mdm_apple_bm"}) + assert.ErrorContains(t, err, "This API endpoint has been deprecated. Please use the new GET /abm_tokens API endpoint") + }) } func TestGetCarves(t *testing.T) { @@ -2271,11 +2297,14 @@ func TestGetTeamsYAMLAndApply(t *testing.T) { } return nil, fmt.Errorf("team not found: %s", name) } - ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration) error { - return nil + ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, + winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil @@ -2291,8 +2320,8 @@ func TestGetTeamsYAMLAndApply(t *testing.T) { declaration.DeclarationUUID = uuid.NewString() return declaration, nil } - ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, tmID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { - return nil + ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, tmID *uint, installers []*fleet.UploadSoftwareInstallerPayload) ([]fleet.SoftwareInstaller, error) { + return nil, nil } actualYaml := runAppForTest(t, []string{"get", "teams", "--yaml"}) diff --git a/cmd/fleetctl/gitops_enterprise_integration_test.go b/cmd/fleetctl/gitops_enterprise_integration_test.go index 5419b0240b19..235d89dca82a 100644 --- a/cmd/fleetctl/gitops_enterprise_integration_test.go +++ b/cmd/fleetctl/gitops_enterprise_integration_test.go @@ -187,6 +187,9 @@ team_settings: ), ) require.NoError(t, err) + + test.CreateInsertGlobalVPPToken(t, s.ds) + // Apply the team to be deleted _ = runAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", deletedTeamFile.Name()}) diff --git a/cmd/fleetctl/gitops_test.go b/cmd/fleetctl/gitops_test.go index 4caaebd00e86..58fb94b1c62a 100644 --- a/cmd/fleetctl/gitops_test.go +++ b/cmd/fleetctl/gitops_test.go @@ -35,13 +35,13 @@ const ( orgName = "GitOps Test" ) -func TestFilenameGitOpsValidation(t *testing.T) { +func TestGitOpsFilenameValidation(t *testing.T) { filename := strings.Repeat("a", filenameMaxLength+1) _, err := runAppNoChecks([]string{"gitops", "-f", filename}) assert.ErrorContains(t, err, "file name must be less than") } -func TestBasicGlobalFreeGitOps(t *testing.T) { +func TestGitOpsBasicGlobalFree(t *testing.T) { // Cannot run t.Parallel() because it sets environment variables _, ds := runServerWithMockedDS(t) @@ -49,13 +49,13 @@ func TestBasicGlobalFreeGitOps(t *testing.T) { ds.BatchSetMDMProfilesFunc = func( ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, - ) error { - return nil + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.BulkSetPendingMDMHostProfilesFunc = func( ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string, - ) error { - return nil + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil } ds.NewActivityFunc = func( @@ -153,7 +153,7 @@ org_settings: assert.Empty(t, enrolledSecrets) } -func TestBasicGlobalPremiumGitOps(t *testing.T) { +func TestGitOpsBasicGlobalPremium(t *testing.T) { // Cannot run t.Parallel() because it sets environment variables license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} @@ -166,13 +166,13 @@ func TestBasicGlobalPremiumGitOps(t *testing.T) { ds.BatchSetMDMProfilesFunc = func( ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, - ) error { - return nil + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.BulkSetPendingMDMHostProfilesFunc = func( ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string, - ) error { - return nil + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil } ds.NewActivityFunc = func( @@ -206,8 +206,8 @@ func TestBasicGlobalPremiumGitOps(t *testing.T) { ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { return &fleet.Job{}, nil } - ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { - return nil + ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) ([]fleet.SoftwareInstaller, error) { + return nil, nil } tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml") @@ -257,7 +257,7 @@ software: assert.Empty(t, enrolledSecrets) } -func TestBasicTeamGitOps(t *testing.T) { +func TestGitOpsBasicTeam(t *testing.T) { // Cannot run t.Parallel() because it sets environment variables license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} _, ds := runServerWithMockedDS( @@ -277,13 +277,13 @@ func TestBasicTeamGitOps(t *testing.T) { ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil } ds.BatchSetMDMProfilesFunc = func( ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, - ) error { - return nil + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.BulkSetPendingMDMHostProfilesFunc = func( ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string, - ) error { - return nil + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.NewActivityFunc = func( ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time, @@ -350,8 +350,8 @@ func TestBasicTeamGitOps(t *testing.T) { ds.DeleteMDMAppleDeclarationByNameFunc = func(ctx context.Context, teamID *uint, name string) error { return nil } - ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { - return nil + ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) ([]fleet.SoftwareInstaller, error) { + return nil, nil } ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error { enrolledTeamSecrets = secrets @@ -363,6 +363,9 @@ func TestBasicTeamGitOps(t *testing.T) { ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { return &fleet.Job{}, nil } + ds.ListSoftwareTitlesFunc = func(ctx context.Context, opt fleet.SoftwareTitleListOptions, tmFilter fleet.TeamFilter) ([]fleet.SoftwareTitleListResult, int, *fleet.PaginationMetadata, error) { + return nil, 0, nil, nil + } tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml") require.NoError(t, err) @@ -395,6 +398,27 @@ software: require.Error(t, err) assert.Contains(t, err.Error(), "'name' is required") + // reserved team name; should error in both dry run and real + t.Setenv("TEST_TEAM_NAME", "no TEam") + _, err = runAppNoChecks([]string{"gitops", "-f", tmpFile.Name(), "--dry-run"}) + require.Error(t, err) + assert.Contains(t, err.Error(), `"No team" is a reserved team name`) + + t.Setenv("TEST_TEAM_NAME", "no TEam") + _, err = runAppNoChecks([]string{"gitops", "-f", tmpFile.Name()}) + require.Error(t, err) + assert.Contains(t, err.Error(), `"No team" is a reserved team name`) + + t.Setenv("TEST_TEAM_NAME", "All teams") + _, err = runAppNoChecks([]string{"gitops", "-f", tmpFile.Name(), "--dry-run"}) + require.Error(t, err) + assert.Contains(t, err.Error(), `"All teams" is a reserved team name`) + + t.Setenv("TEST_TEAM_NAME", "All TEAMS") + _, err = runAppNoChecks([]string{"gitops", "-f", tmpFile.Name()}) + require.Error(t, err) + assert.Contains(t, err.Error(), `"All teams" is a reserved team name`) + // Dry run t.Setenv("TEST_TEAM_NAME", teamName) _ = runAppForTest(t, []string{"gitops", "-f", tmpFile.Name(), "--dry-run"}) @@ -417,7 +441,7 @@ software: assert.Equal(t, secret, enrolledTeamSecrets[0].Secret) } -func TestFullGlobalGitOps(t *testing.T) { +func TestGitOpsFullGlobal(t *testing.T) { // Cannot run t.Parallel() because it sets environment variables // mdm test configuration must be set so that activating windows MDM works. testCert, testKey, err := apple_mdm.NewSCEPCACertKey() @@ -450,13 +474,14 @@ func TestFullGlobalGitOps(t *testing.T) { var appliedWinProfiles []*fleet.MDMWindowsConfigProfile ds.BatchSetMDMProfilesFunc = func( ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, - ) error { + ) (updates fleet.MDMProfilesUpdates, err error) { appliedMacProfiles = macProfiles appliedWinProfiles = winProfiles - return nil + return fleet.MDMProfilesUpdates{}, nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { return job, nil @@ -577,7 +602,7 @@ func TestFullGlobalGitOps(t *testing.T) { assert.Equal(t, "https://activities_webhook_url", savedAppConfig.WebhookSettings.ActivitiesWebhook.DestinationURL) } -func TestFullTeamGitOps(t *testing.T) { +func TestGitOpsFullTeam(t *testing.T) { // Cannot run t.Parallel() because it sets environment variables license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} @@ -625,13 +650,14 @@ func TestFullTeamGitOps(t *testing.T) { var appliedWinProfiles []*fleet.MDMWindowsConfigProfile ds.BatchSetMDMProfilesFunc = func( ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, - ) error { + ) (updates fleet.MDMProfilesUpdates, err error) { appliedMacProfiles = macProfiles appliedWinProfiles = winProfiles - return nil + return fleet.MDMProfilesUpdates{}, nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { return job, nil @@ -755,8 +781,8 @@ func TestFullTeamGitOps(t *testing.T) { appliedQueries = queries return nil } - ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { - return nil + ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) ([]fleet.SoftwareInstaller, error) { + return nil, nil } ds.SetTeamVPPAppsFunc = func(ctx context.Context, teamID *uint, adamIDs []fleet.VPPAppTeam) error { return nil @@ -768,6 +794,9 @@ func TestFullTeamGitOps(t *testing.T) { enrolledSecrets = secrets return nil } + ds.ListSoftwareTitlesFunc = func(ctx context.Context, opt fleet.SoftwareTitleListOptions, tmFilter fleet.TeamFilter) ([]fleet.SoftwareTitleListResult, int, *fleet.PaginationMetadata, error) { + return nil, 0, nil, nil + } startSoftwareInstallerServer(t) @@ -873,7 +902,7 @@ software: assert.Equal(t, filepath.Base(tmpFile.Name()), *savedTeam.Filename) } -func TestBasicGlobalAndTeamGitOps(t *testing.T) { +func TestGitOpsBasicGlobalAndTeam(t *testing.T) { // Cannot run t.Parallel() because it sets environment variables license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} _, ds := runServerWithMockedDS( @@ -927,10 +956,10 @@ func TestBasicGlobalAndTeamGitOps(t *testing.T) { ds.BatchSetMDMProfilesFunc = func( ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, - ) error { + ) (updates fleet.MDMProfilesUpdates, err error) { assert.Empty(t, macProfiles) assert.Empty(t, winProfiles) - return nil + return fleet.MDMProfilesUpdates{}, nil } ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { assert.Empty(t, scripts) @@ -938,9 +967,9 @@ func TestBasicGlobalAndTeamGitOps(t *testing.T) { } ds.BulkSetPendingMDMHostProfilesFunc = func( ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string, - ) error { + ) (updates fleet.MDMProfilesUpdates, err error) { assert.Empty(t, profileUUIDs) - return nil + return fleet.MDMProfilesUpdates{}, nil } ds.DeleteMDMAppleDeclarationByNameFunc = func(ctx context.Context, teamID *uint, name string) error { return nil @@ -996,8 +1025,11 @@ func TestBasicGlobalAndTeamGitOps(t *testing.T) { savedTeam = team return team, nil } - ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { - return nil + ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) ([]fleet.SoftwareInstaller, error) { + return nil, nil + } + ds.ListSoftwareTitlesFunc = func(ctx context.Context, opt fleet.SoftwareTitleListOptions, tmFilter fleet.TeamFilter) ([]fleet.SoftwareTitleListResult, int, *fleet.PaginationMetadata, error) { + return nil, 0, nil, nil } globalFile, err := os.CreateTemp(t.TempDir(), "*.yml") @@ -1132,7 +1164,7 @@ software: assert.True(t, ds.DeleteTeamFuncInvoked) } -func TestFullGlobalAndTeamGitOps(t *testing.T) { +func TestGitOpsFullGlobalAndTeam(t *testing.T) { // Cannot run t.Parallel() because it sets environment variables // mdm test configuration must be set so that activating windows MDM works. ds, savedAppConfigPtr, savedTeams := setupFullGitOpsPremiumServer(t) @@ -1212,7 +1244,7 @@ func TestFullGlobalAndTeamGitOps(t *testing.T) { require.Len(t, enrolledTeamSecrets, 2) } -func TestTeamSofwareInstallersGitOps(t *testing.T) { +func TestGitOpsTeamSofwareInstallers(t *testing.T) { startSoftwareInstallerServer(t) cases := []struct { @@ -1230,7 +1262,7 @@ func TestTeamSofwareInstallersGitOps(t *testing.T) { {"testdata/gitops/team_software_installer_install_not_found.yml", "no such file or directory"}, {"testdata/gitops/team_software_installer_post_install_not_found.yml", "no such file or directory"}, {"testdata/gitops/team_software_installer_no_url.yml", "software URL is required"}, - {"testdata/gitops/team_software_installer_invalid_self_service_value.yml", "cannot unmarshal string into Go struct field SoftwareSpec.packages of type bool"}, + {"testdata/gitops/team_software_installer_invalid_self_service_value.yml", "\"packages.self_service\" must be a bool, found string"}, } for _, c := range cases { t.Run(filepath.Base(c.file), func(t *testing.T) { @@ -1246,24 +1278,24 @@ func TestTeamSofwareInstallersGitOps(t *testing.T) { } } -func TestTeamSoftwareInstallersGitopsQueryEnv(t *testing.T) { +func TestGitOpsTeamSoftwareInstallersQueryEnv(t *testing.T) { startSoftwareInstallerServer(t) ds, _, _ := setupFullGitOpsPremiumServer(t) t.Setenv("QUERY_VAR", "IT_WORKS") - ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, tmID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { + ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, tmID *uint, installers []*fleet.UploadSoftwareInstallerPayload) ([]fleet.SoftwareInstaller, error) { if installers[0].PreInstallQuery != "select IT_WORKS" { - return fmt.Errorf("Missing env var, got %s", installers[0].PreInstallQuery) + return nil, fmt.Errorf("Missing env var, got %s", installers[0].PreInstallQuery) } - return nil + return nil, nil } _, err := runAppNoChecks([]string{"gitops", "-f", "testdata/gitops/team_software_installer_valid_env_query.yml"}) require.NoError(t, err) } -func TestNoTeamSoftwareInstallersGitOps(t *testing.T) { +func TestGitOpsNoTeamSoftwareInstallers(t *testing.T) { startSoftwareInstallerServer(t) cases := []struct { @@ -1279,7 +1311,7 @@ func TestNoTeamSoftwareInstallersGitOps(t *testing.T) { {"testdata/gitops/no_team_software_installer_install_not_found.yml", "no such file or directory"}, {"testdata/gitops/no_team_software_installer_post_install_not_found.yml", "no such file or directory"}, {"testdata/gitops/no_team_software_installer_no_url.yml", "software URL is required"}, - {"testdata/gitops/no_team_software_installer_invalid_self_service_value.yml", "cannot unmarshal string into Go struct field SoftwareSpec.packages of type bool"}, + {"testdata/gitops/no_team_software_installer_invalid_self_service_value.yml", "\"packages.self_service\" must be a bool, found string"}, } for _, c := range cases { t.Run(filepath.Base(c.file), func(t *testing.T) { @@ -1296,7 +1328,7 @@ func TestNoTeamSoftwareInstallersGitOps(t *testing.T) { } } -func TestTeamVPPAppsGitOps(t *testing.T) { +func TestGitOpsTeamVPPApps(t *testing.T) { config := &appleVPPConfigSrvConf{ Assets: []vpp.Asset{ { @@ -1351,6 +1383,7 @@ func TestTeamVPPAppsGitOps(t *testing.T) { {"testdata/gitops/team_vpp_valid_empty.yml", "", time.Now().Add(-24 * time.Hour)}, {"testdata/gitops/team_vpp_valid_app.yml", "VPP token expired", time.Now().Add(-24 * time.Hour)}, {"testdata/gitops/team_vpp_invalid_app.yml", "app not available on vpp account", time.Now().Add(24 * time.Hour)}, + {"testdata/gitops/team_vpp_incorrect_type.yml", "\"app_store_apps.app_store_id\" must be a string, found number", time.Now().Add(24 * time.Hour)}, {"testdata/gitops/team_vpp_empty_adamid.yml", "software app store id required", time.Now().Add(24 * time.Hour)}, } @@ -1388,7 +1421,7 @@ func TestTeamVPPAppsGitOps(t *testing.T) { } } -func TestCustomSettingsGitOps(t *testing.T) { +func TestGitOpsCustomSettings(t *testing.T) { cases := []struct { file string wantErr string @@ -1666,14 +1699,14 @@ func setupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig, ds.BatchSetMDMProfilesFunc = func( ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, - ) error { - return nil + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil } ds.BulkSetPendingMDMHostProfilesFunc = func( ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string, - ) error { - return nil + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.DeleteMDMAppleDeclarationByNameFunc = func(ctx context.Context, teamID *uint, name string) error { return nil @@ -1756,8 +1789,8 @@ func setupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig, declaration.DeclarationUUID = uuid.NewString() return declaration, nil } - ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { - return nil + ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) ([]fleet.SoftwareInstaller, error) { + return nil, nil } ds.InsertVPPTokenFunc = func(ctx context.Context, tok *fleet.VPPTokenData) (*fleet.VPPTokenDB, error) { @@ -1778,6 +1811,9 @@ func setupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig, ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { return []*fleet.ABMToken{{OrganizationName: "Fleet Device Management Inc."}}, nil } + ds.ListSoftwareTitlesFunc = func(ctx context.Context, opt fleet.SoftwareTitleListOptions, tmFilter fleet.TeamFilter) ([]fleet.SoftwareTitleListResult, int, *fleet.PaginationMetadata, error) { + return nil, 0, nil, nil + } t.Setenv("FLEET_SERVER_URL", fleetServerURL) t.Setenv("ORG_NAME", orgName) @@ -1787,7 +1823,7 @@ func setupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig, return ds, &savedAppConfig, savedTeams } -func TestABMGitOps(t *testing.T) { +func TestGitOpsABM(t *testing.T) { global := func(mdm string) string { return fmt.Sprintf(` controls: @@ -1841,13 +1877,13 @@ software: tokens: []*fleet.ABMToken{{OrganizationName: "Fleet Device Management Inc."}}, dryRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { assert.NoError(t, err) - assert.Empty(t, appCfg.MDM.AppleBussinessManager.Value) + assert.Empty(t, appCfg.MDM.AppleBusinessManager.Value) assert.Empty(t, appCfg.MDM.DeprecatedAppleBMDefaultTeam) assert.Contains(t, out, "[!] gitops dry run succeeded") }, realRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { assert.NoError(t, err) - assert.Empty(t, appCfg.MDM.AppleBussinessManager.Value) + assert.Empty(t, appCfg.MDM.AppleBusinessManager.Value) assert.Equal(t, appCfg.MDM.DeprecatedAppleBMDefaultTeam, "💻 Workstations") assert.Contains(t, out, "[!] gitops succeeded") }, @@ -1887,7 +1923,7 @@ software: }, dryRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { assert.NoError(t, err) - assert.Empty(t, appCfg.MDM.AppleBussinessManager.Value) + assert.Empty(t, appCfg.MDM.AppleBusinessManager.Value) assert.Empty(t, appCfg.MDM.DeprecatedAppleBMDefaultTeam) assert.Contains(t, out, "[!] gitops dry run succeeded") }, @@ -1896,7 +1932,7 @@ software: assert.Empty(t, appCfg.MDM.DeprecatedAppleBMDefaultTeam) assert.ElementsMatch( t, - appCfg.MDM.AppleBussinessManager.Value, + appCfg.MDM.AppleBusinessManager.Value, []fleet.MDMAppleABMAssignmentInfo{ { OrganizationName: "Fleet Device Management Inc.", @@ -1928,7 +1964,7 @@ software: }, dryRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { assert.NoError(t, err) - assert.Empty(t, appCfg.MDM.AppleBussinessManager.Value) + assert.Empty(t, appCfg.MDM.AppleBusinessManager.Value) assert.Empty(t, appCfg.MDM.DeprecatedAppleBMDefaultTeam) assert.Contains(t, out, "[!] gitops dry run succeeded") }, @@ -1937,7 +1973,7 @@ software: assert.Empty(t, appCfg.MDM.DeprecatedAppleBMDefaultTeam) assert.ElementsMatch( t, - appCfg.MDM.AppleBussinessManager.Value, + appCfg.MDM.AppleBusinessManager.Value, []fleet.MDMAppleABMAssignmentInfo{ { OrganizationName: "Fleet Device Management Inc.", @@ -2010,7 +2046,7 @@ software: }, dryRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { assert.NoError(t, err) - assert.Empty(t, appCfg.MDM.AppleBussinessManager.Value) + assert.Empty(t, appCfg.MDM.AppleBusinessManager.Value) assert.Empty(t, appCfg.MDM.DeprecatedAppleBMDefaultTeam) assert.Contains(t, out, "[!] gitops dry run succeeded") }, @@ -2019,7 +2055,7 @@ software: assert.Empty(t, appCfg.MDM.DeprecatedAppleBMDefaultTeam) assert.ElementsMatch( t, - appCfg.MDM.AppleBussinessManager.Value, + appCfg.MDM.AppleBusinessManager.Value, []fleet.MDMAppleABMAssignmentInfo{ { OrganizationName: "Fleet Device Management Inc.", @@ -2043,7 +2079,7 @@ software: }, dryRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { assert.NoError(t, err) - assert.Empty(t, appCfg.MDM.AppleBussinessManager.Value) + assert.Empty(t, appCfg.MDM.AppleBusinessManager.Value) assert.Empty(t, appCfg.MDM.DeprecatedAppleBMDefaultTeam) assert.Contains(t, out, "[!] gitops dry run succeeded") }, @@ -2052,7 +2088,7 @@ software: assert.Empty(t, appCfg.MDM.DeprecatedAppleBMDefaultTeam) assert.ElementsMatch( t, - appCfg.MDM.AppleBussinessManager.Value, + appCfg.MDM.AppleBusinessManager.Value, []fleet.MDMAppleABMAssignmentInfo{ { OrganizationName: "Fleet Device Management Inc.", @@ -2076,13 +2112,13 @@ software: tokens: []*fleet.ABMToken{{OrganizationName: "Fleet Device Management Inc."}}, dryRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { assert.ErrorContains(t, err, "token with organization name Does not exist doesn't exist") - assert.Empty(t, appCfg.MDM.AppleBussinessManager.Value) + assert.Empty(t, appCfg.MDM.AppleBusinessManager.Value) assert.Empty(t, appCfg.MDM.DeprecatedAppleBMDefaultTeam) assert.NotContains(t, out, "[!] gitops dry run succeeded") }, realRunAssertion: func(t *testing.T, appCfg *fleet.AppConfig, ds fleet.Datastore, out string, err error) { assert.ErrorContains(t, err, "token with organization name Does not exist doesn't exist") - assert.Empty(t, appCfg.MDM.AppleBussinessManager.Value) + assert.Empty(t, appCfg.MDM.AppleBusinessManager.Value) assert.Empty(t, appCfg.MDM.DeprecatedAppleBMDefaultTeam) assert.NotContains(t, out, "[!] gitops dry run succeeded") }, @@ -2141,9 +2177,7 @@ software: } } -func TestVPPGitOps(t *testing.T) { - // FIXME - t.Skip() +func TestGitOpsVPP(t *testing.T) { global := func(mdm string) string { return fmt.Sprintf(` controls: @@ -2331,10 +2365,10 @@ software: name: "all teams is supported", cfgs: []string{ global(` - volume_purchasing_program: - - location: Fleet Device Management Inc. - teams: - - "All teams"`), + volume_purchasing_program: + - location: Fleet Device Management Inc. + teams: + - "All teams"`), workstations, iosTeam, }, diff --git a/cmd/fleetctl/hosts_test.go b/cmd/fleetctl/hosts_test.go index a24fc79cbe3a..6220d6d75c25 100644 --- a/cmd/fleetctl/hosts_test.go +++ b/cmd/fleetctl/hosts_test.go @@ -43,8 +43,9 @@ func TestHostsTransferByHosts(t *testing.T) { return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.ListMDMAppleDEPSerialsInHostIDsFunc = func(ctx context.Context, hostIDs []uint) ([]string, error) { @@ -114,8 +115,9 @@ func TestHostsTransferByLabel(t *testing.T) { return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.ListMDMAppleDEPSerialsInHostIDsFunc = func(ctx context.Context, hostIDs []uint) ([]string, error) { @@ -184,8 +186,9 @@ func TestHostsTransferByStatus(t *testing.T) { return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.ListMDMAppleDEPSerialsInHostIDsFunc = func(ctx context.Context, hostIDs []uint) ([]string, error) { @@ -243,8 +246,9 @@ func TestHostsTransferByStatusAndSearchQuery(t *testing.T) { return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.ListMDMAppleDEPSerialsInHostIDsFunc = func(ctx context.Context, hostIDs []uint) ([]string, error) { diff --git a/cmd/fleetctl/preview.go b/cmd/fleetctl/preview.go index e11dc8bf9e26..ef06f3c8cc88 100644 --- a/cmd/fleetctl/preview.go +++ b/cmd/fleetctl/preview.go @@ -387,7 +387,7 @@ Use the stop and reset subcommands to manage the server and dependencies once st } // this only applies standard queries, the base directory is not used, // so pass in the current working directory. - _, err = client.ApplyGroup(c.Context, specs, ".", logf, nil, fleet.ApplyClientSpecOptions{}) + _, _, err = client.ApplyGroup(c.Context, specs, ".", logf, nil, fleet.ApplyClientSpecOptions{}) if err != nil { return err } diff --git a/cmd/fleetctl/testdata/gitops/team_vpp_incorrect_type.yml b/cmd/fleetctl/testdata/gitops/team_vpp_incorrect_type.yml new file mode 100644 index 000000000000..74e7b178063a --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/team_vpp_incorrect_type.yml @@ -0,0 +1,17 @@ +name: "${TEST_TEAM_NAME}" +team_settings: + secrets: + - secret: "ABC" + features: + enable_host_users: true + enable_software_inventory: true + host_expiry_settings: + host_expiry_enabled: true + host_expiry_window: 30 +agent_options: +controls: +policies: +queries: +software: + app_store_apps: + - app_store_id: 1 diff --git a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml index e63748bfb9db..2dd2f93adf1f 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml @@ -19,6 +19,7 @@ spec: zendesk: null mdm: apple_business_manager: + volume_purchasing_program: apple_bm_enabled_and_configured: false apple_bm_terms_expired: false enabled_and_configured: true diff --git a/codecov.yml b/codecov.yml index d0a398457d20..f91f1aae408e 100644 --- a/codecov.yml +++ b/codecov.yml @@ -22,3 +22,6 @@ flag_management: - name: frontend paths: - frontend/ + +ignore: + - "server/mock" diff --git a/docs/Configuration/yaml-files.md b/docs/Configuration/yaml-files.md index a23d84b0b263..be73c1ff29e5 100644 --- a/docs/Configuration/yaml-files.md +++ b/docs/Configuration/yaml-files.md @@ -273,14 +273,16 @@ Use `labels_include_all` to only apply (scope) profiles to hosts that have all t #### macos_setup -The `macos_setup` section lets you control the [end user migration workflow](https://fleetdm.com/docs/using-fleet/mdm-migration-guide#end-user-workflow) for macOS hosts that automatically enrolled to your old MDM solution. +The `macos_setup` section lets you control the out-of-the-box macOS [setup experience](https://fleetdm.com/guides/macos-setup-experience) for hosts that use Automated Device Enrollment (ADE). - `bootstrap_package` is the URL to a bootstap package. Fleet will download the bootstrap package (default: `""`). - `enable_end_user_authentication` specifies whether or not to require end user authentication when the user first sets up their macOS host. -- `macos_setup_assistant` is a path to a custom automatic enrollment (DEP) profile (.json). +- `macos_setup_assistant` is a path to a custom automatic enrollment (ADE) profile (.json). #### macos_migration +The `macos_migration` section lets you control the [end user migration workflow](https://fleetdm.com/docs/using-fleet/mdm-migration-guide#end-user-workflow) for macOS hosts that enrolled to your old MDM solution. + - `enable` specifies whether or not to enable end user migration workflow (default: `false`) - `mode` specifies whether the end user initiates migration (`voluntary`) or they're nudged every 15-20 minutes to migrate (`forced`) (default: `""`). - `webhook_url` is the URL that Fleet sends a webhook to when the end user selects **Start**. Receive this webhook using your automation tool (ex. Tines) to unenroll your end users from your old MDM solution. @@ -585,6 +587,19 @@ org_settings: Can only be configured for all teams (`org_settings`). +##### end_user_authentication + +The `end_user_authentication` section lets you define the identity provider (IdP) settings used for end user authentication during Automated Device Enrollment (ADE). Learn more about end user authentication in Fleet [here](https://fleetdm.com/guides/macos-setup-experience#end-user-authentication-and-eula). + +Once the IdP settings are configured, you can use the [`controls.macos_setup.enable_end_user_authentication`](#macos_setup) key to control the end user experience during ADE. + +- `idp_name` is the human-friendly name for the identity provider that will provide single sign-on authentication (default: `""`). +- `entity_id` is the entity ID: a Uniform Resource Identifier (URI) that you use to identify Fleet when configuring the identity provider. It must exactly match the Entity ID field used in identity provider configuration (default: `""`). +- `metadata` is the metadata (in XML format) provided by the identity provider. (default: `""`) +- `metadata_url` is the URL that references the identity provider metadata. Only one of `metadata` or `metadata_url` is required (default: `""`). + +Can only be configured for all teams (`org_settings`). + <meta name="title" value="YAML files"> <meta name="description" value="Reference documentation for Fleet's GitOps workflow. See examples and configuration options."> <meta name="pageOrderInSection" value="1500"> diff --git a/docs/Contributing/File-carving.md b/docs/Contributing/File-carving.md index 4daad97fb03b..b283f11f4c8d 100644 --- a/docs/Contributing/File-carving.md +++ b/docs/Contributing/File-carving.md @@ -77,7 +77,7 @@ The same is not true if S3 is used as the storage backend. In that scenario, it ### Alternative carving backends -#### Minio +#### MinIO Configure the following: - `FLEET_S3_ENDPOINT_URL=minio_host:port` @@ -87,6 +87,11 @@ Configure the following: - `FLEET_S3_FORCE_S3_PATH_STYLE=true` - `FLEET_S3_REGION=minio` or any non-empty string otherwise Fleet will attempt to derive the region. +If you're testing file carving locally with the docker-compose environment, the `--dev` flag on Fleet server will +automatically point carves to the local MinIO container and write to the `carves-dev` bucket without needing to set +additional configuration. Note that this bucket is *not* created automatically when bringing MinIO up; you'll need to +log in via `http://localhost:9001` with credentials `minio` / `minio123!` to create the bucket. + ### Troubleshooting #### Check carve status in osquery diff --git a/docs/Contributing/Testing-and-local-development.md b/docs/Contributing/Testing-and-local-development.md index 8f6bc0ce80b1..45c5a93fe0e8 100644 --- a/docs/Contributing/Testing-and-local-development.md +++ b/docs/Contributing/Testing-and-local-development.md @@ -489,7 +489,9 @@ FLEET_SERVER_SANDBOX_ENABLED=1 FLEET_PACKAGING_GLOBAL_ENROLL_SECRET=xyz ./build Be sure to replace the `FLEET_PACKAGING_GLOBAL_ENROLL_SECRET` value above with the global enroll secret from the `fleetctl package` command used to build the installers. -MinIO also offers a web interface at http://localhost:9001. Credentials are `minio` / `minio123!`. +MinIO also offers a web interface at http://localhost:9001. Credentials are `minio` / `minio123!`. When starting the +Fleet server up with `--dev` the server will look for installers in the `software-installers-dev` MinIO bucket. You can +create this bucket via the MinIO web UI (it is *not* created by default when setting up the docker-compose environment). ## Telemetry diff --git a/docs/Deploy/Reference-Architectures.md b/docs/Deploy/Reference-Architectures.md index c5e637e838a3..285eb54d7a95 100644 --- a/docs/Deploy/Reference-Architectures.md +++ b/docs/Deploy/Reference-Architectures.md @@ -2,10 +2,10 @@ ## The Fleet binary -The Fleet application contains two single static binaries which provide web based administration, REST API, and CLI interface to Fleet. +The Fleet application contains two single static binaries which provide web based administration, a REST API, and a [CLI interface](https://fleetdm.com/guides/fleetctl). The `fleet` binary contains: -- The Fleet TLS web server (no external webserver is required but it supports a proxy if desired) +- The [Fleet TLS web server](https://fleetdm.com/docs/configuration/fleet-server-configuration) (no external webserver is required but it supports a proxy if desired) - The Fleet web interface - The Fleet application management [REST API](https://fleetdm.com/docs/using-fleet/rest-api) - The Fleet osquery API endpoints @@ -24,7 +24,7 @@ Fleet currently has three infrastructure dependencies: MySQL, Redis, and a TLS c ### MySQL Fleet uses MySQL extensively as its main database. Many cloud providers (such as [AWS](https://aws.amazon.com/rds/mysql/) and [GCP](https://cloud.google.com/sql/)) host reliable MySQL services which you may consider for this purpose. A well-supported MySQL [Docker image](https://hub.docker.com/_/mysql/) also exists if you would rather run MySQL in a container. -For more information on how to configure the `fleet` binary to use the correct MySQL instance, see the [Configuration](https://fleetdm.com/docs/deploying/configuration) document. +For more information on how to configure the `fleet` binary to use the correct MySQL instance, see the [MySQL configuration](https://fleetdm.com/docs/configuration/fleet-server-configuration#mysql) documentation. Fleet requires at least MySQL version 8.0, and is tested using the InnoDB storage engine. @@ -32,7 +32,7 @@ There are many "drop-in replacements" for MySQL available. If you'd like to expe ### Redis -Fleet uses Redis to ingest and queue the results of distributed queries, cache data, etc. Many cloud providers (such as [AWS](https://aws.amazon.com/elasticache/) and [GCP](https://console.cloud.google.com/launcher/details/click-to-deploy-images/redis)) host reliable Redis services which you may consider for this purpose. A well supported Redis [Docker image](https://hub.docker.com/_/redis/) also exists if you would rather run Redis in a container. For more information on how to configure the `fleet` binary to use the correct Redis instance, see the [Configuration](https://fleetdm.com/docs/deploying/configuration) document. +Fleet uses Redis to ingest and queue the results of distributed queries, cache data, etc. Many cloud providers (such as [AWS](https://aws.amazon.com/elasticache/) and [GCP](https://console.cloud.google.com/launcher/details/click-to-deploy-images/redis)) host reliable Redis services which you may consider for this purpose. A well supported Redis [Docker image](https://hub.docker.com/_/redis/) also exists if you would rather run Redis in a container. For more information on how to configure the `fleet` binary to use the correct Redis instance, see the [Redis configuration](https://fleetdm.com/docs/configuration/fleet-server-configuration#redis) documentation. ## Systemd diff --git a/docs/Deploy/deploy-fleet.md b/docs/Deploy/deploy-fleet.md index c0e80f4b2e88..ee5299bfc378 100644 --- a/docs/Deploy/deploy-fleet.md +++ b/docs/Deploy/deploy-fleet.md @@ -43,7 +43,7 @@ Render is a cloud hosting service that makes it easy to get up and running fast, </a> </div> -1. Click "Deploy to Render" to open the Fleet Blueprint on Render. You will be prompted to create or log in to your Render account with associated payment information. +1. Click "Deploy to Render" to open the Fleet Blueprint on Render. Ensure that the Redis instance is manually set to the same region as your other resources. You will be prompted to create or log in to your Render account with associated payment information. 2. Give the Blueprint a unique name like `yourcompany-fleet`. diff --git a/docs/REST API/rest-api.md b/docs/REST API/rest-api.md index 607ac2f28d73..da63c0ccb5e6 100644 --- a/docs/REST API/rest-api.md +++ b/docs/REST API/rest-api.md @@ -1090,7 +1090,7 @@ Modifies the Fleet's configuration with the supplied information. | integrations | object | body | Includes `jira`, `zendesk`, and `google_calendar` arrays. See [integrations](#integrations) for details. | | mdm | object | body | See [mdm](#mdm). | | features | object | body | See [features](#features). | -| scripts | list | body | A list of script files to add so they can be executed at a later time. | +| scripts | array | body | A list of script files to add so they can be executed at a later time. | | force | boolean | query | Whether to force-apply the agent options even if there are validation errors. | | dry_run | boolean | query | Whether to validate the configuration and return any validation errors **without** applying changes. | @@ -1518,10 +1518,10 @@ _Available in Fleet Premium._ | Name | Type | Description | | --------------------- | ----- | ---------------------------------------------------------------------------------------------- | -| host_status_webhook | list | See [`webhook_settings.host_status_webhook`](#webhook-settings-host-status-webhook). | -| failing_policies_webhook | list | See [`webhook_settings.failing_policies_webhook`](#webhook-settings-failing-policies-webhook). | -| vulnerabilities_webhook | list | See [`webhook_settings.vulnerabilities_webhook`](#webhook-settings-vulnerabilities-webhook). | -| activities_webhook | list | See [`webhook_settings.activities_webhook`](#webhook-settings-activities-webhook). | +| host_status_webhook | array | See [`webhook_settings.host_status_webhook`](#webhook-settings-host-status-webhook). | +| failing_policies_webhook | array | See [`webhook_settings.failing_policies_webhook`](#webhook-settings-failing-policies-webhook). | +| vulnerabilities_webhook | array | See [`webhook_settings.vulnerabilities_webhook`](#webhook-settings-vulnerabilities-webhook). | +| activities_webhook | array | See [`webhook_settings.activities_webhook`](#webhook-settings-activities-webhook). | <br/> @@ -1614,9 +1614,9 @@ _Available in Fleet Premium._ | Name | Type | Description | | --------------------- | ----- | -------------------------------------------------------------------- | -| jira | list | See [`integrations.jira`](#integrations-jira). | -| zendesk | list | See [`integrations.zendesk`](#integrations-zendesk). | -| google_calendar | list | See [`integrations.google_calendar`](#integrations-google-calendar). | +| jira | array | See [`integrations.jira`](#integrations-jira). | +| zendesk | array | See [`integrations.zendesk`](#integrations-zendesk). | +| google_calendar | array | See [`integrations.google_calendar`](#integrations-google-calendar). | > Note that when making changes to the `integrations` object, all integrations must be provided (not just the one being modified). This is because the endpoint will consider missing integrations as deleted. @@ -1792,7 +1792,7 @@ _Available in Fleet Premium._ | Name | Type | Description | | --------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| custom_settings | list | macOS hosts that belong to no team will have custom profiles applied. | +| custom_settings | array | macOS hosts that belong to no team will have custom profiles applied. | <br/> @@ -1802,7 +1802,7 @@ _Available in Fleet Premium._ | Name | Type | Description | | --------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| custom_settings | list | Windows hosts that belong to no team will have custom profiles applied. | +| custom_settings | array | Windows hosts that belong to no team will have custom profiles applied. | <br/> @@ -2099,7 +2099,7 @@ Delete all of a team's existing enroll secrets | email | string | body | **Required.** The email of the invited user. This email will receive the invitation link. | | name | string | body | **Required.** The name of the invited user. | | sso_enabled | boolean | body | **Required.** Whether or not SSO will be enabled for the invited user. | -| teams | list | body | _Available in Fleet Premium_. A list of the teams the user is a member of. Each item includes the team's ID and the user's role in the specified team. | +| teams | array | body | _Available in Fleet Premium_. A list of the teams the user is a member of. Each item includes the team's ID and the user's role in the specified team. | #### Example @@ -2299,7 +2299,7 @@ Verify the specified invite. | email | string | body | The email of the invited user. Updates on the email won't resend the invitation. | | name | string | body | The name of the invited user. | | sso_enabled | boolean | body | Whether or not SSO will be enabled for the invited user. | -| teams | list | body | _Available in Fleet Premium_. A list of the teams the user is a member of. Each item includes the team's ID and the user's role in the specified team. | +| teams | array | body | _Available in Fleet Premium_. A list of the teams the user is a member of. Each item includes the team's ID and the user's role in the specified team. | #### Example @@ -2489,6 +2489,7 @@ the `software` table. | mdm_id | integer | query | The ID of the _mobile device management_ (MDM) solution to filter hosts by (that is, filter hosts that use a specific MDM provider and URL). | | mdm_name | string | query | The name of the _mobile device management_ (MDM) solution to filter hosts by (that is, filter hosts that use a specific MDM provider). | | mdm_enrollment_status | string | query | The _mobile device management_ (MDM) enrollment status to filter hosts by. Valid options are 'manual', 'automatic', 'enrolled', 'pending', or 'unenrolled'. | +| connected_to_fleet | boolean | query | Filter hosts that are talking to this Fleet server for MDM features. In rare cases, hosts can be enrolled to one Fleet server but talk to a different Fleet server for MDM features. In this case, the value would be `false`. Always `false` for Linux hosts. | | macos_settings | string | query | Filters the hosts by the status of the _mobile device management_ (MDM) profiles applied to hosts. Valid options are 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** | | munki_issue_id | integer | query | The ID of the _munki issue_ (a Munki-reported error or warning message) to filter hosts by (that is, filter hosts that are affected by that corresponding error or warning message). | | low_disk_space | integer | query | _Available in Fleet Premium_. Filters the hosts to only include hosts with less GB of disk space available than this value. Must be a number between 1-100. | @@ -3099,10 +3100,11 @@ Returns the information of the specified host. "timezone": "America/New_York" }, "mdm": { - "encryption_key_available": false, - "enrollment_status": null, - "name": "", - "server_url": null, + "encryption_key_available": true, + "enrollment_status": "On (manual)", + "name": "Fleet", + "connected_to_fleet": true, + "server_url": "https://acme.com/mdm/apple/mdm", "device_status": "unlocked", "pending_action": "", "macos_settings": { @@ -3518,10 +3520,11 @@ This is the API route used by the **My device** page in Fleet desktop to display } ], "mdm": { - "encryption_key_available": false, - "enrollment_status": null, - "name": "", - "server_url": null, + "encryption_key_available": true, + "enrollment_status": "On (manual)", + "name": "Fleet", + "connected_to_fleet": true, + "server_url": "https://acme.com/mdm/apple/mdm", "macos_settings": { "disk_encryption": null, "action_required": null @@ -3696,7 +3699,7 @@ _Available in Fleet Premium_ | Name | Type | In | Description | | ------- | ------- | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| ids | list | body | A list of the host IDs you'd like to delete. If `ids` is specified, `filters` cannot be specified. | +| ids | array | body | A list of the host IDs you'd like to delete. If `ids` is specified, `filters` cannot be specified. | | filters | object | body | Contains any of the following four properties: `query` for search query keywords. Searchable fields include `hostname`, `hardware_serial`, `uuid`, and `ipv4`. `status` to indicate the status of the hosts to return. Can either be `new`, `online`, `offline`, `mia` or `missing`. `label_id` to indicate the selected label. `team_id` to indicate the selected team. If `filters` is specified, `id` cannot be specified. `label_id` and `status` cannot be used at the same time. | Either ids or filters are required. @@ -4663,9 +4666,9 @@ Adds manual labels to a host. #### Parameters -| Name | Type | In | Description | -| ---- | ------- | ---- | ---------------------------- | -| labels | list | body | The list of label names to add to the host. | +| Name | Type | In | Description | +| ------ | ------- | ---- | ---------------------------- | +| labels | array | body | The list of label names to add to the host. | #### Example @@ -4692,9 +4695,9 @@ Removes manual labels from a host. #### Parameters -| Name | Type | In | Description | -| ---- | ------- | ---- | ---------------------------- | -| labels | list | body | The list of label names to delete from the host. | +| Name | Type | In | Description | +| ------ | ------- | ---- | ---------------------------- | +| labels | array | body | The list of label names to delete from the host. | #### Example @@ -5424,7 +5427,6 @@ List all configuration profiles for macOS and Windows hosts enrolled to Fleet's { "name": "Label name 2", "broken": true, - "id": 2 }, { "name": "Label name 3", @@ -6559,7 +6561,7 @@ For example, a policy might ask “Is Gatekeeper enabled on macOS devices?“ Th | Name | Type | In | Description | | -------- | ------- | ---- | ------------------------------------------------- | -| ids | list | body | **Required.** The IDs of the policies to delete. | +| ids | array | body | **Required.** The IDs of the policies to delete. | #### Example @@ -6653,8 +6655,8 @@ Triggers [automations](https://fleetdm.com/docs/using-fleet/automations#policy-a | Name | Type | In | Description | | ---------- | -------- | ---- | -------------------------------------------------------- | -| policy_ids | list | body | Filters to only run policy automations for the specified policies. | -| team_ids | list | body | _Available in Fleet Premium_. Filters to only run policy automations for hosts in the specified teams. | +| policy_ids | array | body | Filters to only run policy automations for the specified policies. | +| team_ids | array | body | _Available in Fleet Premium_. Filters to only run policy automations for hosts in the specified teams. | #### Example @@ -6994,7 +6996,7 @@ Either `query` or `query_id` must be provided. | Name | Type | In | Description | | -------- | ------- | ---- | ------------------------------------------------- | | team_id | integer | path | **Required.** Defines what team ID to operate on | -| ids | list | body | **Required.** The IDs of the policies to delete. | +| ids | array | body | **Required.** The IDs of the policies to delete. | #### Example @@ -7431,14 +7433,14 @@ Creates a global query or team query. | name | string | body | **Required**. The name of the query. | | query | string | body | **Required**. The query in SQL syntax. | | description | string | body | The query's description. | -| observer_can_run | bool | body | Whether or not users with the `observer` role can run the query. In Fleet 4.0.0, 3 user roles were introduced (`admin`, `maintainer`, and `observer`). This field is only relevant for the `observer` role. The `observer_plus` role can run any query and is not limited by this flag (`observer_plus` role was added in Fleet 4.30.0). | +| observer_can_run | boolean | body | Whether or not users with the `observer` role can run the query. In Fleet 4.0.0, 3 user roles were introduced (`admin`, `maintainer`, and `observer`). This field is only relevant for the `observer` role. The `observer_plus` role can run any query and is not limited by this flag (`observer_plus` role was added in Fleet 4.30.0). | | team_id | integer | body | _Available in Fleet Premium_. The parent team to which the new query should be added. If omitted, the query will be global. | -| interval | integer | body | The amount of time, in seconds, the query waits before running. Can be set to `0` to never run. Default: 0. | +| interval | integer | body | The amount of time, in seconds, the query waits before running. Can be set to `0` to never run. Default: 0. | | platform | string | body | The OS platforms where this query will run (other platforms ignored). Comma-separated string. If omitted, runs on all compatible platforms. | | min_osquery_version | string | body | The minimum required osqueryd version installed on a host. If omitted, all osqueryd versions are acceptable. | | automations_enabled | boolean | body | Whether to send data to the configured log destination according to the query's `interval`. | -| logging | string | body | The type of log output for this query. Valid values: `"snapshot"`(default), `"differential"`, or `"differential_ignore_removals"`. | -| discard_data | bool | body | Whether to skip saving the latest query results for each host. Default: `false`. | +| logging | string | body | The type of log output for this query. Valid values: `"snapshot"`(default), `"differential"`, or `"differential_ignore_removals"`. | +| discard_data | boolean | body | Whether to skip saving the latest query results for each host. Default: `false`. | #### Example @@ -7505,13 +7507,13 @@ Modifies the query specified by ID. | name | string | body | The name of the query. | | query | string | body | The query in SQL syntax. | | description | string | body | The query's description. | -| observer_can_run | bool | body | Whether or not users with the `observer` role can run the query. In Fleet 4.0.0, 3 user roles were introduced (`admin`, `maintainer`, and `observer`). This field is only relevant for the `observer` role. The `observer_plus` role can run any query and is not limited by this flag (`observer_plus` role was added in Fleet 4.30.0). | +| observer_can_run | boolean | body | Whether or not users with the `observer` role can run the query. In Fleet 4.0.0, 3 user roles were introduced (`admin`, `maintainer`, and `observer`). This field is only relevant for the `observer` role. The `observer_plus` role can run any query and is not limited by this flag (`observer_plus` role was added in Fleet 4.30.0). | | interval | integer | body | The amount of time, in seconds, the query waits before running. Can be set to `0` to never run. Default: 0. | | platform | string | body | The OS platforms where this query will run (other platforms ignored). Comma-separated string. If set to "", runs on all compatible platforms. | | min_osquery_version | string | body | The minimum required osqueryd version installed on a host. If omitted, all osqueryd versions are acceptable. | | automations_enabled | boolean | body | Whether to send data to the configured log destination according to the query's `interval`. | | logging | string | body | The type of log output for this query. Valid values: `"snapshot"`(default), `"differential"`, or `"differential_ignore_removals"`. | -| discard_data | bool | body | Whether to skip saving the latest query results for each host. | +| discard_data | boolean | body | Whether to skip saving the latest query results for each host. | > Note that any of the following conditions will cause the existing query report to be deleted: > - Updating the `query` (SQL) field @@ -7615,9 +7617,9 @@ Deletes the queries specified by ID. Returns the count of queries successfully d #### Parameters -| Name | Type | In | Description | -| ---- | ---- | ---- | ------------------------------------- | -| ids | list | body | **Required.** The IDs of the queries. | +| Name | Type | In | Description | +| ---- | ----- | ---- | ------------------------------------- | +| ids | array | body | **Required.** The IDs of the queries. | #### Example @@ -8506,9 +8508,9 @@ Get a list of all software. | order_direction | string | query | **Requires `order_key`**. The direction of the order given the order key. Options include `asc` and `desc`. Default is `asc`. | | query | string | query | Search query keywords. Searchable fields include `title` and `cve`. | | team_id | integer | query | _Available in Fleet Premium_. Filters the software to only include the software installed on the hosts that are assigned to the specified team. Use `0` to filter by hosts assigned to "No team". | -| vulnerable | bool | query | If true or 1, only list software that has detected vulnerabilities. Default is `false`. | -| available_for_install | bool | query | If `true` or `1`, only list software that is available for install (added by the user). Default is `false`. | -| self_service | bool | query | If `true` or `1`, only lists self-service software. Default is `false`. | +| vulnerable | boolean | query | If true or 1, only list software that has detected vulnerabilities. Default is `false`. | +| available_for_install | boolean | query | If `true` or `1`, only list software that is available for install (added by the user). Default is `false`. | +| self_service | boolean | query | If `true` or `1`, only lists self-service software. Default is `false`. | #### Example @@ -8627,7 +8629,7 @@ Get a list of all software versions. | order_direction | string | query | **Requires `order_key`**. The direction of the order given the order key. Options include `asc` and `desc`. Default is `asc`. | | query | string | query | Search query keywords. Searchable fields include `name`, `version`, and `cve`. | | team_id | integer | query | _Available in Fleet Premium_. Filters the software to only include the software installed on the hosts that are assigned to the specified team. Use `0` to filter by hosts assigned to "No team". | -| vulnerable | bool | query | If true or 1, only list software that has detected vulnerabilities. Default is `false`. | +| vulnerable | boolean | query | If true or 1, only list software that has detected vulnerabilities. Default is `false`. | #### Example @@ -8990,6 +8992,8 @@ OS vulnerability data is currently available for Windows and macOS. For other pl ### Add package +> **Experimental feature**. This feature is undergoing rapid improvement, which may result in breaking changes to the API or configuration surface. It is not recommended for use in automated workflows. + _Available in Fleet Premium._ Add a package (.pkg, .msi, .exe, .deb) to install on macOS, Windows, or Linux (Ubuntu) hosts. @@ -9136,6 +9140,8 @@ Add App Store (VPP) app purchased in Apple Business Manager. ### Download package +> **Experimental feature**. This feature is undergoing rapid improvement, which may result in breaking changes to the API or configuration surface. It is not recommended for use in automated workflows. + _Available in Fleet Premium._ `GET /api/v1/fleet/software/titles/:software_title_id/package?alt=media` @@ -9172,6 +9178,8 @@ _Available in Fleet Premium._ Install software (package or App Store app) on a macOS, iOS, iPadOS, Windows, or Linux (Ubuntu) host. Software title must have a `software_package` or `app_store_app` added to be installed. +> Note: Fleet's agent (fleetd) only installs software it has been asked to install, but technically has access to all installer executables. + `POST /api/v1/fleet/hosts/:id/software/install/:software_title_id` #### Parameters @@ -9191,6 +9199,8 @@ Install software (package or App Store app) on a macOS, iOS, iPadOS, Windows, or ### Get package install result +> **Experimental feature**. This feature is undergoing rapid improvement, which may result in breaking changes to the API or configuration surface. It is not recommended for use in automated workflows. + _Available in Fleet Premium._ `GET /api/v1/fleet/software/install/results/:install_uuid` @@ -9828,8 +9838,8 @@ _Available in Fleet Premium_ | ------------------------------------------------------- | ------- | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | id | integer | path | **Required.** The desired team's ID. | | name | string | body | The team's name. | -| host_ids | list | body | A list of hosts that belong to the team. | -| user_ids | list | body | A list of users on the team. | +| host_ids | array | body | A list of hosts that belong to the team. | +| user_ids | array | body | A list of users on the team. | | webhook_settings | object | body | Webhook settings contains for the team. | | failing_policies_webhook | object | body | Failing policies webhook settings. | | enable_failing_policies_webhook | boolean | body | Whether or not the failing policies webhook is enabled. | @@ -9864,10 +9874,10 @@ _Available in Fleet Premium_ | deadline_days | integer | body | Hosts that belong to this team and are enrolled into Fleet's MDM will have this number of days before updates are installed on Windows. | | grace_period_days | integer | body | Hosts that belong to this team and are enrolled into Fleet's MDM will have this number of days before Windows restarts to install updates. | | macos_settings | object | body | macOS-specific settings. | -| custom_settings | list | body | The list of objects where each object includes .mobileconfig or JSON file (configuration profile) and label name to apply to macOS hosts that belong to this team and are members of the specified label. | +| custom_settings | array | body | The list of objects where each object includes .mobileconfig or JSON file (configuration profile) and label name to apply to macOS hosts that belong to this team and are members of the specified label. | | enable_disk_encryption | boolean | body | Hosts that belong to this team and are enrolled into Fleet's MDM will have disk encryption enabled if set to true. | | windows_settings | object | body | Windows-specific settings. | -| custom_settings | list | body | The list of objects where each object includes XML file (configuration profile) and label name to apply to Windows hosts that belong to this team and are members of the specified label. | +| custom_settings | array | body | The list of objects where each object includes XML file (configuration profile) and label name to apply to Windows hosts that belong to this team and are members of the specified label. | | macos_setup | object | body | Setup for automatic MDM enrollment of macOS hosts. | | enable_end_user_authentication | boolean | body | If set to true, end user authentication will be required during automatic MDM enrollment of new macOS hosts. Settings for your IdP provider must also be [configured](https://fleetdm.com/docs/using-fleet/mdm-macos-setup-experience#end-user-authentication-and-eula). | | integrations | object | body | Integration settings for this team. | @@ -10087,8 +10097,8 @@ _Available in Fleet Premium_ | Name | Type | In | Description | | --- | --- | --- | --- | | id | integer | path | **Required.** The desired team's ID. | -| force | bool | query | Force apply the options even if there are validation errors. | -| dry_run | bool | query | Validate the options and return any validation errors, but do not apply the changes. | +| force | boolean | query | Force apply the options even if there are validation errors. | +| dry_run | boolean | query | Validate the options and return any validation errors, but do not apply the changes. | | _JSON data_ | object | body | The JSON to use as agent options for this team. See [Agent options](https://fleetdm.com/docs/using-fleet/configuration-files#agent-options) for details. | #### Example @@ -10199,9 +10209,9 @@ Transforms a host name into a host id. For example, the Fleet UI use this endpoi #### Parameters -| Name | Type | In | Description | -| ---- | ----- | ---- | ---------------------------------------- | -| list | array | body | **Required** list of items to translate. | +| Name | Type | In | Description | +| ----- | ----- | ---- | ---------------------------------------- | +| array | array | body | **Required** list of items to translate. | #### Example diff --git a/ee/server/service/mdm.go b/ee/server/service/mdm.go index 9aeaddd67b3f..9d9442879028 100644 --- a/ee/server/service/mdm.go +++ b/ee/server/service/mdm.go @@ -1072,7 +1072,7 @@ func (svc *Service) mdmAppleEditedAppleOSUpdates(ctx context.Context, teamID *ui // This only sets profiles that haven't been queued by the cron to 'pending' (both removes and installs, which includes // the OS updates we just deleted). It doesn't have a functional difference because if you don't call this function // the cron will catch up, but it's important for the UX to mark them as pending immediately so it's reflected in the UI. - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{globalOrTeamID}, nil, nil); err != nil { + if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{globalOrTeamID}, nil, nil); err != nil { return ctxerr.Wrap(ctx, err, "bulk set pending host profiles") } return nil @@ -1105,7 +1105,7 @@ func (svc *Service) mdmAppleEditedAppleOSUpdates(ctx context.Context, teamID *ui return err } - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl.DeclarationUUID}, nil); err != nil { + if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl.DeclarationUUID}, nil); err != nil { return ctxerr.Wrap(ctx, err, "bulk set pending host declarations") } return nil diff --git a/ee/server/service/mdm_external_test.go b/ee/server/service/mdm_external_test.go index 3d9ecf992457..c6324f84fe0c 100644 --- a/ee/server/service/mdm_external_test.go +++ b/ee/server/service/mdm_external_test.go @@ -200,8 +200,9 @@ func TestGetOrCreatePreassignTeam(t *testing.T) { declaration.DeclarationUUID = uuid.NewString() return declaration, nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } apnsCert, apnsKey, err := mysql.GenerateTestCertBytes() require.NoError(t, err) diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index 153b0e76ba46..281d1b05f97f 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -37,6 +37,7 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet. if !ok { return fleet.ErrNoContext } + payload.UserID = vc.UserID() // make sure all scripts use unix-style newlines to prevent errors when // running them, browsers use windows-style newlines, which breaks the @@ -384,6 +385,24 @@ func (svc *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softw // if we found an installer, use that if installer != nil { + lastInstallRequest, err := svc.ds.GetHostLastInstallData(ctx, host.ID, installer.InstallerID) + if err != nil { + return ctxerr.Wrapf(ctx, err, "getting last install data for host %d and installer %d", host.ID, installer.InstallerID) + } + if lastInstallRequest != nil && lastInstallRequest.Status != nil && *lastInstallRequest.Status == fleet.SoftwareInstallerPending { + return &fleet.BadRequestError{ + Message: "Couldn't install software. Host has a pending install request.", + InternalErr: ctxerr.WrapWithData( + ctx, err, "host already has a pending install for this installer", + map[string]any{ + "host_id": host.ID, + "software_installer_id": installer.InstallerID, + "team_id": host.TeamID, + "title_id": softwareTitleID, + }, + ), + } + } return svc.installSoftwareTitleUsingInstaller(ctx, host, installer) } } @@ -629,6 +648,14 @@ func (svc *Service) addMetadataToSoftwarePayload(ctx context.Context, payload *f } return "", ctxerr.Wrap(ctx, err, "extracting metadata from installer") } + + if meta.Version == "" { + return "", &fleet.BadRequestError{ + Message: fmt.Sprintf("Couldn't add. Fleet couldn't read the version from %s.", payload.Filename), + InternalErr: ctxerr.New(ctx, "extracting version from installer metadata"), + } + } + payload.Title = meta.Name if payload.Title == "" { // use the filename if no title from metadata @@ -664,9 +691,11 @@ func (svc *Service) addMetadataToSoftwarePayload(ctx context.Context, payload *f const maxInstallerSizeBytes int64 = 1024 * 1024 * 500 -func (svc *Service) BatchSetSoftwareInstallers(ctx context.Context, tmName string, payloads []fleet.SoftwareInstallerPayload, dryRun bool) error { +func (svc *Service) BatchSetSoftwareInstallers( + ctx context.Context, tmName string, payloads []fleet.SoftwareInstallerPayload, dryRun bool, +) ([]fleet.SoftwareInstaller, error) { if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil { - return err + return nil, err } var teamID *uint @@ -675,15 +704,29 @@ func (svc *Service) BatchSetSoftwareInstallers(ctx context.Context, tmName strin if err != nil { // If this is a dry run, the team may not have been created yet if dryRun && fleet.IsNotFound(err) { - return nil + return nil, nil } - return err + return nil, err } teamID = &tm.ID } if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: teamID}, fleet.ActionWrite); err != nil { - return ctxerr.Wrap(ctx, err, "validating authorization") + return nil, ctxerr.Wrap(ctx, err, "validating authorization") + } + + for _, payload := range payloads { + if len(payload.URL) > fleet.SoftwareInstallerURLMaxLength { + return nil, fleet.NewInvalidArgumentError( + "software.url", + "software URL is too long, must be less than 256 characters", + ) + } + } + + vc, ok := viewer.FromContext(ctx) + if !ok { + return nil, fleet.ErrNoContext } g, workerCtx := errgroup.WithContext(ctx) @@ -692,8 +735,6 @@ func (svc *Service) BatchSetSoftwareInstallers(ctx context.Context, tmName strin // goroutine only writes to its index. installers := make([]*fleet.UploadSoftwareInstallerPayload, len(payloads)) - client := fleethttp.NewClient() - client.Transport = fleethttp.NewSizeLimitTransport(maxInstallerSizeBytes) for i, p := range payloads { i, p := i, p @@ -706,6 +747,8 @@ func (svc *Service) BatchSetSoftwareInstallers(ctx context.Context, tmName strin fmt.Sprintf("Couldn't edit software. URL (%q) is invalid", p.URL), ) } + client := fleethttp.NewClient() + client.Transport = fleethttp.NewSizeLimitTransport(maxInstallerSizeBytes) req, err := http.NewRequestWithContext(workerCtx, http.MethodGet, p.URL, nil) if err != nil { @@ -762,6 +805,8 @@ func (svc *Service) BatchSetSoftwareInstallers(ctx context.Context, tmName strin PostInstallScript: p.PostInstallScript, InstallerFile: bytes.NewReader(bodyBytes), SelfService: p.SelfService, + UserID: vc.UserID(), + URL: p.URL, } // set the filename before adding metadata, as it is used as fallback @@ -803,27 +848,28 @@ func (svc *Service) BatchSetSoftwareInstallers(ctx context.Context, tmName strin if err := g.Wait(); err != nil { // NOTE: intentionally not wrapping to avoid polluting user // errors. - return err + return nil, err } if dryRun { - return nil + return nil, nil } for _, payload := range installers { if err := svc.storeSoftware(ctx, payload); err != nil { - return ctxerr.Wrap(ctx, err, "storing software installer") + return nil, ctxerr.Wrap(ctx, err, "storing software installer") } } - if err := svc.ds.BatchSetSoftwareInstallers(ctx, teamID, installers); err != nil { - return ctxerr.Wrap(ctx, err, "batch set software installers") + insertedSoftwareInstallers, err := svc.ds.BatchSetSoftwareInstallers(ctx, teamID, installers) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "batch set software installers") } // Note: per @noahtalerman we don't want activity items for CLI actions // anymore, so that's intentionally skipped. - return nil + return insertedSoftwareInstallers, nil } func (svc *Service) SelfServiceInstallSoftwareTitle(ctx context.Context, host *fleet.Host, softwareTitleID uint) error { diff --git a/ee/server/service/teams.go b/ee/server/service/teams.go index f7dcbec15de7..448abc367401 100644 --- a/ee/server/service/teams.go +++ b/ee/server/service/teams.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "net/url" + "strings" "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/server" @@ -73,6 +74,13 @@ func (svc *Service) NewTeam(ctx context.Context, p fleet.TeamPayload) (*fleet.Te if *p.Name == "" { return nil, fleet.NewInvalidArgumentError("name", "may not be empty") } + l := strings.ToLower(*p.Name) + if l == strings.ToLower(fleet.ReservedNameAllTeams) { + return nil, fleet.NewInvalidArgumentError("name", `"All teams" is a reserved team name`) + } + if l == strings.ToLower(fleet.ReservedNameNoTeam) { + return nil, fleet.NewInvalidArgumentError("name", `"No team" is a reserved team name`) + } team.Name = *p.Name if p.Description != nil { @@ -129,6 +137,13 @@ func (svc *Service) ModifyTeam(ctx context.Context, teamID uint, payload fleet.T if *payload.Name == "" { return nil, fleet.NewInvalidArgumentError("name", "may not be empty") } + l := strings.ToLower(*payload.Name) + if l == strings.ToLower(fleet.ReservedNameAllTeams) { + return nil, fleet.NewInvalidArgumentError("name", `"All teams" is a reserved team name`) + } + if l == strings.ToLower(fleet.ReservedNameNoTeam) { + return nil, fleet.NewInvalidArgumentError("name", `"No team" is a reserved team name`) + } team.Name = *payload.Name } if payload.Description != nil { @@ -612,7 +627,7 @@ func (svc *Service) DeleteTeam(ctx context.Context, teamID uint) error { } if len(hostIDs) > 0 { - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, hostIDs, nil, nil, nil); err != nil { + if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, hostIDs, nil, nil, nil); err != nil { return ctxerr.Wrap(ctx, err, "bulk set pending host profiles") } @@ -860,6 +875,14 @@ func (svc *Service) ApplyTeamSpecs(ctx context.Context, specs []*fleet.TeamSpec, } } + l := strings.ToLower(spec.Name) + if l == strings.ToLower(fleet.ReservedNameAllTeams) { + return nil, fleet.NewInvalidArgumentError("name", `"All teams" is a reserved team name`) + } + if l == strings.ToLower(fleet.ReservedNameNoTeam) { + return nil, fleet.NewInvalidArgumentError("name", `"No team" is a reserved team name`) + } + var team *fleet.Team // If filename is provided, try to find the team by filename first. // This is needed in case user is trying to modify the team name. @@ -883,6 +906,7 @@ func (svc *Service) ApplyTeamSpecs(ctx context.Context, specs []*fleet.TeamSpec, } } } + var create bool if team == nil { team, err = svc.ds.TeamByName(ctx, spec.Name) diff --git a/ee/server/service/vpp.go b/ee/server/service/vpp.go index 52f4c1f89080..b03291ff4693 100644 --- a/ee/server/service/vpp.go +++ b/ee/server/service/vpp.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "io" "net/http" "sort" "strings" @@ -17,6 +18,9 @@ import ( "github.com/fleetdm/fleet/v4/server/mdm/apple/vpp" ) +// Used for overriding the env var value in testing +var testSetEmptyPrivateKey bool + // getVPPToken returns the base64 encoded VPP token, ready for use in requests to Apple's VPP API. // It returns an error if the token is expired. func (svc *Service) getVPPToken(ctx context.Context, teamID *uint) (string, error) { @@ -148,7 +152,10 @@ func (svc *Service) BatchAssociateVPPApps(ctx context.Context, teamName string, } if err := svc.ds.SetTeamVPPApps(ctx, &team.ID, vppAppTeams); err != nil { - return fleet.NewUserMessageError(ctxerr.Wrap(ctx, err, "set team vpp assets"), http.StatusInternalServerError) + if errors.Is(err, sql.ErrNoRows) { + return fleet.NewUserMessageError(ctxerr.Wrap(ctx, err, "no vpp token to set team vpp assets"), http.StatusUnprocessableEntity) + } + return ctxerr.Wrap(ctx, err, "set team vpp assets") } } @@ -413,3 +420,143 @@ func getVPPAppsMetadata(ctx context.Context, ids []fleet.VPPAppTeam) ([]*fleet.V return apps, nil } + +func (svc *Service) UploadVPPToken(ctx context.Context, token io.ReadSeeker) (*fleet.VPPTokenDB, error) { + if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionWrite); err != nil { + return nil, err + } + + privateKey := svc.config.Server.PrivateKey + if testSetEmptyPrivateKey { + privateKey = "" + } + + if len(privateKey) == 0 { + return nil, ctxerr.New(ctx, "Couldn't upload content token. Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key") + } + + if token == nil { + return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("token", "Invalid token. Please provide a valid content token from Apple Business Manager.")) + } + + tokenBytes, err := io.ReadAll(token) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "reading VPP token") + } + + locName, err := vpp.GetConfig(string(tokenBytes)) + if err != nil { + var vppErr *vpp.ErrorResponse + if errors.As(err, &vppErr) { + // Per https://developer.apple.com/documentation/devicemanagement/app_and_book_management/app_and_book_management_legacy/interpreting_error_codes + if vppErr.ErrorNumber == 9622 { + return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("token", "Invalid token. Please provide a valid content token from Apple Business Manager.")) + } + } + return nil, ctxerr.Wrap(ctx, err, "validating VPP token with Apple") + } + + data := fleet.VPPTokenData{ + Token: string(tokenBytes), + Location: locName, + } + + tok, err := svc.ds.InsertVPPToken(ctx, &data) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "writing VPP token to db") + } + + if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityEnabledVPP{ + Location: locName, + }); err != nil { + return nil, ctxerr.Wrap(ctx, err, "create activity for upload VPP token") + } + + return tok, nil +} + +func (svc *Service) UpdateVPPToken(ctx context.Context, tokenID uint, token io.ReadSeeker) (*fleet.VPPTokenDB, error) { + if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionWrite); err != nil { + return nil, err + } + + privateKey := svc.config.Server.PrivateKey + if testSetEmptyPrivateKey { + privateKey = "" + } + + if len(privateKey) == 0 { + return nil, ctxerr.New(ctx, "Couldn't upload content token. Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key") + } + + if token == nil { + return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("token", "Invalid token. Please provide a valid content token from Apple Business Manager.")) + } + + tokenBytes, err := io.ReadAll(token) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "reading VPP token") + } + + locName, err := vpp.GetConfig(string(tokenBytes)) + if err != nil { + var vppErr *vpp.ErrorResponse + if errors.As(err, &vppErr) { + // Per https://developer.apple.com/documentation/devicemanagement/app_and_book_management/app_and_book_management_legacy/interpreting_error_codes + if vppErr.ErrorNumber == 9622 { + return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("token", "Invalid token. Please provide a valid content token from Apple Business Manager.")) + } + } + return nil, ctxerr.Wrap(ctx, err, "validating VPP token with Apple") + } + + data := fleet.VPPTokenData{ + Token: string(tokenBytes), + Location: locName, + } + + tok, err := svc.ds.UpdateVPPToken(ctx, tokenID, &data) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "updating vpp token") + } + + return tok, nil +} + +func (svc *Service) UpdateVPPTokenTeams(ctx context.Context, tokenID uint, teamIDs []uint) (*fleet.VPPTokenDB, error) { + if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionWrite); err != nil { + return nil, err + } + + tok, err := svc.ds.UpdateVPPTokenTeams(ctx, tokenID, teamIDs) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "updating vpp token team") + } + + return tok, nil +} + +func (svc *Service) GetVPPTokens(ctx context.Context) ([]*fleet.VPPTokenDB, error) { + if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionRead); err != nil { + return nil, err + } + + return svc.ds.ListVPPTokens(ctx) +} + +func (svc *Service) DeleteVPPToken(ctx context.Context, tokenID uint) error { + if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionWrite); err != nil { + return err + } + tok, err := svc.ds.GetVPPToken(ctx, tokenID) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting vpp token") + } + if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityDisabledVPP{ + Location: tok.Location, + }); err != nil { + return ctxerr.Wrap(ctx, err, "create activity for delete VPP token") + } + + return svc.ds.DeleteVPPToken(ctx, tokenID) +} diff --git a/frontend/__mocks__/policyMock.ts b/frontend/__mocks__/policyMock.ts index b14095463ec6..ba60c55c74db 100644 --- a/frontend/__mocks__/policyMock.ts +++ b/frontend/__mocks__/policyMock.ts @@ -23,6 +23,10 @@ const DEFAULT_POLICY_MOCK: IPolicyStats = { has_run: true, next_update_ms: 3600000, calendar_events_enabled: true, + install_software: { + name: "testSw0", + software_title_id: 1, + }, }; const createMockPolicy = (overrides?: Partial<IPolicyStats>): IPolicyStats => { diff --git a/frontend/__mocks__/softwareMock.ts b/frontend/__mocks__/softwareMock.ts index 02a5f2d185a9..9ef0b14e9a0e 100644 --- a/frontend/__mocks__/softwareMock.ts +++ b/frontend/__mocks__/softwareMock.ts @@ -14,6 +14,8 @@ import { ISoftwareVersionsResponse, ISoftwareVersionResponse, } from "services/entities/software"; +import { IOSVersionsResponse } from "../services/entities/operating_systems"; +import { IOperatingSystemVersion } from "../interfaces/operating_system"; const DEFAULT_SOFTWARE_MOCK: ISoftware = { hosts_count: 1, @@ -93,12 +95,48 @@ const DEFAULT_SOFTWARE_VERSIONS_RESPONSE_MOCK: ISoftwareVersionsResponse = { }, }; -export const createMockSoftwareVersionsReponse = ( +export const createMockSoftwareVersionsResponse = ( overrides?: Partial<ISoftwareVersionsResponse> ): ISoftwareVersionsResponse => { return { ...DEFAULT_SOFTWARE_VERSIONS_RESPONSE_MOCK, ...overrides }; }; +const DEFAULT_OS_VERSION_MOCK = { + os_version_id: 1, + name: "macOS 14.6.1", + name_only: "macOS", + version: "14.6.1", + platform: "darwin", + hosts_count: 42, + generated_cpes: [], + vulnerabilities: [], +}; + +export const createMockOSVersion = ( + overrides?: Partial<IOperatingSystemVersion> +): IOperatingSystemVersion => { + return { + ...DEFAULT_OS_VERSION_MOCK, + ...overrides, + }; +}; + +const DEFAULT_OS_VERSIONS_RESPONSE_MOCK: IOSVersionsResponse = { + counts_updated_at: "2020-01-01T00:00:00.000Z", + count: 1, + os_versions: [createMockOSVersion()], + meta: { + has_next_results: false, + has_previous_results: false, + }, +}; + +export const createMockOSVersionsResponse = ( + overrides?: Partial<IOSVersionsResponse> +): IOSVersionsResponse => { + return { ...DEFAULT_OS_VERSIONS_RESPONSE_MOCK, ...overrides }; +}; + const DEFAULT_APP_STORE_APP_MOCK: IAppStoreApp = { name: "test app", app_store_id: 1, @@ -208,7 +246,7 @@ const DEFAULT_SOFTWARE_TITLES_RESPONSE_MOCK: ISoftwareTitlesResponse = { }, }; -export const createMockSoftwareTitlesReponse = ( +export const createMockSoftwareTitlesResponse = ( overrides?: Partial<ISoftwareTitlesResponse> ): ISoftwareTitlesResponse => { return { ...DEFAULT_SOFTWARE_TITLES_RESPONSE_MOCK, ...overrides }; diff --git a/frontend/components/AddHostsModal/AddHostsModal.tests.tsx b/frontend/components/AddHostsModal/AddHostsModal.tests.tsx index 658bbd11882e..2af31091f6b8 100644 --- a/frontend/components/AddHostsModal/AddHostsModal.tests.tsx +++ b/frontend/components/AddHostsModal/AddHostsModal.tests.tsx @@ -73,8 +73,9 @@ describe("AddHostsModal", () => { expect(screen.queryByText(/--enable-scripts/i)).not.toBeInTheDocument(); await user.click(screen.getByRole("tab", { name: "iOS & iPadOS" })); - expect(screen.queryByText(/Apple Business Manager/i)).toBeInTheDocument(); - expect(screen.queryByText(/Learn more/i)).toBeInTheDocument(); + expect( + screen.queryByText(/Send this to your end users:/i) + ).toBeInTheDocument(); await user.click(screen.getByRole("tab", { name: "Advanced" })); const advancedText = screen.getByText(/--type=YOUR_TYPE/i); diff --git a/frontend/components/AddHostsModal/AddHostsModal.tsx b/frontend/components/AddHostsModal/AddHostsModal.tsx index 04a42908ed2e..1cac7712099c 100644 --- a/frontend/components/AddHostsModal/AddHostsModal.tsx +++ b/frontend/components/AddHostsModal/AddHostsModal.tsx @@ -9,7 +9,6 @@ import Modal from "components/Modal"; import Spinner from "components/Spinner"; import PlatformWrapper from "./PlatformWrapper/PlatformWrapper"; -import DownloadInstallers from "./DownloadInstallers/DownloadInstallers"; const baseClass = "add-hosts-modal"; diff --git a/frontend/components/AddHostsModal/PlatformWrapper/IosIpadosPanel/IosIpadosPanel.tsx b/frontend/components/AddHostsModal/PlatformWrapper/IosIpadosPanel/IosIpadosPanel.tsx new file mode 100644 index 000000000000..40d991e2f841 --- /dev/null +++ b/frontend/components/AddHostsModal/PlatformWrapper/IosIpadosPanel/IosIpadosPanel.tsx @@ -0,0 +1,46 @@ +import React, { useContext } from "react"; + +import { AppContext } from "context/app"; + +// @ts-ignore +import InputField from "components/forms/fields/InputField"; + +const generateUrl = (serverUrl: string, enrollSecret: string) => { + return `${serverUrl}/enroll?enroll_secret=${enrollSecret}`; +}; + +const baseClass = "ios-ipados-panel"; + +interface IosIpadosPanelProps { + enrollSecret: string; +} + +const IosIpadosPanel = ({ enrollSecret }: IosIpadosPanelProps) => { + const { config } = useContext(AppContext); + + const helpText = + "When the end user navigates to this URL, the enrollment profile " + + "will download in their browser. End users will have to install the profile " + + "to enroll to Fleet."; + + if (!config) return null; + + const url = generateUrl(config.server_settings.server_url, enrollSecret); + + return ( + <div className={baseClass}> + <InputField + label="Send this to your end users:" + enableCopy + copyButtonPosition="inside" + readOnly + inputWrapperClass + name="enroll-link" + value={url} + helpText={helpText} + /> + </div> + ); +}; + +export default IosIpadosPanel; diff --git a/frontend/components/AddHostsModal/PlatformWrapper/IosIpadosPanel/_styles.scss b/frontend/components/AddHostsModal/PlatformWrapper/IosIpadosPanel/_styles.scss new file mode 100644 index 000000000000..e781e7ca7072 --- /dev/null +++ b/frontend/components/AddHostsModal/PlatformWrapper/IosIpadosPanel/_styles.scss @@ -0,0 +1,5 @@ +.ios-ipados-panel { + &__spinner { + margin: $pad-xxlarge auto; + } +} diff --git a/frontend/components/AddHostsModal/PlatformWrapper/IosIpadosPanel/index.ts b/frontend/components/AddHostsModal/PlatformWrapper/IosIpadosPanel/index.ts new file mode 100644 index 000000000000..4dc628ef88a5 --- /dev/null +++ b/frontend/components/AddHostsModal/PlatformWrapper/IosIpadosPanel/index.ts @@ -0,0 +1 @@ +export { default } from "./IosIpadosPanel"; diff --git a/frontend/components/AddHostsModal/PlatformWrapper/PlatformWrapper.tsx b/frontend/components/AddHostsModal/PlatformWrapper/PlatformWrapper.tsx index f10fbead552b..a2a8ccb1d1c3 100644 --- a/frontend/components/AddHostsModal/PlatformWrapper/PlatformWrapper.tsx +++ b/frontend/components/AddHostsModal/PlatformWrapper/PlatformWrapper.tsx @@ -19,6 +19,7 @@ import InfoBanner from "components/InfoBanner/InfoBanner"; import CustomLink from "components/CustomLink/CustomLink"; import { isValidPemCertificate } from "../../../pages/hosts/ManageHostsPage/helpers"; +import IosIpadosPanel from "./IosIpadosPanel"; interface IPlatformSubNav { name: string; @@ -324,7 +325,7 @@ const PlatformWrapper = ({ ); }; - const renderTab = (packageType: string) => { + const renderPanel = (packageType: string) => { const CHROME_OS_INFO = { extensionId: "fleeedmmihkfkeemmipgmhhjemlljidg", installationUrl: "https://chrome.fleetdm.com/updates.xml", @@ -395,19 +396,7 @@ const PlatformWrapper = ({ } if (packageType === "ios-ipados") { - return ( - <div className={`${baseClass}__ios-ipados--info`}> - <p> - Enroll iPhones and iPads by adding them to Fleet in Apple Business - Manager (ABM).{" "} - <CustomLink - url="https://fleetdm.com/learn-more-about/setup-abm" - text="Learn more" - newTab - /> - </p> - </div> - ); + return <IosIpadosPanel enrollSecret={enrollSecret} />; } if (packageType === "advanced") { @@ -590,7 +579,7 @@ const PlatformWrapper = ({ return ( <TabPanel className={`${baseClass}__info`} key={navItem.type}> <div className={`${baseClass} form`}> - {renderTab(navItem.type)} + {renderPanel(navItem.type)} </div> </TabPanel> ); diff --git a/frontend/components/Editor/Editor.tsx b/frontend/components/Editor/Editor.tsx index e8f230e3799d..b724a296c39e 100644 --- a/frontend/components/Editor/Editor.tsx +++ b/frontend/components/Editor/Editor.tsx @@ -29,6 +29,10 @@ interface IEditorProps { * @default "editor" */ name?: string; + /** Include correct styles as a form field. + * @default false + */ + isFormField?: boolean; maxLines?: number; className?: string; onChange?: (value: string, event?: any) => void; @@ -52,11 +56,13 @@ const Editor = ({ readOnly = false, wrapEnabled = false, name = "editor", + isFormField = false, maxLines = 20, className, onChange, }: IEditorProps) => { const classNames = classnames(baseClass, className, { + "form-field": isFormField, [`${baseClass}__error`]: !!error, }); diff --git a/frontend/components/Editor/_styles.scss b/frontend/components/Editor/_styles.scss index 676172697dd5..22fbacfd337c 100644 --- a/frontend/components/Editor/_styles.scss +++ b/frontend/components/Editor/_styles.scss @@ -3,7 +3,6 @@ &__label { font-size: $x-small; font-weight: $bold; - margin-bottom: $pad-small; &--error { color: $core-vibrant-red; diff --git a/frontend/components/FleetAce/FleetAce.tsx b/frontend/components/FleetAce/FleetAce.tsx index c30232cb596b..b0422d4cde21 100644 --- a/frontend/components/FleetAce/FleetAce.tsx +++ b/frontend/components/FleetAce/FleetAce.tsx @@ -29,6 +29,7 @@ export interface IFleetAceProps { label?: string; name?: string; value?: string; + placeholder?: string; readOnly?: boolean; maxLines?: number; showGutter?: boolean; @@ -55,6 +56,7 @@ const FleetAce = ({ labelActionComponent, name = "query-editor", value, + placeholder, readOnly, maxLines = 20, showGutter = true, @@ -266,6 +268,7 @@ const FleetAce = ({ showPrintMargin={false} theme="fleet" value={value} + placeholder={placeholder} width="100%" wrapEnabled={wrapEnabled} style={style} diff --git a/frontend/components/FleetAce/_styles.scss b/frontend/components/FleetAce/_styles.scss index c12237a3a771..f9f0dccf8960 100644 --- a/frontend/components/FleetAce/_styles.scss +++ b/frontend/components/FleetAce/_styles.scss @@ -25,6 +25,16 @@ } } + .ace_content { + padding-left: 4px; + } + + .ace_placeholder { + font-family: "SourceCodePro", $monospace; + margin: initial; + font-size: 15px; + } + &__help-text { @include help-text; diff --git a/frontend/components/forms/fields/Dropdown/Dropdown.jsx b/frontend/components/forms/fields/Dropdown/Dropdown.jsx index 1edde66fec07..302233e5d674 100644 --- a/frontend/components/forms/fields/Dropdown/Dropdown.jsx +++ b/frontend/components/forms/fields/Dropdown/Dropdown.jsx @@ -27,6 +27,19 @@ class Dropdown extends Component { onClose: PropTypes.func, options: PropTypes.arrayOf(dropdownOptionInterface).isRequired, placeholder: PropTypes.oneOfType([PropTypes.array, PropTypes.string]), + /** + value must correspond to the value of a dropdown option to render + e.g. with options: + + [ + { + label: "Display name", + value: 1, <– the id of the thing + } + ] + + set value to 1, not "Display name" + */ value: PropTypes.oneOfType([ PropTypes.array, PropTypes.string, @@ -75,7 +88,7 @@ class Dropdown extends Component { const { multi, onChange, clearable, name, parseTarget } = this.props; if (parseTarget) { - // Returns both name and value + // Returns both name of the Dropdown and value of the selected option return onChange({ value: selected.value, name }); } diff --git a/frontend/components/forms/fields/InputField/InputField.jsx b/frontend/components/forms/fields/InputField/InputField.jsx index 09ffad2fa4af..758ef70ab4c0 100644 --- a/frontend/components/forms/fields/InputField/InputField.jsx +++ b/frontend/components/forms/fields/InputField/InputField.jsx @@ -43,6 +43,7 @@ class InputField extends Component { PropTypes.object, ]), enableCopy: PropTypes.bool, + copyButtonPosition: PropTypes.oneOfType(["inside", "outside"]), ignore1password: PropTypes.bool, }; @@ -62,6 +63,7 @@ class InputField extends Component { labelTooltipPosition: undefined, helpText: "", enableCopy: false, + copyButtonPosition: "outside", ignore1password: false, }; @@ -97,6 +99,59 @@ class InputField extends Component { return onChange(value); }; + renderCopyButton = () => { + const { value, copyButtonPosition } = this.props; + + const copyValue = (e) => { + e.preventDefault(); + stringToClipboard(value).then(() => { + this.setState({ copied: true }); + setTimeout(() => { + this.setState({ copied: false }); + }, 2000); + }); + }; + + const copyButtonValue = + copyButtonPosition === "outside" ? ( + <> + <Icon name="copy" /> + <span>Copy</span> + </> + ) : ( + <Icon name="copy" /> + ); + + const wrapperClasses = classnames( + `${baseClass}__copy-wrapper`, + copyButtonPosition === "outside" + ? `${baseClass}__copy-wrapper-outside` + : `${baseClass}__copy-wrapper-inside` + ); + + const copiedConfirmationClasses = classnames( + `${baseClass}__copied-confirmation`, + copyButtonPosition === "outside" + ? `${baseClass}__copied-confirmation-outside` + : `${baseClass}__copied-confirmation-inside` + ); + + return ( + <div className={wrapperClasses}> + <Button + variant="text-icon" + onClick={copyValue} + className={`${baseClass}__copy-value-button`} + > + {copyButtonValue} + </Button> + {this.state.copied && ( + <span className={copiedConfirmationClasses}>Copied!</span> + )} + </div> + ); + }; + render() { const { readOnly, @@ -113,6 +168,8 @@ class InputField extends Component { blockAutoComplete, value, ignore1password, + enableCopy, + copyButtonPosition, } = this.props; const { onInputChange } = this; @@ -139,16 +196,6 @@ class InputField extends Component { "labelTooltipPosition", ]); - const copyValue = (e) => { - e.preventDefault(); - stringToClipboard(value).then(() => { - this.setState({ copied: true }); - setTimeout(() => { - this.setState({ copied: false }); - }, 2000); - }); - }; - if (type === "textarea") { return ( <FormField @@ -175,7 +222,9 @@ class InputField extends Component { } const inputContainerClasses = classnames(`${baseClass}__input-container`, { - "copy-enabled": this.props.enableCopy, + "copy-enabled": enableCopy, + "copy-outside": enableCopy && copyButtonPosition === "outside", + "copy-inside": enableCopy && copyButtonPosition === "inside", }); return ( @@ -203,22 +252,8 @@ class InputField extends Component { autoComplete={blockAutoComplete ? "new-password" : ""} data-1p-ignore={ignore1password} /> - {this.props.enableCopy && ( - <div className={`${baseClass}__copy-wrapper`}> - <Button - variant="text-icon" - onClick={copyValue} - className={`${baseClass}__copy-value-button`} - > - <Icon name="copy" /> Copy - </Button> - {this.state.copied && ( - <span className={`${baseClass}__copied-confirmation`}> - Copied! - </span> - )} - </div> - )} + + {enableCopy && this.renderCopyButton()} </div> </FormField> ); diff --git a/frontend/components/forms/fields/InputField/InputField.stories.jsx b/frontend/components/forms/fields/InputField/InputField.stories.jsx index 09ccb5a19702..7b27aa435c2d 100644 --- a/frontend/components/forms/fields/InputField/InputField.stories.jsx +++ b/frontend/components/forms/fields/InputField/InputField.stories.jsx @@ -8,3 +8,16 @@ const meta = { export default meta; export const Basic = {}; + +export const WithCopyEnabled = { + args: { + enableCopy: true, + }, +}; + +export const WithCopyEnabledInsideInput = { + args: { + enableCopy: true, + copyButtonPosition: "inside", + }, +}; diff --git a/frontend/components/forms/fields/InputField/_styles.scss b/frontend/components/forms/fields/InputField/_styles.scss index caf6c237133d..c70b7551756a 100644 --- a/frontend/components/forms/fields/InputField/_styles.scss +++ b/frontend/components/forms/fields/InputField/_styles.scss @@ -86,17 +86,32 @@ } } - &__copy-wrapper { + &__copy-wrapper-outside { position: relative; } + &__copy-wrapper-inside { + position: absolute; + top: 50%; + transform: translateY(-50%); + right: 12px; + } + + &__input-container.copy-enabled { - display: flex; - align-items: center; - gap: $pad-medium; + &.copy-outside { + display: flex; + align-items: center; + gap: $pad-medium; + } + + &.copy-inside { + position: relative; + } } &__copied-confirmation { + font-size: $x-small; position: absolute; background-color: $ui-light-grey; border: solid 1px $ui-fleet-black-10; @@ -104,6 +119,13 @@ padding: $pad-xxsmall 6px; top: 50%; transform: translateY(-50%); + } + + &__copied-confirmation-inside { + right: 24px; + } + + &__copied-confirmation-outside { left: -90px; } } diff --git a/frontend/interfaces/policy.ts b/frontend/interfaces/policy.ts index 41586ea22da9..621c52f63872 100644 --- a/frontend/interfaces/policy.ts +++ b/frontend/interfaces/policy.ts @@ -41,6 +41,12 @@ export interface IPolicy { updated_at: string; critical: boolean; calendar_events_enabled: boolean; + install_software?: IPolicySoftwareToInstall; +} + +export interface IPolicySoftwareToInstall { + name: string; + software_title_id: number; } // Used on the manage hosts page and other places where aggregate stats are displayed @@ -94,6 +100,8 @@ export interface IPolicyFormData { team_id?: number | null; id?: number; calendar_events_enabled?: boolean; + // undefined from GET/LIST when not set, null for PATCH to unset + software_title_id?: number | null; } export interface IPolicyNew { diff --git a/frontend/interfaces/software.ts b/frontend/interfaces/software.ts index bf6ba786a17e..9d6d3617b2fb 100644 --- a/frontend/interfaces/software.ts +++ b/frontend/interfaces/software.ts @@ -109,6 +109,7 @@ export interface ISoftwareTitleDetails { source: string; // "apps" | "ios_apps" | "ipados_apps" | ? hosts_count: number; versions: ISoftwareTitleVersion[] | null; + versions_updated_at?: string; bundle_identifier?: string; browser?: string; versions_count?: number; diff --git a/frontend/pages/DashboardPage/cards/OperatingSystems/OSTable.tests.tsx b/frontend/pages/DashboardPage/cards/OperatingSystems/OSTable.tests.tsx index eb2672562e4b..934bd5db442c 100644 --- a/frontend/pages/DashboardPage/cards/OperatingSystems/OSTable.tests.tsx +++ b/frontend/pages/DashboardPage/cards/OperatingSystems/OSTable.tests.tsx @@ -4,7 +4,7 @@ import { render, screen } from "@testing-library/react"; import OSTable from "./OSTable"; describe("Dashboard OS table", () => { - it("renders data normally when present", async () => { + it("renders data normally when present", () => { render( <OSTable currentTeamId={undefined} diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/components/AddProfileModal/AddProfileModal.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/components/AddProfileModal/AddProfileModal.tsx index 12191a96418f..694970c4ca1a 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/components/AddProfileModal/AddProfileModal.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/ProfileUploader/components/AddProfileModal/AddProfileModal.tsx @@ -77,7 +77,7 @@ interface IFileDetailsProps { // TODO: if we reuse this one more time, we should consider moving this // into FileUploader as a default preview. Currently we have this in -// AddSoftwareForm.tsx and here. +// AddPackageForm.tsx and here. const FileDetails = ({ details: { name, platform } }: IFileDetailsProps) => ( <div className={`${baseClass}__selected-file`}> <ProfileGraphic baseClass={baseClass} /> diff --git a/frontend/pages/SoftwarePage/SoftwareOS/SoftwareOSTable/SoftwareOSTable.tests.tsx b/frontend/pages/SoftwarePage/SoftwareOS/SoftwareOSTable/SoftwareOSTable.tests.tsx new file mode 100644 index 000000000000..21a59614b279 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareOS/SoftwareOSTable/SoftwareOSTable.tests.tsx @@ -0,0 +1,70 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; + +import { createMockOSVersionsResponse } from "__mocks__/softwareMock"; + +import SoftwareOSTable from "./SoftwareOSTable"; + +// TODO: figure out how to mock the router properly. +const mockRouter = { + push: jest.fn(), + replace: jest.fn(), + goBack: jest.fn(), + goForward: jest.fn(), + go: jest.fn(), + setRouteLeaveHook: jest.fn(), + isActive: jest.fn(), + createHref: jest.fn(), + createPath: jest.fn(), +}; + +describe("Software operating systems table", () => { + it("Renders the page-wide disabled state when software inventory is disabled", async () => { + render( + <SoftwareOSTable + router={mockRouter} + isSoftwareEnabled={false} // Set to false + data={createMockOSVersionsResponse({ + count: 0, + os_versions: [], + })} + perPage={20} + orderDirection="asc" + orderKey="hosts_count" + currentPage={0} + teamId={1} + isLoading={false} + resetPageIndex={false} + /> + ); + + expect(screen.getByText("Software inventory disabled")).toBeInTheDocument(); + }); + + it("Renders the page-wide empty state when no software is present", () => { + render( + <SoftwareOSTable + router={mockRouter} + isSoftwareEnabled + data={createMockOSVersionsResponse({ + count: 0, + os_versions: [], + })} + perPage={20} + orderDirection="asc" + orderKey="hosts_count" + currentPage={0} + teamId={1} + isLoading={false} + resetPageIndex={false} + /> + ); + + expect( + screen.getByText("No operating systems detected") + ).toBeInTheDocument(); + expect(screen.getByText("0 items")).toBeInTheDocument(); + expect(screen.queryByText("Search")).toBeNull(); + expect(screen.queryByText("Updated")).toBeNull(); + }); +}); diff --git a/frontend/pages/SoftwarePage/SoftwareOS/SoftwareOSTable/SoftwareOSTable.tsx b/frontend/pages/SoftwarePage/SoftwareOS/SoftwareOSTable/SoftwareOSTable.tsx index b04f99fbe5e1..9be6ee4d1626 100644 --- a/frontend/pages/SoftwarePage/SoftwareOS/SoftwareOSTable/SoftwareOSTable.tsx +++ b/frontend/pages/SoftwarePage/SoftwareOS/SoftwareOSTable/SoftwareOSTable.tsx @@ -129,7 +129,7 @@ const SoftwareOSTable = ({ }; const renderSoftwareCount = () => { - if (!data?.os_versions || !data?.count) return null; + if (!data) return null; return ( <> diff --git a/frontend/pages/SoftwarePage/SoftwarePage.tsx b/frontend/pages/SoftwarePage/SoftwarePage.tsx index 574c05367898..9dcf95250fe2 100644 --- a/frontend/pages/SoftwarePage/SoftwarePage.tsx +++ b/frontend/pages/SoftwarePage/SoftwarePage.tsx @@ -36,9 +36,10 @@ import ManageAutomationsModal from "./components/ManageSoftwareAutomationsModal" import AddSoftwareModal from "./components/AddSoftwareModal"; import { buildSoftwareFilterQueryParams, + buildSoftwareVulnFiltersQueryParams, getSoftwareFilterFromQueryParams, getSoftwareVulnFiltersFromQueryParams, - ISoftwareVulnFilters, + ISoftwareVulnFiltersParams, } from "./SoftwareTitles/SoftwareTable/helpers"; import SoftwareFiltersModal from "./components/SoftwareFiltersModal"; @@ -308,7 +309,7 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => { [handleTeamChange] ); - const onApplyVulnFilters = (vulnFilters: ISoftwareVulnFilters) => { + const onApplyVulnFilters = (vulnFilters: ISoftwareVulnFiltersParams) => { const newQueryParams: ISoftwareApiParams = { query, teamId: currentTeamId, @@ -316,7 +317,7 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => { orderKey: sortHeader, page: 0, // resets page index ...buildSoftwareFilterQueryParams(softwareFilter), - ...vulnFilters, + ...buildSoftwareVulnFiltersQueryParams(vulnFilters), }; router.replace( diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AdvancedOptionsModal/AdvancedOptionsModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AdvancedOptionsModal/AdvancedOptionsModal.tsx index b3f7c31146b5..5cfc313e4416 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AdvancedOptionsModal/AdvancedOptionsModal.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AdvancedOptionsModal/AdvancedOptionsModal.tsx @@ -38,6 +38,7 @@ const AdvancedOptionsModal = ({ helpText="Fleet will run this command on hosts to install software." label="Install script" labelTooltip="For security agents, add the script provided by the vendor." + isFormField /> {preInstallQuery && ( <div className={`${baseClass}__input-field`}> @@ -72,6 +73,7 @@ const AdvancedOptionsModal = ({ maxLines={10} value={postInstallScript} helpText="Shell (macOS and Linux) or PowerShell (Windows)." + isFormField /> </div> )} diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx index 23d03c58521c..fa75f1d5541d 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx @@ -3,11 +3,15 @@ import React, { useCallback, useContext } from "react"; import softwareAPI from "services/entities/software"; import { NotificationContext } from "context/notification"; +import { getErrorReason } from "interfaces/errors"; + import Modal from "components/Modal"; import Button from "components/buttons/Button"; const baseClass = "delete-software-modal"; +const DELETE_SW_USED_BY_POLICY_ERROR_MSG = + "Couldn't delete. Policy automation uses this software. Please disable policy automation for this software and try again."; interface IDeleteSoftwareModalProps { softwareId: number; teamId: number; @@ -28,8 +32,13 @@ const DeleteSoftwareModal = ({ await softwareAPI.deleteSoftwarePackage(softwareId, teamId); renderFlash("success", "Software deleted successfully!"); onSuccess(); - } catch { - renderFlash("error", "Couldn't delete. Please try again."); + } catch (error) { + const reason = getErrorReason(error); + if (reason.includes("Policy automation uses this software")) { + renderFlash("error", DELETE_SW_USED_BY_POLICY_ERROR_MSG); + } else { + renderFlash("error", "Couldn't delete. Please try again."); + } } onExit(); }, [softwareId, teamId, renderFlash, onSuccess, onExit]); diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx index 8453abfbb6bf..1c6a31e9d769 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx @@ -4,8 +4,6 @@ import React, { useLayoutEffect, useState, } from "react"; -import FileSaver from "file-saver"; -import { parse } from "content-disposition"; import PATHS from "router/paths"; import { AppContext } from "context/app"; @@ -45,10 +43,15 @@ function useTruncatedElement<T extends HTMLElement>(ref: React.RefObject<T>) { useLayoutEffect(() => { const element = ref.current; - if (element) { - const { scrollWidth, clientWidth } = element; - setIsTruncated(scrollWidth > clientWidth); + function updateIsTruncated() { + if (element) { + const { scrollWidth, clientWidth } = element; + setIsTruncated(scrollWidth > clientWidth); + } } + window.addEventListener("resize", updateIsTruncated); + updateIsTruncated(); + return () => window.removeEventListener("resize", updateIsTruncated); }, [ref]); return isTruncated; @@ -92,20 +95,29 @@ const STATUS_DISPLAY_OPTIONS: Record< iconName: "success", tooltip: ( <> - Fleet installed software on these hosts. Currently, if the software is - uninstalled, the "Installed" status won't be updated. + Software is installed on these hosts (install script finished + <br /> + with exit code 0). Currently, if the software is uninstalled, the + <br /> + "installed" status won't be updated. </> ), }, pending: { displayName: "Pending", iconName: "pending-outline", - tooltip: "Fleet will install software when these hosts come online.", + tooltip: "Fleet is installing or will install when the host comes online.", }, failed: { displayName: "Failed", iconName: "error", - tooltip: "Fleet failed to install software on these hosts.", + tooltip: ( + <> + These hosts failed to install software. Click on a host to view + <br /> + error(s). + </> + ), }, }; @@ -130,16 +142,18 @@ const PackageStatusCount = ({ })}`; return ( <DataSet + className={`${baseClass}__status`} title={ <TooltipWrapper position="top" tipContent={displayData.tooltip} underline={false} showArrow + tipOffset={10} > <div className={`${baseClass}__status-title`}> <Icon name={displayData.iconName} /> - <span>{displayData.displayName}</span> + <div>{displayData.displayName}</div> </div> </TooltipWrapper> } @@ -305,7 +319,7 @@ const SoftwarePackageCard = ({ return ( <Card borderRadiusSize="xxlarge" includeShadow className={baseClass}> - <div className={`${baseClass}__main-content`}> + <div className={`${baseClass}__row-1`}> {/* TODO: main-info could be a seperate component as its reused on a couple pages already. Come back and pull this into a component */} <div className={`${baseClass}__main-info`}> @@ -315,46 +329,46 @@ const SoftwarePackageCard = ({ <span className={`${baseClass}__details`}>{renderDetails()}</span> </div> </div> - <div className={`${baseClass}__package-statuses`}> - <PackageStatusCount - softwareId={softwareId} - status="installed" - count={status.installed} - teamId={teamId} - /> - <PackageStatusCount - softwareId={softwareId} - status="pending" - count={status.pending} - teamId={teamId} - /> - <PackageStatusCount - softwareId={softwareId} - status="failed" - count={status.failed} - teamId={teamId} - /> + <div className={`${baseClass}__actions-wrapper`}> + {isSelfService && ( + <div className={`${baseClass}__self-service-badge`}> + <Icon + name="install-self-service" + size="small" + color="ui-fleet-black-75" + /> + Self-service + </div> + )} + {showActions && ( + <ActionsDropdown + isSoftwarePackage={!!softwarePackage} + onDownloadClick={onDownloadClick} + onDeleteClick={onDeleteClick} + onAdvancedOptionsClick={onAdvancedOptionsClick} + /> + )} </div> </div> - <div className={`${baseClass}__actions-wrapper`}> - {isSelfService && ( - <div className={`${baseClass}__self-service-badge`}> - <Icon - name="install-self-service" - size="small" - color="ui-fleet-black-75" - /> - Self-service - </div> - )} - {showActions && ( - <ActionsDropdown - isSoftwarePackage={!!softwarePackage} - onDownloadClick={onDownloadClick} - onDeleteClick={onDeleteClick} - onAdvancedOptionsClick={onAdvancedOptionsClick} - /> - )} + <div className={`${baseClass}__package-statuses`}> + <PackageStatusCount + softwareId={softwareId} + status="installed" + count={status.installed} + teamId={teamId} + /> + <PackageStatusCount + softwareId={softwareId} + status="pending" + count={status.pending} + teamId={teamId} + /> + <PackageStatusCount + softwareId={softwareId} + status="failed" + count={status.failed} + teamId={teamId} + /> </div> {showAdvancedOptionsModal && ( <AdvancedOptionsModal diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/_styles.scss b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/_styles.scss index 95d51c4c6ca5..f4c5f6a9e0b7 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/_styles.scss +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/_styles.scss @@ -1,13 +1,15 @@ .software-package-card { display: flex; + flex-direction: column; justify-content: space-between; align-items: center; gap: $pad-medium; - &__main-content { + &__row-1 { display: flex; - align-items: flex-start; - gap: $pad-xxlarge; + justify-content: space-between; + width: 100%; + gap: $pad-medium; } &__main-info { @@ -19,13 +21,14 @@ display: flex; flex-direction: column; gap: $pad-xsmall; + justify-content: center; } &__title { font-size: $x-small; font-weight: $bold; @include ellipse-text; - max-width: 290px; + max-width: 48vw; } &__details { @@ -34,13 +37,36 @@ &__package-statuses { display: flex; - gap: $pad-xxlarge; + align-items: flex-start; + align-self: stretch; + border-radius: 6px; + border: 1px solid $ui-fleet-black-10; + font-size: $x-small; } - &__status-title { + &__status { display: flex; + flex-direction: column; + padding: 16px 24px; + justify-content: center; align-items: center; - gap: $pad-xsmall; + flex: 1 0 0; + border-right: 1px solid var(--UI-Fleet-Black-10, #e2e4ea); + + &:last-child { + border-right: none; + } + + .react-tooltip { + text-align: center; + } + } + + &__status-title{ + display: flex; + flex-direction: column; + align-items: center; + gap: $pad-small; } &__status-count { @@ -66,6 +92,7 @@ color: $ui-fleet-black-75; font-size: $xx-small; font-weight: $bold; + white-space: nowrap; } &__actions { diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx index 51d0ed51796a..ae2084e6b726 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx @@ -208,6 +208,7 @@ const SoftwareTitleDetailsPage = ({ softwareTitle.source )} isAvailableForInstall={isAvailableForInstall} + countsUpdatedAt={softwareTitle.versions_updated_at} /> </Card> </> diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/SoftwareTitleDetailsTable.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/SoftwareTitleDetailsTable.tsx index 4eb9660e625a..6eaafa2a28e0 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/SoftwareTitleDetailsTable.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsTable/SoftwareTitleDetailsTable.tsx @@ -13,6 +13,7 @@ import TableContainer from "components/TableContainer"; import TableCount from "components/TableContainer/TableCount"; import EmptyTable from "components/EmptyTable"; import CustomLink from "components/CustomLink"; +import LastUpdatedText from "components/LastUpdatedText"; import generateSoftwareTitleDetailsTableConfig from "./SoftwareTitleDetailsTableConfig"; @@ -21,6 +22,21 @@ const DEFAULT_SORT_DIRECTION = "desc"; const baseClass = "software-title-details-table"; +const SoftwareLastUpdatedInfo = (lastUpdatedAt: string) => { + return ( + <LastUpdatedText + lastUpdatedAt={lastUpdatedAt} + customTooltipText={ + <> + The last time software data was <br /> + updated, including vulnerabilities <br /> + and host counts. + </> + } + /> + ); +}; + const NoVersionsDetected = (isAvailableForInstall = false): JSX.Element => { return ( <EmptyTable @@ -54,6 +70,7 @@ interface ISoftwareTitleDetailsTableProps { teamIdForApi?: number; isIPadOSOrIOSApp: boolean; isAvailableForInstall?: boolean; + countsUpdatedAt?: string; } interface IRowProps extends Row { @@ -69,6 +86,7 @@ const SoftwareTitleDetailsTable = ({ teamIdForApi, isIPadOSOrIOSApp, isAvailableForInstall, + countsUpdatedAt, }: ISoftwareTitleDetailsTableProps) => { const handleRowSelect = (row: IRowProps) => { const hostsBySoftwareParams = { @@ -95,7 +113,10 @@ const SoftwareTitleDetailsTable = ({ ); const renderVersionsCount = () => ( - <TableCount name="versions" count={data?.length} /> + <> + <TableCount name="versions" count={data?.length} /> + {countsUpdatedAt && SoftwareLastUpdatedInfo(countsUpdatedAt)} + </> ); return ( diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tests.tsx b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tests.tsx index add93fc3b17e..1c07bc5cd7ea 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tests.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tests.tsx @@ -4,8 +4,8 @@ import { createCustomRenderer } from "test/test-utils"; import createMockUser from "__mocks__/userMock"; import { - createMockSoftwareTitlesReponse, - createMockSoftwareVersionsReponse, + createMockSoftwareTitlesResponse, + createMockSoftwareVersionsResponse, } from "__mocks__/softwareMock"; import { noop } from "lodash"; @@ -25,7 +25,7 @@ const mockRouter = { }; describe("Software table", () => { - it("Renders the page-wide disabled state when software inventory is disabled", async () => { + it("Renders the page-wide disabled state when software inventory is disabled", () => { const render = createCustomRenderer({ context: { app: { @@ -40,7 +40,7 @@ describe("Software table", () => { router={mockRouter} isSoftwareEnabled={false} // Set to false showVersions={false} - data={createMockSoftwareTitlesReponse({ + data={createMockSoftwareTitlesResponse({ counts_updated_at: null, software_titles: [], })} @@ -68,7 +68,7 @@ describe("Software table", () => { expect(screen.queryByText("Vulnerability")).toBeNull(); }); - it("Renders the page-wide empty state when no software are present", async () => { + it("Renders the page-wide empty state when no software are present", () => { const render = createCustomRenderer({ context: { app: { @@ -83,7 +83,8 @@ describe("Software table", () => { router={mockRouter} isSoftwareEnabled showVersions={false} - data={createMockSoftwareTitlesReponse({ + data={createMockSoftwareTitlesResponse({ + count: 0, counts_updated_at: null, software_titles: [], })} @@ -111,11 +112,12 @@ describe("Software table", () => { expect( screen.getByText("Expecting to see software? Check back later.") ).toBeInTheDocument(); + expect(screen.getByText("0 items")).toBeInTheDocument(); expect(screen.queryByText("Search")).toBeNull(); expect(screen.queryByText("Updated")).toBeNull(); }); - it("Renders the page-wide empty state when search query does not exist but versions toggle is applied", async () => { + it("Renders the page-wide empty state when search query does not exist but versions toggle is applied", () => { const render = createCustomRenderer({ context: { app: { @@ -130,7 +132,7 @@ describe("Software table", () => { router={mockRouter} isSoftwareEnabled showVersions // Versions toggle applied - data={createMockSoftwareVersionsReponse({ + data={createMockSoftwareVersionsResponse({ counts_updated_at: null, software: [], })} @@ -160,7 +162,7 @@ describe("Software table", () => { ).toBeInTheDocument(); }); - it("Renders the empty search state when search query does not exist but dropdown is applied", async () => { + it("Renders the empty search state when search query does not exist but dropdown is applied", () => { const render = createCustomRenderer({ context: { app: { @@ -175,7 +177,7 @@ describe("Software table", () => { router={mockRouter} isSoftwareEnabled showVersions={false} - data={createMockSoftwareTitlesReponse({ + data={createMockSoftwareTitlesResponse({ counts_updated_at: null, software_titles: [], })} @@ -209,7 +211,7 @@ describe("Software table", () => { ).toBeInTheDocument(); }); - it("Renders the empty search state when search query does not exist but vulnerability filter is applied", async () => { + it("Renders the empty search state when search query does not exist but vulnerability filter is applied", () => { const render = createCustomRenderer({ context: { app: { @@ -224,7 +226,7 @@ describe("Software table", () => { router={mockRouter} isSoftwareEnabled showVersions={false} - data={createMockSoftwareTitlesReponse({ + data={createMockSoftwareTitlesResponse({ counts_updated_at: null, software_titles: [], })} diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tsx b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tsx index 498599053782..9b8df49c2cf9 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tsx @@ -269,7 +269,7 @@ const SoftwareTable = ({ }; const renderSoftwareCount = () => { - if (!tableData || !data?.count) return null; + if (!tableData || !data) return null; return ( <> diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers.tests.ts b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers.tests.ts new file mode 100644 index 000000000000..aed9762f450c --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers.tests.ts @@ -0,0 +1,49 @@ +import { isValidNumber } from "./helpers"; + +describe("isValidNumber", () => { + // Test valid numbers + it("returns true for valid numbers", () => { + expect(isValidNumber(0)).toBe(true); + expect(isValidNumber(42)).toBe(true); + expect(isValidNumber(-10)).toBe(true); + expect(isValidNumber(3.14)).toBe(true); + }); + + // Test invalid inputs + it("returns false for non-number inputs", () => { + expect(isValidNumber("42")).toBe(false); + expect(isValidNumber(null)).toBe(false); + expect(isValidNumber(undefined)).toBe(false); + expect(isValidNumber({})).toBe(false); + expect(isValidNumber([])).toBe(false); + expect(isValidNumber(true)).toBe(false); + }); + + // Test NaN + it("returns false for NaN", () => { + expect(isValidNumber(NaN)).toBe(false); + }); + + // Test with min value + it("respects min value when provided", () => { + expect(isValidNumber(5, 0)).toBe(true); + expect(isValidNumber(5, 5)).toBe(true); + expect(isValidNumber(5, 6)).toBe(false); + }); + + // Test with max value + it("respects max value when provided", () => { + expect(isValidNumber(5, undefined, 10)).toBe(true); + expect(isValidNumber(5, undefined, 5)).toBe(true); + expect(isValidNumber(5, undefined, 4)).toBe(false); + }); + + // Test with both min and max values + it("respects both min and max values when provided", () => { + expect(isValidNumber(5, 0, 10)).toBe(true); + expect(isValidNumber(0, 0, 10)).toBe(true); + expect(isValidNumber(10, 0, 10)).toBe(true); + expect(isValidNumber(-1, 0, 10)).toBe(false); + expect(isValidNumber(11, 0, 10)).toBe(false); + }); +}); diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers.ts b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers.ts index 90abdfb14774..b4dacf1c7d03 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers.ts +++ b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers.ts @@ -127,7 +127,7 @@ export type ISoftwareVulnFiltersParams = { maxCvssScore?: number; }; -const isValidNumber = ( +export const isValidNumber = ( value: any, min?: number, max?: number diff --git a/frontend/pages/SoftwarePage/SoftwareVulnerabilities/SoftwareVulnerabilitiesTable/SoftwareVulnerabilitiesTable.tests.tsx b/frontend/pages/SoftwarePage/SoftwareVulnerabilities/SoftwareVulnerabilitiesTable/SoftwareVulnerabilitiesTable.tests.tsx index ea0ac483897c..2e869892d182 100644 --- a/frontend/pages/SoftwarePage/SoftwareVulnerabilities/SoftwareVulnerabilitiesTable/SoftwareVulnerabilitiesTable.tests.tsx +++ b/frontend/pages/SoftwarePage/SoftwareVulnerabilities/SoftwareVulnerabilitiesTable/SoftwareVulnerabilitiesTable.tests.tsx @@ -24,7 +24,7 @@ const mockRouter = { }; describe("Software Vulnerabilities table", () => { - it("Renders the page-wide disabled state when software inventory is disabled", async () => { + it("Renders the page-wide disabled state when software inventory is disabled", () => { const render = createCustomRenderer({ context: { app: { @@ -62,7 +62,7 @@ describe("Software Vulnerabilities table", () => { }); // TODO: Reinstate collecting software view - it("Renders the page-wide empty state when no software vulnerabilities are present", async () => { + it("Renders the page-wide empty state when no software vulnerabilities are present", () => { const render = createCustomRenderer({ context: { app: { @@ -97,13 +97,14 @@ describe("Software Vulnerabilities table", () => { ); expect(screen.getByText("No vulnerabilities detected")).toBeInTheDocument(); + expect(screen.getByText("0 items")).toBeInTheDocument(); expect( screen.getByText("Expecting to see vulnerabilities? Check back later.") ).toBeInTheDocument(); expect(screen.queryByText("Vulnerability")).toBeNull(); }); - it("Renders the empty search state when search query does not exist but exploited vulnerabilities dropdown is applied", async () => { + it("Renders the empty search state when search query does not exist but exploited vulnerabilities dropdown is applied", () => { const render = createCustomRenderer({ context: { app: { @@ -145,7 +146,7 @@ describe("Software Vulnerabilities table", () => { expect(screen.queryByText("Vulnerability")).toBeNull(); }); - it("Renders the invalid CVE empty search state when search query wrapped in quotes is invalid with no results", async () => { + it("Renders the invalid CVE empty search state when search query wrapped in quotes is invalid with no results", () => { const render = createCustomRenderer({ context: { app: { @@ -188,7 +189,7 @@ describe("Software Vulnerabilities table", () => { expect(screen.queryByText("Vulnerability")).toBeNull(); }); - it("Renders the valid known CVE empty search state when search query wrapped in quotes is valid known CVE with no results", async () => { + it("Renders the valid known CVE empty search state when search query wrapped in quotes is valid known CVE with no results", () => { const render = createCustomRenderer({ context: { app: { @@ -233,7 +234,7 @@ describe("Software Vulnerabilities table", () => { expect(screen.queryByText("Vulnerability")).toBeNull(); }); - it("Renders the valid unknown CVE empty search state when search query wrapped in quotes is not a valid known CVE with no results", async () => { + it("Renders the valid unknown CVE empty search state when search query wrapped in quotes is not a valid known CVE with no results", () => { const render = createCustomRenderer({ context: { app: { @@ -276,7 +277,7 @@ describe("Software Vulnerabilities table", () => { expect(screen.queryByText("Vulnerability")).toBeNull(); }); - it("Renders premium columns", async () => { + it("Renders premium columns", () => { const render = createCustomRenderer({ context: { app: { diff --git a/frontend/pages/SoftwarePage/SoftwareVulnerabilities/SoftwareVulnerabilitiesTable/SoftwareVulnerabilitiesTable.tsx b/frontend/pages/SoftwarePage/SoftwareVulnerabilities/SoftwareVulnerabilitiesTable/SoftwareVulnerabilitiesTable.tsx index d37eb63acbd1..390a3dc86682 100644 --- a/frontend/pages/SoftwarePage/SoftwareVulnerabilities/SoftwareVulnerabilitiesTable/SoftwareVulnerabilitiesTable.tsx +++ b/frontend/pages/SoftwarePage/SoftwareVulnerabilities/SoftwareVulnerabilitiesTable/SoftwareVulnerabilitiesTable.tsx @@ -197,9 +197,9 @@ const SoftwareVulnerabilitiesTable = ({ }; const renderVulnerabilityCount = () => { - if (!data?.count) return null; + if (!data) return null; - const count = data.count; + const count = data?.count; return ( <> diff --git a/frontend/pages/SoftwarePage/components/AddPackage/AddPackage.tsx b/frontend/pages/SoftwarePage/components/AddPackage/AddPackage.tsx index 2e7e4b17a566..52cb805f7f46 100644 --- a/frontend/pages/SoftwarePage/components/AddPackage/AddPackage.tsx +++ b/frontend/pages/SoftwarePage/components/AddPackage/AddPackage.tsx @@ -1,13 +1,19 @@ import React, { useContext, useEffect, useState } from "react"; import { InjectedRouter } from "react-router"; +import { getErrorReason } from "interfaces/errors"; + import PATHS from "router/paths"; import { NotificationContext } from "context/notification"; import softwareAPI from "services/entities/software"; import { QueryParams, buildQueryStringFromParams } from "utilities/url"; +import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants"; + +import CustomLink from "components/CustomLink"; + import AddPackageForm from "../AddPackageForm"; -import { IAddSoftwareFormData } from "../AddPackageForm/AddSoftwareForm"; +import { IAddPackageFormData } from "../AddPackageForm/AddPackageForm"; import { getErrorMessage } from "../AddSoftwareModal/helpers"; const baseClass = "add-package"; @@ -60,7 +66,7 @@ const AddPackage = ({ }; }, [isUploading]); - const onAddPackage = async (formData: IAddSoftwareFormData) => { + const onAddPackage = async (formData: IAddPackageFormData) => { setIsUploading(true); if (formData.software && formData.software.size > MAX_FILE_SIZE_BYTES) { @@ -98,6 +104,21 @@ const AddPackage = ({ `${PATHS.SOFTWARE_TITLES}?${buildQueryStringFromParams(newQueryParams)}` ); } catch (e) { + const reason = getErrorReason(e); + if ( + reason.includes("Couldn't add. Fleet couldn't read the version from") + ) { + renderFlash( + "error", + `${reason}. ${( + <CustomLink + newTab + url={`${LEARN_MORE_ABOUT_BASE_LINK}/read-package-version`} + text="Learn more" + /> + )} ` + ); + } renderFlash("error", getErrorMessage(e)); } diff --git a/frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/AddPackageAdvancedOptions.tsx b/frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/AddPackageAdvancedOptions.tsx new file mode 100644 index 000000000000..5d6524e8dce1 --- /dev/null +++ b/frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/AddPackageAdvancedOptions.tsx @@ -0,0 +1,101 @@ +import React, { useState } from "react"; + +import Editor from "components/Editor"; +import CustomLink from "components/CustomLink"; +import FleetAce from "components/FleetAce"; +import RevealButton from "components/buttons/RevealButton"; + +const baseClass = "add-package-advanced-options"; + +interface IAddPackageAdvancedOptionsProps { + errors: { preInstallQuery?: string; postInstallScript?: string }; + preInstallQuery?: string; + installScript: string; + postInstallScript?: string; + onChangePreInstallQuery: (value?: string) => void; + onChangeInstallScript: (value: string) => void; + onChangePostInstallScript: (value?: string) => void; +} + +const AddPackageAdvancedOptions = ({ + errors, + preInstallQuery, + installScript, + postInstallScript, + onChangePreInstallQuery, + onChangeInstallScript, + onChangePostInstallScript, +}: IAddPackageAdvancedOptionsProps) => { + const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); + + return ( + <div className={baseClass}> + <RevealButton + className={`${baseClass}__accordion-title`} + isShowing={showAdvancedOptions} + showText="Advanced options" + hideText="Advanced options" + caretPosition="after" + onClick={() => setShowAdvancedOptions(!showAdvancedOptions)} + /> + {showAdvancedOptions && ( + <div className={`${baseClass}__input-fields`}> + <FleetAce + className="form-field" + focus + error={errors.preInstallQuery} + value={preInstallQuery} + placeholder="SELECT * FROM osquery_info WHERE start_time > 1" + label="Pre-install query" + name="preInstallQuery" + maxLines={10} + onChange={onChangePreInstallQuery} + helpText={ + <> + Software will be installed only if the{" "} + <CustomLink + className={`${baseClass}__table-link`} + text="query returns results" + url="https://fleetdm.com/tables" + newTab + /> + </> + } + /> + <Editor + wrapEnabled + maxLines={10} + name="install-script" + onChange={onChangeInstallScript} + value={installScript} + helpText="Shell (macOS and Linux) or PowerShell (Windows)." + label="Install script" + labelTooltip={ + <> + Fleet will run this script on hosts to install software. Use the + <br /> + $INSTALLER_PATH variable to point to the installer. + </> + } + isFormField + /> + <Editor + label="Post-install script" + labelTooltip="Fleet will run this script after install." + focus + error={errors.postInstallScript} + wrapEnabled + name="post-install-script-editor" + maxLines={10} + onChange={onChangePostInstallScript} + value={postInstallScript} + helpText="Shell (macOS and Linux) or PowerShell (Windows)." + isFormField + /> + </div> + )} + </div> + ); +}; + +export default AddPackageAdvancedOptions; diff --git a/frontend/pages/SoftwarePage/components/AddSoftwareAdvancedOptions/_styles.scss b/frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/_styles.scss similarity index 88% rename from frontend/pages/SoftwarePage/components/AddSoftwareAdvancedOptions/_styles.scss rename to frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/_styles.scss index 58f1f85892b9..0728e3241560 100644 --- a/frontend/pages/SoftwarePage/components/AddSoftwareAdvancedOptions/_styles.scss +++ b/frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/_styles.scss @@ -1,4 +1,4 @@ -.add-software-advanced-options { +.add-package-advanced-options { display: flex; flex-direction: column; align-items: flex-start; diff --git a/frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/index.ts b/frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/index.ts new file mode 100644 index 000000000000..004c96332d47 --- /dev/null +++ b/frontend/pages/SoftwarePage/components/AddPackageAdvancedOptions/index.ts @@ -0,0 +1 @@ +export { default } from "./AddPackageAdvancedOptions"; diff --git a/frontend/pages/SoftwarePage/components/AddPackageForm/AddSoftwareForm.tsx b/frontend/pages/SoftwarePage/components/AddPackageForm/AddPackageForm.tsx similarity index 55% rename from frontend/pages/SoftwarePage/components/AddPackageForm/AddSoftwareForm.tsx rename to frontend/pages/SoftwarePage/components/AddPackageForm/AddPackageForm.tsx index aab97985d0d0..cf3802b3f6f8 100644 --- a/frontend/pages/SoftwarePage/components/AddPackageForm/AddSoftwareForm.tsx +++ b/frontend/pages/SoftwarePage/components/AddPackageForm/AddPackageForm.tsx @@ -6,7 +6,6 @@ import getInstallScript from "utilities/software_install_scripts"; import Button from "components/buttons/Button"; import Checkbox from "components/forms/fields/Checkbox"; -import Editor from "components/Editor"; import { FileUploader, FileDetails, @@ -14,25 +13,25 @@ import { import Spinner from "components/Spinner"; import TooltipWrapper from "components/TooltipWrapper"; -import AddSoftwareAdvancedOptions from "../AddSoftwareAdvancedOptions"; +import AddPackageAdvancedOptions from "../AddPackageAdvancedOptions"; import { generateFormValidation } from "./helpers"; -export const baseClass = "add-software-form"; +export const baseClass = "add-package-form"; const UploadingSoftware = () => { return ( <div className={`${baseClass}__uploading-message`}> <Spinner centered={false} /> - <p>Uploading. It may take a few minutes to finish.</p> + <p>Adding software. This may take a few minutes to finish.</p> </div> ); }; -export interface IAddSoftwareFormData { +export interface IAddPackageFormData { software: File | null; installScript: string; - preInstallCondition?: string; + preInstallQuery?: string; postInstallScript?: string; selfService: boolean; } @@ -40,30 +39,28 @@ export interface IAddSoftwareFormData { export interface IFormValidation { isValid: boolean; software: { isValid: boolean }; - preInstallCondition?: { isValid: boolean; message?: string }; + preInstallQuery?: { isValid: boolean; message?: string }; postInstallScript?: { isValid: boolean; message?: string }; selfService?: { isValid: boolean }; } -interface IAddSoftwareFormProps { +interface IAddPackageFormProps { isUploading: boolean; onCancel: () => void; - onSubmit: (formData: IAddSoftwareFormData) => void; + onSubmit: (formData: IAddPackageFormData) => void; } -const AddSoftwareForm = ({ +const AddPackageForm = ({ isUploading, onCancel, onSubmit, -}: IAddSoftwareFormProps) => { +}: IAddPackageFormProps) => { const { renderFlash } = useContext(NotificationContext); - const [showPreInstallCondition, setShowPreInstallCondition] = useState(false); - const [showPostInstallScript, setShowPostInstallScript] = useState(false); - const [formData, setFormData] = useState<IAddSoftwareFormData>({ + const [formData, setFormData] = useState<IAddPackageFormData>({ software: null, installScript: "", - preInstallCondition: undefined, + preInstallQuery: undefined, postInstallScript: undefined, selfService: false, }); @@ -90,13 +87,7 @@ const AddSoftwareForm = ({ installScript, }; setFormData(newData); - setFormValidation( - generateFormValidation( - newData, - showPreInstallCondition, - showPostInstallScript - ) - ); + setFormValidation(generateFormValidation(newData)); } }; @@ -105,62 +96,26 @@ const AddSoftwareForm = ({ onSubmit(formData); }; - const onTogglePreInstallConditionCheckbox = (value: boolean) => { - const newData = { ...formData, preInstallCondition: undefined }; - setShowPreInstallCondition(value); - setFormData(newData); - setFormValidation( - generateFormValidation(newData, value, showPostInstallScript) - ); - }; - - const onTogglePostInstallScriptCheckbox = (value: boolean) => { - const newData = { ...formData, postInstallScript: undefined }; - setShowPostInstallScript(value); - setFormData(newData); - setFormValidation( - generateFormValidation(newData, showPreInstallCondition, value) - ); - }; - const onChangeInstallScript = (value: string) => { setFormData({ ...formData, installScript: value }); }; - const onChangePreInstallCondition = (value?: string) => { - const newData = { ...formData, preInstallCondition: value }; + const onChangePreInstallQuery = (value?: string) => { + const newData = { ...formData, preInstallQuery: value }; setFormData(newData); - setFormValidation( - generateFormValidation( - newData, - showPreInstallCondition, - showPostInstallScript - ) - ); + setFormValidation(generateFormValidation(newData)); }; const onChangePostInstallScript = (value?: string) => { const newData = { ...formData, postInstallScript: value }; setFormData(newData); - setFormValidation( - generateFormValidation( - newData, - showPreInstallCondition, - showPostInstallScript - ) - ); + setFormValidation(generateFormValidation(newData)); }; const onToggleSelfServiceCheckbox = (value: boolean) => { const newData = { ...formData, selfService: value }; setFormData(newData); - setFormValidation( - generateFormValidation( - newData, - showPreInstallCondition, - showPostInstallScript - ) - ); + setFormValidation(generateFormValidation(newData)); }; const isSubmitDisabled = !formValidation.isValid; @@ -185,25 +140,6 @@ const AddSoftwareForm = ({ ) } /> - {formData.software && ( - <Editor - wrapEnabled - maxLines={10} - name="install-script" - onChange={onChangeInstallScript} - value={formData.installScript} - helpText="Fleet will run this command on hosts to install software." - label="Install script" - labelTooltip={ - <> - For security agents, add the script provided by the vendor. - <br /> - In custom scripts, you can use the $INSTALLER_PATH variable to - point to the installer. - </> - } - /> - )} <Checkbox value={formData.selfService} onChange={onToggleSelfServiceCheckbox} @@ -219,19 +155,17 @@ const AddSoftwareForm = ({ Self-service </TooltipWrapper> </Checkbox> - <AddSoftwareAdvancedOptions + <AddPackageAdvancedOptions errors={{ - preInstallCondition: formValidation.preInstallCondition?.message, + preInstallQuery: formValidation.preInstallQuery?.message, postInstallScript: formValidation.postInstallScript?.message, }} - showPreInstallCondition={showPreInstallCondition} - showPostInstallScript={showPostInstallScript} - preInstallCondition={formData.preInstallCondition} + preInstallQuery={formData.preInstallQuery} postInstallScript={formData.postInstallScript} - onTogglePreInstallCondition={onTogglePreInstallConditionCheckbox} - onTogglePostInstallScript={onTogglePostInstallScriptCheckbox} - onChangePreInstallCondition={onChangePreInstallCondition} + onChangePreInstallQuery={onChangePreInstallQuery} + onChangeInstallScript={onChangeInstallScript} onChangePostInstallScript={onChangePostInstallScript} + installScript={formData.installScript} /> <div className="modal-cta-wrap"> <Button type="submit" variant="brand" disabled={isSubmitDisabled}> @@ -247,4 +181,4 @@ const AddSoftwareForm = ({ ); }; -export default AddSoftwareForm; +export default AddPackageForm; diff --git a/frontend/pages/SoftwarePage/components/AddPackageForm/_styles.scss b/frontend/pages/SoftwarePage/components/AddPackageForm/_styles.scss index 8e09f0a64d53..aee078016f36 100644 --- a/frontend/pages/SoftwarePage/components/AddPackageForm/_styles.scss +++ b/frontend/pages/SoftwarePage/components/AddPackageForm/_styles.scss @@ -1,4 +1,4 @@ -.add-software-form { +.add-package-form { &__uploading-message { display: flex; align-items: center; diff --git a/frontend/pages/SoftwarePage/components/AddPackageForm/helpers.ts b/frontend/pages/SoftwarePage/components/AddPackageForm/helpers.ts index d38ca110d5d1..d527049c1474 100644 --- a/frontend/pages/SoftwarePage/components/AddPackageForm/helpers.ts +++ b/frontend/pages/SoftwarePage/components/AddPackageForm/helpers.ts @@ -3,23 +3,19 @@ import validator from "validator"; // @ts-ignore import validateQuery from "components/forms/validators/validate_query"; -import { IAddSoftwareFormData, IFormValidation } from "./AddSoftwareForm"; +import { IAddPackageFormData, IFormValidation } from "./AddPackageForm"; -type IAddSoftwareFormValidatorKey = Exclude< - keyof IAddSoftwareFormData, +type IAddPackageFormValidatorKey = Exclude< + keyof IAddPackageFormData, "installScript" >; -type IMessageFunc = (formData: IAddSoftwareFormData) => string; +type IMessageFunc = (formData: IAddPackageFormData) => string; type IValidationMessage = string | IMessageFunc; interface IValidation { name: string; - isValid: ( - formData: IAddSoftwareFormData, - enabledPreInstallCondition?: boolean, - enabledPostInstallScript?: boolean - ) => boolean; + isValid: (formData: IAddPackageFormData) => boolean; message?: IValidationMessage; } @@ -27,7 +23,7 @@ interface IValidation { * to determine if a field is valid, and rules for generating an error message. */ const FORM_VALIDATION_CONFIG: Record< - IAddSoftwareFormValidatorKey, + IAddPackageFormValidatorKey, { validations: IValidation[] } > = { software: { @@ -38,70 +34,23 @@ const FORM_VALIDATION_CONFIG: Record< }, ], }, - preInstallCondition: { + preInstallQuery: { validations: [ - { - name: "required", - isValid: ( - formData: IAddSoftwareFormData, - enabledPreInstallCondition - ) => { - if (!enabledPreInstallCondition) { - return true; - } - return ( - formData.preInstallCondition !== undefined && - !validator.isEmpty(formData.preInstallCondition) - ); - }, - message: (formData) => { - // we dont want an error message until the user has interacted with - // the field. This is why we check for undefined here. - if (formData.preInstallCondition === undefined) { - return ""; - } - return "Pre-install condition is required when enabled."; - }, - }, { name: "invalidQuery", - isValid: (formData, enabledPreInstallCondition) => { - if (!enabledPreInstallCondition) { - return true; - } + isValid: (formData) => { + const query = formData.preInstallQuery; return ( - formData.preInstallCondition !== undefined && - validateQuery(formData.preInstallCondition).valid + query === undefined || query === "" || validateQuery(query).valid ); }, - message: (formData) => - validateQuery(formData.preInstallCondition).error, + message: (formData) => validateQuery(formData.preInstallQuery).error, }, ], }, postInstallScript: { - validations: [ - { - name: "required", - message: (formData) => { - // we dont want an error message until the user has interacted with - // the field. This is why we check for undefined here. - if (formData.postInstallScript === undefined) { - return ""; - } - return "Post-install script is required when enabled."; - }, - isValid: (formData, _, enabledPostInstallScript) => { - if (!enabledPostInstallScript) { - return true; - } - return ( - formData.postInstallScript !== undefined && - !validator.isEmpty(formData.postInstallScript) - ); - }, - }, - ], + // no validations related to postInstallScript + validations: [], }, selfService: { // no validations related to self service @@ -110,7 +59,7 @@ const FORM_VALIDATION_CONFIG: Record< }; const getErrorMessage = ( - formData: IAddSoftwareFormData, + formData: IAddPackageFormData, message?: IValidationMessage ) => { if (message === undefined || typeof message === "string") { @@ -119,11 +68,7 @@ const getErrorMessage = ( return message(formData); }; -export const generateFormValidation = ( - formData: IAddSoftwareFormData, - showingPreInstallCondition: boolean, - showingPostInstallScript: boolean -) => { +export const generateFormValidation = (formData: IAddPackageFormData) => { const formValidation: IFormValidation = { isValid: true, software: { @@ -134,12 +79,7 @@ export const generateFormValidation = ( Object.keys(FORM_VALIDATION_CONFIG).forEach((key) => { const objKey = key as keyof typeof FORM_VALIDATION_CONFIG; const failedValidation = FORM_VALIDATION_CONFIG[objKey].validations.find( - (validation) => - !validation.isValid( - formData, - showingPreInstallCondition, - showingPostInstallScript - ) + (validation) => !validation.isValid(formData) ); if (!failedValidation) { diff --git a/frontend/pages/SoftwarePage/components/AddPackageForm/index.ts b/frontend/pages/SoftwarePage/components/AddPackageForm/index.ts index d3ea76d47d3c..2795e058dc01 100644 --- a/frontend/pages/SoftwarePage/components/AddPackageForm/index.ts +++ b/frontend/pages/SoftwarePage/components/AddPackageForm/index.ts @@ -1 +1 @@ -export { default } from "./AddSoftwareForm"; +export { default } from "./AddPackageForm"; diff --git a/frontend/pages/SoftwarePage/components/AddSoftwareAdvancedOptions/AddSoftwareAdvancedOptions.tsx b/frontend/pages/SoftwarePage/components/AddSoftwareAdvancedOptions/AddSoftwareAdvancedOptions.tsx deleted file mode 100644 index 96de54fd33f4..000000000000 --- a/frontend/pages/SoftwarePage/components/AddSoftwareAdvancedOptions/AddSoftwareAdvancedOptions.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import React, { useState } from "react"; - -import Editor from "components/Editor"; -import CustomLink from "components/CustomLink"; -import FleetAce from "components/FleetAce"; -import RevealButton from "components/buttons/RevealButton"; -import Checkbox from "components/forms/fields/Checkbox"; - -const baseClass = "add-software-advanced-options"; - -interface IAddSoftwareAdvancedOptionsProps { - errors: { preInstallCondition?: string; postInstallScript?: string }; - showPreInstallCondition: boolean; - showPostInstallScript: boolean; - preInstallCondition?: string; - postInstallScript?: string; - onTogglePreInstallCondition: (value: boolean) => void; - onTogglePostInstallScript: (value: boolean) => void; - onChangePreInstallCondition: (value?: string) => void; - onChangePostInstallScript: (value?: string) => void; -} - -const AddSoftwareAdvancedOptions = ({ - errors, - showPreInstallCondition, - showPostInstallScript, - preInstallCondition, - postInstallScript, - onTogglePreInstallCondition, - onTogglePostInstallScript, - onChangePreInstallCondition, - onChangePostInstallScript, -}: IAddSoftwareAdvancedOptionsProps) => { - const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); - - const onChangePreInstallCheckbox = () => { - onTogglePreInstallCondition(!showPreInstallCondition); - }; - - const onChangePostInstallCheckbox = () => { - onTogglePostInstallScript(!showPostInstallScript); - }; - - return ( - <div className={baseClass}> - <RevealButton - className={`${baseClass}__accordion-title`} - isShowing={showAdvancedOptions} - showText="Advanced options" - hideText="Advanced options" - caretPosition="after" - onClick={() => setShowAdvancedOptions(!showAdvancedOptions)} - /> - {showAdvancedOptions && ( - <div className={`${baseClass}__input-fields`}> - <Checkbox - value={showPreInstallCondition} - onChange={onChangePreInstallCheckbox} - > - Pre-install condition - </Checkbox> - {showPreInstallCondition && ( - <FleetAce - focus - error={errors.preInstallCondition} - value={preInstallCondition} - label="Query" - name="preInstallQuery" - maxLines={10} - onChange={onChangePreInstallCondition} - helpText={ - <> - Software will be installed only if the{" "} - <CustomLink - className={`${baseClass}__table-link`} - text="query returns results" - url="https://fleetdm.com/tables" - newTab - /> - </> - } - /> - )} - <Checkbox - value={showPostInstallScript} - onChange={onChangePostInstallCheckbox} - > - Post-install script - </Checkbox> - {showPostInstallScript && ( - <> - <Editor - focus - error={errors.postInstallScript} - wrapEnabled - name="post-install-script-editor" - maxLines={10} - onChange={onChangePostInstallScript} - value={postInstallScript} - helpText="Shell (macOS and Linux) or PowerShell (Windows)." - /> - </> - )} - </div> - )} - </div> - ); -}; - -export default AddSoftwareAdvancedOptions; diff --git a/frontend/pages/SoftwarePage/components/AddSoftwareAdvancedOptions/index.ts b/frontend/pages/SoftwarePage/components/AddSoftwareAdvancedOptions/index.ts deleted file mode 100644 index 264fa61b112e..000000000000 --- a/frontend/pages/SoftwarePage/components/AddSoftwareAdvancedOptions/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./AddSoftwareAdvancedOptions"; diff --git a/frontend/pages/SoftwarePage/components/AppStoreVpp/AppStoreVpp.tsx b/frontend/pages/SoftwarePage/components/AppStoreVpp/AppStoreVpp.tsx index 06108649bf72..ffde9220a271 100644 --- a/frontend/pages/SoftwarePage/components/AppStoreVpp/AppStoreVpp.tsx +++ b/frontend/pages/SoftwarePage/components/AppStoreVpp/AppStoreVpp.tsx @@ -35,14 +35,14 @@ const EnableVppCard = () => { <Card borderRadiusSize="medium"> <div className={`${baseClass}__enable-vpp`}> <p className={`${baseClass}__enable-vpp-title`}> - <b>Volume Purchasing Program (VPP) isn't enabled</b> + <b>No Volume Purchasing Program (VPP) token assigned</b> </p> <p className={`${baseClass}__enable-vpp-description`}> - To add App Store apps, first enable VPP. + To add App Store apps, assign a VPP token to this team. </p> <CustomLink url={PATHS.ADMIN_INTEGRATIONS_VPP} - text="Enable VPP" + text="Edit VPP" className={`${baseClass}__enable-vpp-link`} /> </div> @@ -57,9 +57,8 @@ const NoVppAppsCard = () => ( You don't have any App Store apps </p> <p className={`${baseClass}__no-software-description`}> - Add apps in{" "} - <CustomLink url="https://business.apple.com" text="ABM" newTab /> Apps - that are already added to this team are not listed. + You must purchase apps in ABM. App Store apps that are already added to + this team are not listed. </p> </div> </Card> diff --git a/frontend/pages/SoftwarePage/components/AppStoreVpp/_styles.scss b/frontend/pages/SoftwarePage/components/AppStoreVpp/_styles.scss index e530588b7a4a..604750a25808 100644 --- a/frontend/pages/SoftwarePage/components/AppStoreVpp/_styles.scss +++ b/frontend/pages/SoftwarePage/components/AppStoreVpp/_styles.scss @@ -53,6 +53,7 @@ &__no-software-description { margin: 0; color: $ui-fleet-black-75; + text-align: center; } &__error { diff --git a/frontend/pages/SoftwarePage/components/SoftwareFiltersModal/SoftwareFiltersModal.tsx b/frontend/pages/SoftwarePage/components/SoftwareFiltersModal/SoftwareFiltersModal.tsx index 0c9ec70b3528..91223b5746b5 100644 --- a/frontend/pages/SoftwarePage/components/SoftwareFiltersModal/SoftwareFiltersModal.tsx +++ b/frontend/pages/SoftwarePage/components/SoftwareFiltersModal/SoftwareFiltersModal.tsx @@ -18,7 +18,7 @@ const baseClass = "software-filters-modal"; interface ISoftwareFiltersModalProps { onExit: () => void; - onSubmit: (vulnFilters: ISoftwareVulnFilters) => void; + onSubmit: (vulnFilters: ISoftwareVulnFiltersParams) => void; vulnFilters: ISoftwareVulnFiltersParams; isPremiumTier: boolean; } @@ -53,8 +53,8 @@ const SoftwareFiltersModal = ({ onSubmit({ vulnerable: vulnSoftwareFilterEnabled, exploit: hasKnownExploit || undefined, - min_cvss_score: severity?.minSeverity || undefined, - max_cvss_score: severity?.maxSeverity || undefined, + minCvssScore: severity?.minSeverity || undefined, + maxCvssScore: severity?.maxSeverity || undefined, }); }; diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/MdmSettings.tsx b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/MdmSettings.tsx index ff3cf5b53994..b6e0f02795f0 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/MdmSettings.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/MdmSettings.tsx @@ -27,6 +27,8 @@ interface IMdmSettingsProps { const MdmSettings = ({ router }: IMdmSettingsProps) => { const { isPremiumTier, config } = useContext(AppContext); + const isMdmEnabled = !!config?.mdm.enabled_and_configured; + // Currently the status of this API call is what determines various UI states on // this page. Because of this we will not render any of this components UI until this API // call has completed. @@ -48,7 +50,7 @@ const MdmSettings = ({ router }: IMdmSettingsProps) => { // we're fetching and setting the config, but for now we'll just assume that any 400 response // means that MDM is not enabled and we'll show the "Turn on MDM" button. staleTime: 5000, - enabled: !!config?.mdm.enabled_and_configured, + enabled: isMdmEnabled, } ); @@ -63,7 +65,7 @@ const MdmSettings = ({ router }: IMdmSettingsProps) => { { ...DEFAULT_USE_QUERY_OPTIONS, retry: false, - enabled: isPremiumTier && !!config?.mdm.enabled_and_configured, + enabled: isPremiumTier && isMdmEnabled, } ); @@ -80,7 +82,7 @@ const MdmSettings = ({ router }: IMdmSettingsProps) => { { ...DEFAULT_USE_QUERY_OPTIONS, retry: false, - enabled: isPremiumTier && !!config?.mdm.enabled_and_configured, + enabled: isPremiumTier && isMdmEnabled, } ); @@ -104,7 +106,7 @@ const MdmSettings = ({ router }: IMdmSettingsProps) => { // we use this to determine if we have all the data we need to render the UI. // Notice that we do not need VPP or EULA data to render this page. - const hasAllData = !!APNSInfo; + const hasAllData = !isMdmEnabled || !!APNSInfo; return ( <div className={baseClass}> diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/AddVppModal/helpers.tsx b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/AddVppModal/helpers.tsx index 77dc84278241..7716c6de6c92 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/AddVppModal/helpers.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/AddVppModal/helpers.tsx @@ -20,7 +20,7 @@ export const getErrorMessage = (err: unknown) => { reasonIncludes: "Duplicate entry", }); const invalidTokenReason = getErrorReason(err, { - reasonIncludes: "invalid", + reasonIncludes: "Invalid token", }); if (duplicateEntryReason) { @@ -28,7 +28,7 @@ export const getErrorMessage = (err: unknown) => { } if (invalidTokenReason) { - return "Invalid token. Please provide a valid token from Apple Business Manager."; + return invalidTokenReason; } return DEFAULT_ERROR_MESSAGE; diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/EditTeamsVppModal/EditTeamsVppModal.tsx b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/EditTeamsVppModal/EditTeamsVppModal.tsx index 8635499a0ced..029380e60e17 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/EditTeamsVppModal/EditTeamsVppModal.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/EditTeamsVppModal/EditTeamsVppModal.tsx @@ -209,7 +209,7 @@ const EditTeamsVppModal = ({ showArrow tipContent={ <div className={`${baseClass}__tooltip--all-teams`}> - You can’t choose teams because you already have a VPP token + You can't choose teams because you already have a VPP token assigned to all teams. First, edit teams for that VPP token to choose teams here. </div> @@ -223,6 +223,7 @@ const EditTeamsVppModal = ({ placeholder="Search teams" value={selectedValue} label="Teams" + className={`${baseClass}__vpp-dropdown`} wrapperClassName={`${baseClass}__form-field--vpp-teams ${ isDropdownDisabled ? `${baseClass}__form-field--disabled` : "" }`} @@ -230,7 +231,7 @@ const EditTeamsVppModal = ({ isDropdownDisabled ? undefined : ( <> Each team can have only one VPP token. Teams that already - have a VPP token won’t show up here. + have a VPP token won't show up here. </> ) } diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/EditTeamsVppModal/_styles.scss b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/EditTeamsVppModal/_styles.scss index 9b6f508dc1a6..72ed9e4a10f1 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/EditTeamsVppModal/_styles.scss +++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/EditTeamsVppModal/_styles.scss @@ -2,6 +2,13 @@ .component__tooltip-wrapper__element { width: 100%; // default component style was causing the select box not to be full width } + + // this is needed to wrap the selected team names in that are displayed + // in the dropdown select box. + .dropdown__select { + text-wrap: wrap; + } + // styles needed to make select look like figma design when disabled, // default styles in the Dropdown component were not enough &__form-field--disabled { diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/AutomaticEnrollmentSection/AppleAutomaticEnrollmentCard/AppleAutomaticEnrollmentCard.tsx b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/AutomaticEnrollmentSection/AppleAutomaticEnrollmentCard/AppleAutomaticEnrollmentCard.tsx index dd0ca4c96d36..69d2c65ad542 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/AutomaticEnrollmentSection/AppleAutomaticEnrollmentCard/AppleAutomaticEnrollmentCard.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/AutomaticEnrollmentSection/AppleAutomaticEnrollmentCard/AppleAutomaticEnrollmentCard.tsx @@ -25,7 +25,7 @@ const AppleAutomaticEnrollmentCard = ({ "Add an Apple Business Manager (ABM) connection to automatically enroll newly " + "purchased Apple hosts when they're first unboxed and set up by your end users."; } else if (isAppleMdmOn && configured) { - msg = "Automatic enrollment for Apple (macOS, iOS, iPadOS) hosts enabled."; + msg = "Automatic enrollment for Apple (macOS, iOS, iPadOS) is enabled."; icon = "success"; } diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/VppSection/VppSection.tsx b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/VppSection/VppSection.tsx index e2b58dd29b4a..afcd3af3c213 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/VppSection/VppSection.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/VppSection/VppSection.tsx @@ -46,7 +46,7 @@ const VppCard = ({ isAppleMdmOn, isVppOn, router }: IVppCardProps) => { <p> <span> <Icon name="success" /> - Volume Purchasing Program (VPP) enabled. + Volume Purchasing Program (VPP) is enabled. </span> </p> <Button onClick={navigateToVppSetup} variant="text-icon"> diff --git a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/TeamDetailsWrapper.tsx b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/TeamDetailsWrapper.tsx index 90a3d609d929..dc7d191ec209 100644 --- a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/TeamDetailsWrapper.tsx +++ b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/TeamDetailsWrapper.tsx @@ -337,11 +337,18 @@ const TeamDetailsWrapper = ({ setBackendValidators({ name: "A team with this name already exists", }); + } else if (errorObject.base.includes("all teams")) { + setBackendValidators({ + name: `"All teams" is a reserved team name. Please try another name.`, + }); + } else if (errorObject.base.includes("no team")) { + setBackendValidators({ + name: `"No team" is a reserved team name. Please try another name.`, + }); } else { renderFlash("error", "Could not create team. Please try again."); } } finally { - toggleRenameTeamModal(); setIsUpdatingTeams(false); } }, diff --git a/frontend/pages/admin/TeamManagementPage/TeamManagementPage.tsx b/frontend/pages/admin/TeamManagementPage/TeamManagementPage.tsx index 92bbec028043..7243f01eedd6 100644 --- a/frontend/pages/admin/TeamManagementPage/TeamManagementPage.tsx +++ b/frontend/pages/admin/TeamManagementPage/TeamManagementPage.tsx @@ -116,6 +116,14 @@ const TeamManagementPage = (): JSX.Element => { setBackendValidators({ name: "A team with this name already exists", }); + } else if (createError.data.errors[0].reason.includes("all teams")) { + setBackendValidators({ + name: `"All teams" is a reserved team name. Please try another name.`, + }); + } else if (createError.data.errors[0].reason.includes("no team")) { + setBackendValidators({ + name: `"No team" is a reserved team name. Please try another name.`, + }); } else { renderFlash("error", "Could not create team. Please try again."); toggleCreateTeamModal(); @@ -185,6 +193,16 @@ const TeamManagementPage = (): JSX.Element => { setBackendValidators({ name: "A team with this name already exists", }); + } else if ( + updateError.data.errors[0].reason.includes("all teams") + ) { + setBackendValidators({ + name: `"All teams" is a reserved team name.`, + }); + } else if (updateError.data.errors[0].reason.includes("no team")) { + setBackendValidators({ + name: `"No team" is a reserved team name. Please try another name.`, + }); } else { renderFlash( "error", diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index 4971fdf086a3..172b6e4bd9f5 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -53,7 +53,7 @@ import { HOST_OSQUERY_DATA, } from "utilities/constants"; -import { isIPadOrIPhone, Platform } from "interfaces/platform"; +import { isIPadOrIPhone } from "interfaces/platform"; import Spinner from "components/Spinner"; import TabsWrapper from "components/TabsWrapper"; @@ -479,7 +479,7 @@ const HostDetailsPage = ({ case "ios": return mdmConfig?.ios_updates; default: - null; + return undefined; } }; diff --git a/frontend/pages/hosts/details/HostDetailsPage/modals/UnenrollMdmModal/UnenrollMdmModal.tsx b/frontend/pages/hosts/details/HostDetailsPage/modals/UnenrollMdmModal/UnenrollMdmModal.tsx index ef2135dc19ed..a254ea2fa4a8 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/modals/UnenrollMdmModal/UnenrollMdmModal.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/modals/UnenrollMdmModal/UnenrollMdmModal.tsx @@ -25,11 +25,15 @@ const UnenrollMdmModal = ({ hostId, onClose }: IUnenrollMdmModalProps) => { setRequestState("unenrolling"); try { await mdmAPI.unenrollHostFromMdm(hostId, 5000); - renderFlash("success", "Successfully turned off MDM."); + renderFlash( + "success", + "Turning off MDM or will turn off when the host comes online." + ); onClose(); } catch (unenrollMdmError: unknown) { + renderFlash("error", "Couldn't turn off MDM. Please try again."); console.log(unenrollMdmError); - setRequestState("error"); + onClose(); } }; diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsModal.tsx b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsModal.tsx index a9c916f57083..f86226a967c6 100644 --- a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsModal.tsx +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsModal.tsx @@ -43,7 +43,7 @@ const OSSettingsModal = ({ title="OS settings" onExit={onClose} className={baseClass} - width="large" + width="xlarge" > <> <OSSettingsTable diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsErrorCell/_styles.scss b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsErrorCell/_styles.scss index f91e845c9aac..10de839ebb14 100644 --- a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsErrorCell/_styles.scss +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsErrorCell/_styles.scss @@ -15,6 +15,7 @@ } &__resend-button { + width: 106px; display: flex; .children-wrapper { diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/_styles.scss b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/_styles.scss index c4a39112866e..dc330199451c 100644 --- a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/_styles.scss +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/_styles.scss @@ -4,8 +4,15 @@ // for these cells in the table. Total width of the table cell will be // 240px including the padding. .data-table-block .data-table tbody td { - .os-settings-name-cell, .os-settings-status-cell, .os-settings-error-cell { - max-width: 192px; + .os-settings-name-cell { + width: 135px; + max-width: none; + } + .os-settings-status-cell { + width: 200px; + } + .os-settings-error-cell { + width: 237px; } } diff --git a/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx b/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx index 63caddf9e0fa..2a1c13b98b77 100644 --- a/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx +++ b/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx @@ -441,6 +441,7 @@ const HostSummary = ({ }; const renderSummary = () => { + console.log(hostMdmProfiles); // for windows hosts we have to manually add a profile for disk encryption // as this is not currently included in the `profiles` value from the API // response for windows hosts. diff --git a/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tsx b/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tsx index b99503903d73..ef5ac1b737fb 100644 --- a/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tsx +++ b/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tsx @@ -4,7 +4,6 @@ import ReactTooltip from "react-tooltip"; import { uniqueId } from "lodash"; import { IHostSoftware, SoftwareInstallStatus } from "interfaces/software"; -import { dateAgo } from "utilities/date_format"; import Icon from "components/Icon"; import TextCell from "components/TableContainer/DataTable/TextCell"; @@ -14,6 +13,7 @@ const baseClass = "install-status-cell"; type IStatusValue = SoftwareInstallStatus | "avaiableForInstall"; interface TootipArgs { softwareName?: string | null; + // this field is used in My device > Self-service lastInstalledAt?: string; isAppStoreApp?: boolean; } @@ -36,26 +36,23 @@ export const INSTALL_STATUS_DISPLAY_OPTIONS: Record< installed: { iconName: "success", displayText: "Installed", - tooltip: ({ lastInstalledAt: lastInstall }) => ( - <> - Fleet installed software on this host ({dateAgo(lastInstall as string)} - ). Currently, if the software is uninstalled, the "Installed" - status won't be updated. - </> - ), + tooltip: () => + "Software is installed (install script finished with exit code 0).", }, pending: { iconName: "pending-outline", displayText: "Pending", - tooltip: () => "Fleet will install software when the host comes online.", + tooltip: () => + "Fleet is installing or will install when the host comes online.", }, failed: { iconName: "error", displayText: "Failed", - tooltip: ({ lastInstalledAt: lastInstall }) => ( + tooltip: () => ( <> - Fleet failed to install software ({dateAgo(lastInstall as string)} ago). - Select <b>Actions > Software details</b> to see more. + The host failed to install software. To view errors, select + <br /> + <b>Actions > Show details</b>. </> ), }, @@ -96,10 +93,6 @@ const InstallStatusCell = ({ app_store_app, }: IInstallStatusCellProps) => { // FIXME: Improve the way we handle polymophism of software_package and app_store_app - const lastInstalledAt = - software_package?.last_install?.installed_at || - app_store_app?.last_install?.installed_at || - ""; const hasPackage = !!software_package; const hasAppStoreApp = !!app_store_app; @@ -140,7 +133,6 @@ const InstallStatusCell = ({ <span className={`${baseClass}__status-tooltip-text`}> {displayConfig.tooltip({ softwareName, - lastInstalledAt, isAppStoreApp: hasAppStoreApp, })} </span> diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tests.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tests.tsx index 2c995aa2b2d4..a7ca5e5cdd6c 100644 --- a/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tests.tsx +++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tests.tsx @@ -166,7 +166,7 @@ describe("SelfService", () => { ).toHaveTextContent("Install"); }); - it("renders no action button with 'Install in progress...' status", async () => { + it("renders no action button with 'Pending' status", async () => { mockServer.use( customDeviceSoftwareHandler({ software: [ @@ -186,7 +186,7 @@ describe("SelfService", () => { expect( screen.getByTestId("self-service-item__status--test") - ).toHaveTextContent("Install in progress..."); + ).toHaveTextContent("Pending"); expect( screen.queryByTestId("self-service-item__item-action-button--test") diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx index d0fce6aca3b2..3d817c9d16c0 100644 --- a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx +++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx @@ -25,25 +25,20 @@ const STATUS_CONFIG: Record<SoftwareInstallStatus, IStatusDisplayConfig> = { installed: { iconName: "success", displayText: "Installed", - tooltip: ({ lastInstalledAt }) => ( - <> - Software installed successfully ({dateAgo(lastInstalledAt as string)}). - Currently, if the software is uninstalled, the "Installed" - status won't be updated. - </> - ), + tooltip: ({ lastInstalledAt }) => + `Software is installed (${dateAgo(lastInstalledAt as string)}).`, }, pending: { iconName: "pending-outline", - displayText: "Install in progress...", - tooltip: () => "Software installation in progress...", + displayText: "Pending", + tooltip: () => "Fleet is installing software.", }, failed: { iconName: "error", displayText: "Failed", tooltip: ({ lastInstalledAt = "" }) => ( <> - Software failed to install + Software failed to install{" "} {lastInstalledAt ? ` (${dateAgo(lastInstalledAt)})` : ""}. Select{" "} <b>Retry</b> to install again, or contact your IT department. </> @@ -144,7 +139,6 @@ const getInstallButtonText = (status: SoftwareInstallStatus | null) => { case "installed": return "Reinstall"; default: - // we don't show a button for pending installs return ""; } }; @@ -165,10 +159,7 @@ const InstallerStatusAction = ({ SoftwareInstallStatus | undefined >(undefined); - // displayStatus allows us to display the localStatus (if any) or the status from the list - // software reponse - const displayStatus = localStatus || status; - const installButtonText = getInstallButtonText(displayStatus); + const installButtonText = getInstallButtonText(status); // if the localStatus is "failed", we don't want our tooltip to include the old installed_at date so we // set this to null, which tells the tooltip to omit the parenthetical date @@ -200,21 +191,16 @@ const InstallerStatusAction = ({ return ( <div className={`${baseClass}__item-status-action`}> <div className={`${baseClass}__item-status`}> - <InstallerStatus - id={id} - status={displayStatus} - last_install={lastInstall} - /> + <InstallerStatus id={id} status={status} last_install={lastInstall} /> </div> <div className={`${baseClass}__item-action`}> {!!installButtonText && ( <Button variant="text-icon" type="button" - className={`${baseClass}__item-action-button${ - localStatus === "pending" ? "--installing" : "" - }`} + className={`${baseClass}__item-action-button`} onClick={onClick} + disabled={localStatus === "pending"} > <span data-testid={`${baseClass}__item-action-button--test`}> {installButtonText} diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/_styles.scss b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/_styles.scss index 027b9c4fd6c4..eb21c7d118a8 100644 --- a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/_styles.scss +++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/_styles.scss @@ -87,9 +87,5 @@ &__item-action-button { height: auto; - - &--installing { - display: none; - } } } diff --git a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx index ff587f290ab5..e4b97e18d124 100644 --- a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx @@ -49,6 +49,8 @@ import AddPolicyModal from "./components/AddPolicyModal"; import DeletePolicyModal from "./components/DeletePolicyModal"; import CalendarEventsModal from "./components/CalendarEventsModal"; import { ICalendarEventsFormData } from "./components/CalendarEventsModal/CalendarEventsModal"; +import InstallSoftwareModal from "./components/InstallSoftwareModal"; +import { IInstallSoftwareFormData } from "./components/InstallSoftwareModal/InstallSoftwareModal"; interface IManagePoliciesPageProps { router: InjectedRouter; @@ -127,12 +129,19 @@ const ManagePolicyPage = ({ const [isUpdatingCalendarEvents, setIsUpdatingCalendarEvents] = useState( false ); + const [ + isUpdatingPolicySoftwareInstall, + setIsUpdatingPolicySoftwareInstall, + ] = useState(false); const [isUpdatingOtherWorkflows, setIsUpdatingOtherWorkflows] = useState( false ); const [selectedPolicyIds, setSelectedPolicyIds] = useState<number[]>([]); const [showAddPolicyModal, setShowAddPolicyModal] = useState(false); const [showDeletePolicyModal, setShowDeletePolicyModal] = useState(false); + const [showInstallSoftwareModal, setShowInstallSoftwareModal] = useState( + false + ); const [showCalendarEventsModal, setShowCalendarEventsModal] = useState(false); const [showOtherWorkflowsModal, setShowOtherWorkflowsModal] = useState(false); const [ @@ -438,6 +447,10 @@ const ManagePolicyPage = ({ const toggleDeletePolicyModal = () => setShowDeletePolicyModal(!showDeletePolicyModal); + const toggleInstallSoftwareModal = () => { + setShowInstallSoftwareModal(!showInstallSoftwareModal); + }; + const toggleCalendarEventsModal = () => { setShowCalendarEventsModal(!showCalendarEventsModal); }; @@ -447,6 +460,9 @@ const ManagePolicyPage = ({ case "calendar_events": toggleCalendarEventsModal(); break; + case "install_software": + toggleInstallSoftwareModal(); + break; case "other_workflows": toggleOtherWorkflowsModal(); break; @@ -476,6 +492,63 @@ const ManagePolicyPage = ({ } }; + const onUpdatePolicySoftwareInstall = async ( + formData: IInstallSoftwareFormData + ) => { + try { + setIsUpdatingPolicySoftwareInstall(true); + const changedPolicies = formData.filter((formPolicy) => { + const prevPolicyState = policiesAvailableToAutomate.find( + (policy) => policy.id === formPolicy.id + ); + + const turnedOff = + prevPolicyState?.install_software !== undefined && + formPolicy.installSoftwareEnabled === false; + + const turnedOn = + prevPolicyState?.install_software === undefined && + formPolicy.installSoftwareEnabled === true; + + const updatedSwId = + prevPolicyState?.install_software?.software_title_id !== undefined && + formPolicy.swIdToInstall !== + prevPolicyState?.install_software?.software_title_id; + + return turnedOff || turnedOn || updatedSwId; + }); + if (!changedPolicies.length) { + renderFlash("success", "No changes detected."); + return; + } + const responses: Promise< + ReturnType<typeof teamPoliciesAPI.update> + >[] = []; + responses.concat( + changedPolicies.map((changedPolicy) => { + return teamPoliciesAPI.update(changedPolicy.id, { + // "software_title_id:" 0 will unset software install for the policy + // "software_title_id": X will set the value to the given integer (except 0). + software_title_id: changedPolicy.swIdToInstall || 0, + team_id: teamIdForApi, + }); + }) + ); + await Promise.all(responses); + await wait(100); // prevent race + refetchTeamPolicies(); + renderFlash("success", "Successfully updated policy automations."); + } catch { + renderFlash( + "error", + "Could not update policy automations. Please try again." + ); + } finally { + toggleInstallSoftwareModal(); + setIsUpdatingPolicySoftwareInstall(false); + } + }; + const onUpdateCalendarEvents = async (formData: ICalendarEventsFormData) => { setIsUpdatingCalendarEvents(true); @@ -698,11 +771,20 @@ const ManagePolicyPage = ({ const getAutomationsDropdownOptions = () => { const isAllTeams = teamIdForApi === undefined || teamIdForApi === -1; - let disabledTooltipContent: React.ReactNode; + let disabledInstallTooltipContent: React.ReactNode; + let disabledCalendarTooltipContent: React.ReactNode; if (!isPremiumTier) { - disabledTooltipContent = "Available in Fleet Premium."; + disabledInstallTooltipContent = "Available in Fleet Premium."; + disabledCalendarTooltipContent = "Available in Fleet Premium."; } else if (isAllTeams) { - disabledTooltipContent = ( + disabledInstallTooltipContent = ( + <> + Select a team to manage + <br /> + install software automation. + </> + ); + disabledCalendarTooltipContent = ( <> Select a team to manage <br /> @@ -715,9 +797,16 @@ const ManagePolicyPage = ({ { label: "Calendar events", value: "calendar_events", - disabled: !isPremiumTier || isAllTeams, + disabled: !!disabledCalendarTooltipContent, helpText: "Automatically reserve time to resolve failing policies.", - tooltipContent: disabledTooltipContent, + tooltipContent: disabledCalendarTooltipContent, + }, + { + label: "Install software", + value: "install_software", + disabled: !!disabledInstallTooltipContent, + helpText: "Install software to resolve failing policies.", + tooltipContent: disabledInstallTooltipContent, }, { label: "Other workflows", @@ -816,6 +905,16 @@ const ManagePolicyPage = ({ onSubmit={onDeletePolicySubmit} /> )} + {showInstallSoftwareModal && ( + <InstallSoftwareModal + onExit={toggleInstallSoftwareModal} + onSubmit={onUpdatePolicySoftwareInstall} + isUpdating={isUpdatingPolicySoftwareInstall} + policies={policiesAvailableToAutomate} + // currentTeamId will at this point be present + teamId={currentTeamId ?? 0} + /> + )} {showCalendarEventsModal && ( <CalendarEventsModal onExit={toggleCalendarEventsModal} diff --git a/frontend/pages/policies/ManagePoliciesPage/_styles.scss b/frontend/pages/policies/ManagePoliciesPage/_styles.scss index cd7f309af316..a9300ee34b1c 100644 --- a/frontend/pages/policies/ManagePoliciesPage/_styles.scss +++ b/frontend/pages/policies/ManagePoliciesPage/_styles.scss @@ -220,7 +220,7 @@ } } - // Used in both CalendarEventsModal and OtherWorkflowsModal + // Used in CalendarEventsModal, InstallSoftwareModal, and OtherWorkflowsModal .automated-policies-section { display: flex; flex-direction: column; @@ -228,8 +228,10 @@ align-self: stretch; border-radius: 4px; border: 1px solid $ui-fleet-black-10; + // negate ul padding + padding-left: 0; - .checkbox-row { + .policy-row { display: flex; max-width: 100%; padding: 8px 12px; @@ -270,7 +272,7 @@ cursor: pointer; } - .checkbox-row__preview-button { + .policy-row__preview-button { visibility: visible; } } diff --git a/frontend/pages/policies/ManagePoliciesPage/components/CalendarEventsModal/CalendarEventsModal.tsx b/frontend/pages/policies/ManagePoliciesPage/components/CalendarEventsModal/CalendarEventsModal.tsx index 4a10a9c01004..3e2a9167899a 100644 --- a/frontend/pages/policies/ManagePoliciesPage/components/CalendarEventsModal/CalendarEventsModal.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/components/CalendarEventsModal/CalendarEventsModal.tsx @@ -185,11 +185,11 @@ const CalendarEventsModal = ({ return ( <div className="form-field"> <div className="form-field__label">Policies:</div> - <div className="automated-policies-section"> + <ul className="automated-policies-section"> {formData.policies.map((policy) => { const { isChecked, name, id } = policy; return ( - <div className="checkbox-row" id={`checkbox-row--${id}`} key={id}> + <li className="policy-row" id={`policy-row--${id}`} key={id}> <Checkbox value={isChecked} name={name} @@ -208,14 +208,14 @@ const CalendarEventsModal = ({ ); togglePreviewCalendarEvent(); }} - className="checkbox-row__preview-button" + className="policy-row__preview-button" > <Icon name="eye" /> Preview </Button> - </div> + </li> ); })} - </div> + </ul> <span className="form-field__help-text"> A calendar event will be created for end users if one of their hosts fail any of these policies.{" "} diff --git a/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/InstallSoftwareModal.tsx b/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/InstallSoftwareModal.tsx new file mode 100644 index 000000000000..7c29b4979f3b --- /dev/null +++ b/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/InstallSoftwareModal.tsx @@ -0,0 +1,276 @@ +import React, { useCallback, useState } from "react"; + +import { useQuery } from "react-query"; +import { omit } from "lodash"; + +import { IPolicyStats } from "interfaces/policy"; +import softwareAPI, { + ISoftwareTitlesQueryKey, + ISoftwareTitlesResponse, +} from "services/entities/software"; +import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; + +// @ts-ignore +import Dropdown from "components/forms/fields/Dropdown"; +import Modal from "components/Modal"; +import DataError from "components/DataError"; +import Spinner from "components/Spinner"; +import Checkbox from "components/forms/fields/Checkbox"; +import TooltipTruncatedText from "components/TooltipTruncatedText"; +import CustomLink from "components/CustomLink"; +import Button from "components/buttons/Button"; +import { ISoftwareTitle } from "interfaces/software"; + +const getPlatformDisplayFromPackageSuffix = (packageName: string) => { + const split = packageName.split("."); + const suff = split[split.length - 1]; + switch (suff) { + case "pkg": + return "macOS"; + case "deb": + return "Linux"; + case "exe": + return "Windows"; + case "msi": + return "Windows"; + default: + return null; + } +}; + +const AFI_SOFTWARE_BATCH_SIZE = 1000; + +const baseClass = "install-software-modal"; + +interface ISwDropdownField { + name: string; + value: number; +} +interface IFormPolicy { + name: string; + id: number; + installSoftwareEnabled: boolean; + swIdToInstall?: number; +} + +export type IInstallSoftwareFormData = IFormPolicy[]; + +interface IInstallSoftwareModal { + onExit: () => void; + onSubmit: (formData: IInstallSoftwareFormData) => void; + isUpdating: boolean; + policies: IPolicyStats[]; + teamId: number; +} +const InstallSoftwareModal = ({ + onExit, + onSubmit, + isUpdating, + policies, + teamId, +}: IInstallSoftwareModal) => { + const [formData, setFormData] = useState<IInstallSoftwareFormData>( + policies.map((policy) => ({ + name: policy.name, + id: policy.id, + installSoftwareEnabled: !!policy.install_software, + swIdToInstall: policy.install_software?.software_title_id, + })) + ); + + const anyPolicyEnabledWithoutSelectedSoftware = formData.some( + (policy) => policy.installSoftwareEnabled && !policy.swIdToInstall + ); + const { + data: titlesAFI, + isLoading: isTitlesAFILoading, + isError: isTitlesAFIError, + } = useQuery< + ISoftwareTitlesResponse, + Error, + ISoftwareTitle[], + [ISoftwareTitlesQueryKey] + >( + [ + { + scope: "software-titles", + page: 0, + perPage: AFI_SOFTWARE_BATCH_SIZE, + query: "", + orderDirection: "desc", + orderKey: "hosts_count", + teamId, + availableForInstall: true, + packagesOnly: true, + }, + ], + ({ queryKey: [queryKey] }) => + softwareAPI.getSoftwareTitles(omit(queryKey, "scope")), + { + select: (data) => data.software_titles, + ...DEFAULT_USE_QUERY_OPTIONS, + } + ); + + const onUpdateInstallSoftware = useCallback(() => { + onSubmit(formData); + }, [formData, onSubmit]); + + const onChangeEnableInstallSoftware = useCallback( + (newVal: { policyName: string; value: boolean }) => { + const { policyName, value } = newVal; + setFormData( + formData.map((policy) => { + if (policy.name === policyName) { + return { + ...policy, + installSoftwareEnabled: value, + swIdToInstall: value ? policy.swIdToInstall : undefined, + }; + } + return policy; + }) + ); + }, + [formData] + ); + + const onSelectPolicySoftware = useCallback( + ({ name, value }: ISwDropdownField) => { + const [policyName, softwareId] = [name, value]; + setFormData( + formData.map((policy) => { + if (policy.name === policyName) { + return { ...policy, swIdToInstall: softwareId }; + } + return policy; + }) + ); + }, + [formData] + ); + + const availableSoftwareOptions = titlesAFI?.map((title) => { + const platformDisplay = getPlatformDisplayFromPackageSuffix( + title.software_package?.name ?? "" + ); + const platformString = platformDisplay ? `${platformDisplay} • ` : ""; + return { + label: title.name, + value: title.id, + helpText: `${platformString}${title.software_package?.version ?? ""}`, + }; + }); + + const renderPolicySwInstallOption = (policy: IFormPolicy) => { + const { + name: policyName, + id: policyId, + installSoftwareEnabled: enabled, + swIdToInstall, + } = policy; + + return ( + <li + className={`${baseClass}__policy-row policy-row`} + id={`policy-row--${policyId}`} + key={policyId} + > + <Checkbox + value={enabled} + name={policyName} + onChange={() => { + onChangeEnableInstallSoftware({ + policyName, + value: !enabled, + }); + }} + > + <TooltipTruncatedText value={policyName} /> + </Checkbox> + {enabled && ( + <Dropdown + options={availableSoftwareOptions} + value={swIdToInstall} + onChange={onSelectPolicySoftware} + placeholder="Select software" + className={`${baseClass}__software-dropdown`} + name={policyName} + parseTarget + /> + )} + </li> + ); + }; + + const renderContent = () => { + if (isTitlesAFIError) { + return <DataError />; + } + if (isTitlesAFILoading) { + return <Spinner />; + } + if (!titlesAFI?.length) { + return ( + <div className={`${baseClass}__no-software`}> + <b>No software available for install</b> + <span> + Go to <b>Software</b> to add software to this team. + </span> + </div> + ); + } + + return ( + <div className={`${baseClass} form`}> + <div className="form-field"> + <div className="form-field__label">Policies:</div> + <ul className="automated-policies-section"> + {formData.map((policyData) => + renderPolicySwInstallOption(policyData) + )} + </ul> + <span className="form-field__help-text"> + Selected software will be installed when hosts fail the chosen + policy.{" "} + <CustomLink + url="https://fleetdm.com/learn-more-about/policy-automation-install-software" + text="Learn more" + newTab + /> + </span> + </div> + <div className="modal-cta-wrap"> + <Button + type="submit" + variant="brand" + onClick={onUpdateInstallSoftware} + className="save-loading" + isLoading={isUpdating} + disabled={anyPolicyEnabledWithoutSelectedSoftware} + > + Save + </Button> + <Button onClick={onExit} variant="inverse"> + Cancel + </Button> + </div> + </div> + ); + }; + + return ( + <Modal + title="Install software" + className={baseClass} + onExit={onExit} + onEnter={onUpdateInstallSoftware} + width="large" + isContentDisabled={isUpdating} + > + {renderContent()} + </Modal> + ); +}; + +export default InstallSoftwareModal; diff --git a/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/_styles.scss b/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/_styles.scss new file mode 100644 index 000000000000..de9cfc05be59 --- /dev/null +++ b/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/_styles.scss @@ -0,0 +1,41 @@ +.manage-policies-page { + .install-software-modal { + .form-field--dropdown { + width: 276px; + .Select-placeholder { + color: $ui-fleet-black-50; + } + .Select-menu { + max-height: none; + overflow: visible; + } + .Select-menu-outer { + max-height: 240px; + overflow-y: auto; + } + } + .policy-row { + height: 40px; + padding-top: 4px; + padding-bottom: 4px; + } + + &__no-software { + display: flex; + height: 178px; + flex-direction: column; + align-items: center; + gap: $pad-small; + justify-content: center; + font-size: $small; + + span { + color: $ui-fleet-black-75; + font-size: $xx-small; + } + } + .data-error { + padding: 78px; + } + } +} diff --git a/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/index.ts b/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/index.ts new file mode 100644 index 000000000000..a9f46a726a03 --- /dev/null +++ b/frontend/pages/policies/ManagePoliciesPage/components/InstallSoftwareModal/index.ts @@ -0,0 +1 @@ +export { default } from "./InstallSoftwareModal"; diff --git a/frontend/pages/policies/ManagePoliciesPage/components/OtherWorkflowsModal/OtherWorkflowsModal.tsx b/frontend/pages/policies/ManagePoliciesPage/components/OtherWorkflowsModal/OtherWorkflowsModal.tsx index d34ae56ff60f..7a9668e82544 100644 --- a/frontend/pages/policies/ManagePoliciesPage/components/OtherWorkflowsModal/OtherWorkflowsModal.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/components/OtherWorkflowsModal/OtherWorkflowsModal.tsx @@ -416,8 +416,8 @@ const OtherWorkflowsModal = ({ const { isChecked, name, id } = policyItem; return ( <div - className="checkbox-row" - id={`checkbox-row--${id}`} + className="policy-row" + id={`policy-row--${id}`} key={id} > <Checkbox diff --git a/frontend/router/paths.ts b/frontend/router/paths.ts index ca0d3683f5d0..3c22cd097a01 100644 --- a/frontend/router/paths.ts +++ b/frontend/router/paths.ts @@ -42,7 +42,7 @@ export default { ADMIN_INTEGRATIONS_APPLE_BUSINESS_MANAGER: `${URL_PREFIX}/settings/integrations/mdm/abm`, ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT_WINDOWS: `${URL_PREFIX}/settings/integrations/automatic-enrollment/windows`, ADMIN_INTEGRATIONS_CALENDARS: `${URL_PREFIX}/settings/integrations/calendars`, - ADMIN_INTEGRATIONS_VPP: `${URL_PREFIX}/settings/integrations/vpp`, + ADMIN_INTEGRATIONS_VPP: `${URL_PREFIX}/settings/integrations/mdm/vpp`, ADMIN_INTEGRATIONS_VPP_SETUP: `${URL_PREFIX}/settings/integrations/vpp/setup`, ADMIN_TEAMS: `${URL_PREFIX}/settings/teams`, diff --git a/frontend/services/entities/software.ts b/frontend/services/entities/software.ts index a6586808a0c1..2db788093223 100644 --- a/frontend/services/entities/software.ts +++ b/frontend/services/entities/software.ts @@ -1,5 +1,3 @@ -import { AxiosResponse } from "axios"; - import sendRequest from "services"; import endpoints from "utilities/endpoints"; import { @@ -13,7 +11,7 @@ import { buildQueryStringFromParams, convertParamsToSnakeCase, } from "utilities/url"; -import { IAddSoftwareFormData } from "pages/SoftwarePage/components/AddPackageForm/AddSoftwareForm"; +import { IAddPackageFormData } from "pages/SoftwarePage/components/AddPackageForm/AddPackageForm"; export interface ISoftwareApiParams { page?: number; @@ -26,6 +24,7 @@ export interface ISoftwareApiParams { min_cvss_score?: number; exploit?: boolean; availableForInstall?: boolean; + packagesOnly?: boolean; selfService?: boolean; teamId?: number; } @@ -206,7 +205,7 @@ export default { }, addSoftwarePackage: ( - data: IAddSoftwareFormData, + data: IAddPackageFormData, teamId?: number, timeout?: number ) => { @@ -220,8 +219,8 @@ export default { formData.append("software", data.software); formData.append("self_service", data.selfService.toString()); data.installScript && formData.append("install_script", data.installScript); - data.preInstallCondition && - formData.append("pre_install_query", data.preInstallCondition); + data.preInstallQuery && + formData.append("pre_install_query", data.preInstallQuery); data.postInstallScript && formData.append("post_install_script", data.postInstallScript); teamId && formData.append("team_id", teamId.toString()); diff --git a/frontend/services/entities/team_policies.ts b/frontend/services/entities/team_policies.ts index a10d954e8b00..d2e13863729b 100644 --- a/frontend/services/entities/team_policies.ts +++ b/frontend/services/entities/team_policies.ts @@ -86,6 +86,7 @@ export default { platform, critical, calendar_events_enabled, + software_title_id, } = data; const { TEAMS } = endpoints; const path = `${TEAMS}/${team_id}/policies/${id}`; @@ -98,6 +99,7 @@ export default { platform, critical, calendar_events_enabled, + software_title_id, }); }, destroy: (teamId: number | undefined, ids: number[]) => { diff --git a/frontend/styles/byod.css b/frontend/styles/byod.css new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/frontend/templates/enroll-ota.html b/frontend/templates/enroll-ota.html new file mode 100644 index 000000000000..6aafd2870de5 --- /dev/null +++ b/frontend/templates/enroll-ota.html @@ -0,0 +1,180 @@ +<html> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <meta name="robots" content="noindex" /> + <link rel="shortcut icon" href="{{.URLPrefix}}/assets/favicon.ico" /> + <title>Fleet</title> + <style> + @font-face { + font-family: "Inter"; + font-weight: 400; + src: url("../assets/fonts/inter/Inter-Regular.woff2") format("woff2"), + url("../assets/fonts/inter/Inter-Regular.woff") format("woff"); + } + @font-face { + font-family: "Inter"; + font-weight: 700; + src: url("../assets/fonts/inter/Inter-Bold.woff2") format("woff2"), + url("../assets/fonts/inter/Inter-Bold.woff") format("woff"); + } + + html { + box-sizing: border-box; + font-family: "Inter", sans-serif; + color: #192147; + } + + *, + *:before, + *:after { + box-sizing: inherit; + } + + body, + h1, + p { + margin: 0; + } + + .download-link { + color: #fff; + background-color: #6a67fe; + padding: 8px 16px; + border-radius: 4px; + text-decoration: none; + font-weight: 700; + font-size: 14px; + width: 100px; + line-height: 21px; + } + + header { + background-color: #192147; + padding: 13px 20px; + } + + h1 { + margin-bottom: 8px; + } + + p { + font-size: 14px; + } + + ol { + display: flex; + flex-direction: column; + gap: 48px; + list-style: none; + padding: 0; + } + + li { + display: flex; + flex-direction: column; + gap: 20px; + } + + li > p { + display: flex; + gap: 4px; + } + + .page-description { + font-size: 16px; + margin-bottom: 48px; + } + + .profile-downloaded-container, + .install-profile-container { + width: 100%; + background-color: #f9fafc; + text-align: center; + } + + .profile-downloaded-img, + .install-profile-img { + max-width: 100%; + } + + .profile-downloaded-img { + max-height: 150px; + } + + .install-profile-img { + max-height: 118px; + } + + #main-content { + padding: 48px 24px; + } + </style> + </head> + <body> + <header> + <img src="{{.URLPrefix}}/assets/images/fleet-logo.svg" /> + </header> + <section id="main-content"> + <h1>Enroll your device to Fleet</h1> + <p class="page-description"> + Follow the instructions below to download and install the Fleet profile + on your device. + </p> + <ol> + <li> + <p> + <span>1.</span> + <span> + <b>Download</b> the Fleet profile and select <b>Allow</b> in the + pop-up. + </span> + </p> + <a class="download-link" href="{{.EnrollURL}}">Download</a> + </li> + <li> + <p> + <span>2.</span> + <span> + Navigate to <b>Settings</b> and select <b>Profile Downloaded</b>. + </span> + </p> + <div class="profile-downloaded-container"> + <img + class="profile-downloaded-img" + src="" + alt="select profile downloaded in settings" + /> + </div> + </li> + <li> + <p> + <span>3.</span> + <span>Select <b>Install</b>.</span> + </p> + <div class="install-profile-container"> + <img class="install-profile-img" src="" alt="select install" /> + </div> + </li> + </ol> + </section> + <script> + const os = navigator.userAgent.includes("iPhone") ? "ios" : "iPadOS"; + + const profileDownloadedImg = document.querySelector( + ".profile-downloaded-img" + ); + const installProfileImg = document.querySelector(".install-profile-img"); + + // setting image src based on OS + profileDownloadedImg.setAttribute( + "src", + `{{.URLPrefix}}/assets/images/${os}-profile-downloaded.png` + ); + installProfileImg.setAttribute( + "src", + `{{.URLPrefix}}/assets/images/${os}-install-profile.png` + ); + </script> + </body> +</html> diff --git a/frontend/utilities/constants.tsx b/frontend/utilities/constants.tsx index c399b5e9c107..4b780aebc98c 100644 --- a/frontend/utilities/constants.tsx +++ b/frontend/utilities/constants.tsx @@ -60,9 +60,13 @@ export const HOST_STATUS_WEBHOOK_WINDOW_DROPDOWN_OPTIONS: IDropdownOption[] = [ export const GITHUB_NEW_ISSUE_LINK = "https://github.com/fleetdm/fleet/issues/new?assignees=&labels=bug%2C%3Areproduce&template=bug-report.md"; -export const SUPPORT_LINK = "https://fleetdm.com/support"; +export const FLEET_WEBSITE_URL = "https://fleetdm.com"; -export const CONTACT_FLEET_LINK = "https://fleetdm.com/contact"; +export const SUPPORT_LINK = `${FLEET_WEBSITE_URL}/support`; + +export const CONTACT_FLEET_LINK = `${FLEET_WEBSITE_URL}/contact`; + +export const LEARN_MORE_ABOUT_BASE_LINK = `${FLEET_WEBSITE_URL}/learn-more-about`; /** July 28, 2016 is the date of the initial commit to fleet/fleet. */ export const INITIAL_FLEET_DATE = "2016-07-28T00:00:00Z"; diff --git a/go.mod b/go.mod index e8e855c0aee3..d1458f2471d6 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/fleetdm/fleet/v4 -go 1.22.6 +go 1.23.1 require ( cloud.google.com/go/pubsub v1.36.1 diff --git a/handbook/business-operations/README.md b/handbook/business-operations/README.md index 5635ad4bbd77..11bc287b1793 100644 --- a/handbook/business-operations/README.md +++ b/handbook/business-operations/README.md @@ -267,7 +267,9 @@ When BizOps receives notification of a Fleetie's job title changing, follow thes - Input the new job title in the Fleetie's row in the "Job title" cell. - Navigate to the "Org chart" tab of the spreadsheet, and verify that the Fleetie's title appears correctly in the org chart. 2. Update the departmental handbook page with the change of job title -3. Update the relevant payroll/HRIS system. +3. [Prepare salary benchmarking information](https://fleetdm.com/handbook/business-operations#prepare-salary-benchmarking-information) to determine whether the teammate's current compensation aligns with the benchmarks of the new role. + - If the benchmark is significantly different, take the steps to [update a team member's compensation](https://fleetdm.com/handbook/business-operations#prepare-salary-benchmarking-information). +4. Update the relevant payroll/HRIS system. - For updating Gusto (US-based Fleeties): - Login to Gusto and navigate to "People > Team members". - Find the Fleetie and select them to see their profile page. diff --git a/handbook/business-operations/business-operations.rituals.yml b/handbook/business-operations/business-operations.rituals.yml index e6df744a0a9b..fec505589810 100644 --- a/handbook/business-operations/business-operations.rituals.yml +++ b/handbook/business-operations/business-operations.rituals.yml @@ -52,7 +52,7 @@ task: "Prioritize for next sprint" # Title that will actually show in rituals table startedOn: "2023-08-09" # Needs to align with frequency e.g. if frequency is every thrid Thursday startedOn === any third thursday frequency: "Triweekly" # must be supported by https://github.com/fleetdm/fleet/blob/dbbb501358e226fa3fdf48865175efe3334c826c/website/scripts/build-static-content.js - description: "Drag next sprint's priorities to the top of the 'Not yet' column." # example of a longer thing: description: "[Prioritizing next sprint](https://fleetdm.com/handbook/digital-experiencemunication)" + description: "Using your departmental kanban board, prioritize and finalize next sprint's goals for your team by draging the appropriate issues to the top of the 'Not yet' column." # example of a longer thing: description: "[Prioritizing next sprint](https://fleetdm.com/handbook/digital-experiencemunication)" moreInfoUrl: "https://fleetdm.com/handbook/company/why-this-way#why-make-work-visible" #URL used to highlight "description:" test in table dri: "jostableford" # DRI for ritual (assignee if autoIssue) (TODO display GitHub proflie pic instead of name or title) autoIssue: # Enables automation of GitHub issues diff --git a/handbook/company/communications.md b/handbook/company/communications.md index bc0dde30fbb0..7b5a97b1ae54 100644 --- a/handbook/company/communications.md +++ b/handbook/company/communications.md @@ -1519,6 +1519,11 @@ Use dashes (at least 3) to separate the header, and add colons to align the text |:---|---:|:---:| | Left alignment | Right alignment | Center Alignment | +> When using tables to document API endpoint parameters, we use the following conventions: +> + Document nested objects in their own separate tables. See the [**Modify configuration**](https://fleetdm.com/docs/rest-api/rest-api#modify-configuration) documentation for example formatting. +> + In the **Type** column, use the terms "boolean" (not "bool"), and "array" (not "list"). +> + In the **Description** column for required parameters, begin the description with "**Required.**" + ### Blockquotes diff --git a/handbook/company/leadership.md b/handbook/company/leadership.md index 2bd1e493461a..a567aeec991a 100644 --- a/handbook/company/leadership.md +++ b/handbook/company/leadership.md @@ -255,7 +255,7 @@ When review is requested on a proposal to open a new position, the Apprentice to - _Update team database:_ Update the row in ["¶¶ 🥧 Equity plan"](https://docs.google.com/spreadsheets/d/1_GJlqnWWIQBiZFOoyl9YbTr72bg5qdSSp4O3kuKm1Jc/edit#gid=0) using the benchmarked compensation and share count. - _Salary:_ Enter the salary: If the role has variable compensation, use the role's OTE (on-target earning estimate) as the budgeted salary amount, and leave a note in the "Notes (¶¶)" cell clarifying the role's bonus or commission structure. - _Equity:_ Enter the equity as a number of shares, watching the percentage that is automatically calculated in the next cell. Keep guessing different numbers of shares until you get the derived percentage looking like what you want to see. - - _Create Slack channel:_ Create a private "#hiring-xxxxxx-YYYY" Slack channel (where "xxxxxx" is the job title and YYYY is the current year) for discussion and invite the hiring manager and Head of Business Operations. + - _Create Slack channel:_ Create a private "#YYYY-hiring-xxxxxx" Slack channel (where "xxxxxx" is the job title and YYYY is the current year) for discussion and invite the hiring manager and Head of Business Operations. - _Publish opening:_ Approve and merge the pull request. The job posting will go live within ≤10 minutes. - _Track as approved in "Fleeties":_ In the "Fleeties" spreadsheet, find the row for the new position and update the "Job description" column and replace the URL of the pull request that originally proposed this new position with the URL of the GitHub merge commit when that PR was merged. - _Reply to requestor:_ Post a comment on the pull request, being sure to include a direct link to their live job description on fleetdm.com. (This is the URL where candidates can go to read about the job and apply. For example: `fleetdm.com/handbook/company/product-designer`): @@ -442,14 +442,6 @@ Although it's sad to see someone go, Fleet understands that not everything is me 4. **CEO**: The CEO will make an announcement during the "🌈 Weekly Update" post on Friday in the `#general` channel on Slack. -## Changing someone's position - -From time to time, someone's job title changes. To do this, Business Operations follows these steps: - -1. Change "Fleeties" to reflect the new job title, manager, and/or department. -2. If there is a compensation change, update "Equity plan". Use the first day of a month as the date, and enter this in the corresponding column. -3. If applicable, schedule the change in the appropriate payroll system. (Don't worry about updating job titles in the payroll system.) - <img width="384" alt="image" src="https://github.com/fleetdm/fleet/assets/618009/1e59ac8f-26d8-4f2f-9ff4-2e303753c910"> diff --git a/handbook/company/open-positions.yml b/handbook/company/open-positions.yml index 19ad611e9ee0..36eb7e05dfb1 100644 --- a/handbook/company/open-positions.yml +++ b/handbook/company/open-positions.yml @@ -36,3 +36,35 @@ - 🛠️ Technical: You understand the software development processes. You understand that software quality matters. - 🟣 Openness: You are flexible and open to new ideas and ways of working. - ➕ Bonus: Cybersecurity or IT background. + +- jobTitle: 🐋 Account Executive + department: Customers + hiringManagerName: Alex Mitchell + hiringManagerLinkedInUrl: https://www.linkedin.com/in/alexandercmitchell/ + hiringManagerGithubUsername: alexmitchelliii + responsibilities: | + - 🎯 Direct and participate in prospecting target companies, identifying key decision makers and influencers, leading when assigned/necessary/appropriate. + - 📈 Use available data to identify opportunities and trends with individual prospects. + - 📣 Actively promote FleetDM product and services on social media. + - 🖥️ Actively present and demonstrate the value of FleetDM products and services and upgrades targeting customer expansion opportunities. + - ❔ Appropriately use and follow MEDDPPICC process to qualify and progress opportunities to best help prospects solve problems. + - 🤔 Anticipate market trends and identify new opportunities for growth. + - 🕴️ Utilize systems and tools such as salesforce to analyze pipeline and opportunity data and keep all information up to date for leadership reporting. + - 🚀 Work collaboratively with the product management, customer support, and engineering teams to facilitate feature development based on customer asks. + - 🧑💻 Collaborate with the marketing team to plan, execute and track impactful marketing campaigns, in order to meet and/or exceed quarterly pipeline and revenue targets. + - 🤝 Work with prospects to find win-win commercial agreements. + experience: | + - 🦉 5+ years experience selling to enterprise customers. + - 📣 Have excellent communication and interpersonal skills. + - 🧑💻 Love technology and can explain how things work in detail. + - 🧪 Extensive experience with Slack, Salesforce, Google Suite, and GitHub. + - ⏩ Thrive in a complex, fast-paced, results driven environment with the ability to pivot to organizational changes easily. + - 🤝 Decisive with the ability to shift gears between thinking and doing. + - 📈 Ability to partner with various teams and stakeholders to drive sales. + - 👀 Strong understanding of the enterprise procurement process. + - ➕ Bonus: Direct experience with Fleet, MDM, osquery or SQL query writing, and working with Client Platform Engineering, SRE, or Security Engineering teams. + - 💭 You know how to manage complex sales, difficult escalations, and challenging procurement processes with the utmost care and organization. + - 💖 You know how to manage your time and priorities between leads, opportunities other day-to-day responsibilities. + - ✍ You have the ability to effectively influence key stakeholders, from senior executives to day-to-day engineering contacts, and discuss Fleet's value with them. + - 🧬 You care about delivering an outstanding customer experience and advocating for the customer's needs within Fleet. + - ➕ Bonus: You are comfortable with concepts like DevOps/GitOps, APIs, and security. diff --git a/handbook/company/pricing-features-table.yml b/handbook/company/pricing-features-table.yml index 43c301c0fe34..85392beb5d1b 100644 --- a/handbook/company/pricing-features-table.yml +++ b/handbook/company/pricing-features-table.yml @@ -107,7 +107,10 @@ jamfProtectHasFeature: no productCategories: [Endpoint operations] pricingTableCategories: [Configuration] - usualDepartment: IT + usualDepartment: IT + waysToUse: + - description: Supply-chain Levels for Software Artifacts (SLSA) attestations for the fleetd binary artifacts and server container image to enable verification that the binaries are built and uploaded using GitHub Actions from the Fleet repository at a particular commit SHA coming soon (2024-12-31). + - moreInfoUrl: https://github.com/fleetdm/fleet/issues/20219 #customer-figali # # ╔═╗╔═╗╔╦╗╔╦╗╔═╗╔╗╔╔╦╗ ╦ ╦╔╗╔╔═╗ ╔╦╗╔═╗╔═╗╦ ┌─ ╔═╗╦ ╦ ─┐ # ║ ║ ║║║║║║║╠═╣║║║ ║║ ║ ║║║║║╣ ║ ║ ║║ ║║ │ ║ ║ ║ │ @@ -1586,4 +1589,4 @@ # tier: Free # jamfProHasFeature: yes # jamfProtectHasFeature: yes -# \ No newline at end of file +# diff --git a/handbook/company/product-groups.md b/handbook/company/product-groups.md index 8a21d332eeae..2783f7faa31f 100644 --- a/handbook/company/product-groups.md +++ b/handbook/company/product-groups.md @@ -1,4 +1,5 @@ # 🛩️ Product groups + This page covers what all contributors (fleeties or not) need to know in order to contribute changes to [the core product](https://fleetdm.com/docs). When creating software, handoffs between teams or contributors are one of the most common sources of miscommunication and waste. Like [GitLab](https://docs.google.com/document/d/1RxqS2nR5K0vN6DbgaBw7SEgpPLi0Kr9jXNGzpORT-OY/edit#heading=h.7sfw1n9c1i2t), Fleet uses product groups to minimize handoffs and maximize iteration and efficiency in the way we build the product. @@ -6,10 +7,14 @@ When creating software, handoffs between teams or contributors are one of the mo > - Write down philosophies and show how the pieces of the development process fit together on this "🛩️ Product groups" page. > - Use the dedicated [departmental](https://fleetdm.com/handbook/company#org-chart) handbook pages for [🚀 Engineering](https://fleetdm.com/handbook/engineering) and [🦢 Product Design](https://fleetdm.com/handbook/product) to keep track of specific, rote responsibilities and recurring rituals designed to be read and used only by people within those departments. + ## Product roadmap + Fleet team members can read [Fleet's high-level product goals for the current quarter](https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit?usp=sharing) (confidential Google Sheet). + ## What are product groups? + Fleet organizes product development efforts into separate, cross-functional product groups that include product designers, developers, and quality engineers. These product groups are organized by business goal, and designed to operate in parallel. Security, performance, stability, scalability, database migrations, release compatibility, usage documentation (such as REST API and configuration reference), contributor experience, and support escalation are the responsibility of every product group. @@ -18,6 +23,7 @@ At Fleet, [anyone can contribute](https://fleetdm.com/handbook/company#openness) > Ideas expressed in wireframes, like code contributions, [are welcome from everyone](https://chat.osquery.io/c/fleet), inside or outside the company. + ## Current product groups | Product group | Goal _(value for customers and/or community)_ | Capacity\* | @@ -27,7 +33,9 @@ At Fleet, [anyone can contribute](https://fleetdm.com/handbook/company#openness) \* The number of [estimated story points](https://fleetdm.com/handbook/company/communications#estimation-points) this group can take on per-sprint under ideal circumstances, used as a baseline number for planning and prioritizing user stories for drafting. In reality, capacity will vary as engineers are on-call, out-of-office, filling in for other product groups, etc. + ### Endpoint ops group + The goal of the endpoint ops group is to increase and exceed [Fleet's product maturity goals in the endpoint operations category](https://drive.google.com/file/d/11yQ_2WG7TbRErUpMBKWu_hQ5wRIZyQhr/view?usp=sharing). | Responsibility | Human(s) | @@ -40,7 +48,9 @@ The goal of the endpoint ops group is to increase and exceed [Fleet's product ma > The [Slack channel](https://fleetdm.slack.com/archives/C01EZVBHFHU), [kanban release board](https://app.zenhub.com/workspaces/-g-endpoint-ops-current-sprint-63bd7e0bf75dba002a2343ac/board), and [GitHub label](https://github.com/fleetdm/fleet/issues?q=is%3Aopen+is%3Aissue+label%3A%23g-endpoint-ops) for this product group is `#g-endpoint-ops`. + ### MDM group + The goal of the MDM group is to increase and exceed [Fleet's product maturity goals](https://drive.google.com/file/d/11yQ_2WG7TbRErUpMBKWu_hQ5wRIZyQhr/view?usp=sharing) in the "MDM" product category. | Responsibility | Human(s) | @@ -53,7 +63,9 @@ The goal of the MDM group is to increase and exceed [Fleet's product maturity go > The [Slack channel](https://fleetdm.slack.com/archives/C03C41L5YEL), [kanban release board](https://app.zenhub.com/workspaces/-g-mdm-current-sprint-63bc507f6558550011840298/board), and [GitHub label](https://github.com/fleetdm/fleet/issues?q=is%3Aopen+is%3Aissue+label%3A%23g-mdm) for this product group is `#g-mdm`. + ## Making changes + Fleet's highest product ambition is to create experiences that users want. To deliver on this mission, we need a clear, repeatable process for turning an idea into a set of cohesively-designed changes in the product. We also need to allow [open source contributions](https://fleetdm.com/handbook/company#open-source) at any point in the process from the wider Fleet community - these won't necessarily follow this process. @@ -65,14 +77,18 @@ To make a change to Fleet: - Then, it will be [drafted](https://fleetdm.com/handbook/company/product-groups#drafting) (planned). - Next, it will be [implemented](https://fleetdm.com/handbook/company/product-groups#implementing) and [released](https://fleetdm.com/handbook/engineering#release-process). + ### Planned and unplanned changes + Most changes to Fleet are planned changes. They are [prioritized](https://fleetdm.com/handbook/product), defined, designed, revised, estimated, and scheduled into a release sprint _prior to starting implementation_. The process of going from a prioritized goal to an estimated, scheduled, committed user story with a target release is called "drafting", or "the drafting phase". Occasionally, changes are unplanned. Like a patch for an unexpected bug, or a hotfix for a security issue. Or if an open source contributor suggests an unplanned change in the form of a pull request. These unplanned changes are sometimes OK to merge as-is. But if they change the user interface, the CLI usage, or the REST API, then they need to go through drafting and reconsideration before merging. > But wait, [isn't this "waterfall"?](https://about.gitlab.com/handbook/product-development-flow/#but-wait-isnt-this-waterfall) Waterfall is something else. Between 2015-2023, GitLab and The Sails Company independently developed and coevolved similar delivery processes. (What we call "drafting" and "implementation" at Fleet, is called "the validation phase" and "the build phase" at GitLab.) + ### Experimental features + When a new feature is introduced it may be labeled as experimental. Experimental features are undergoing a rapid [incremental improvement and iteration process](https://fleetdm.com/handbook/company/why-this-way#why-lean-software-development) where new learnings may requires breaking changes. When we introduce experimental features, it is important that any API endpoints or configuration surface that may change in the future be clearly labeled as experimental. 1. Apply the `~experimental` label to all associated user stories. @@ -81,7 +97,9 @@ When a new feature is introduced it may be labeled as experimental. Experimental > **Experimental feature**. This feature is undergoing rapid improvement, which may result in breaking changes to the API or configuration surface. It is not recommended for use in automated workflows. + ### Breaking changes + For product changes that cause breaking API or configuration changes or major impact for users (or even just the _impression_ of major impact!), the company plans migration thoughtfully. If the feature was released as stable (not experimental), the product group and E-group: 1. **Written:** Write a migration guide. @@ -92,12 +110,16 @@ For product changes that cause breaking API or configuration changes or major im All of the steps above happen prior to any breaking changes to stable features being prioritized for implementation. + #### API changes + To maintain consistency, ensure perspective, and provide a single pair of eyes in the design of Fleet's REST API and API documentation, there is a single Directly Responsible Individual (DRI). The API design DRI will review and approve any alterations at the pull request stage, instead of making it a prerequisite during drafting of the story. You may tag the DRI in a GitHub issue with draft API specs in place to receive a review and feedback prior to implementation. Receiving a pre-review from the DRI is encouraged if the API changes introduce new endpoints, or substantially change existing endpoints. No API changes are merged without accompanying API documentation and approval from the DRI. The DRI is responsible for ensuring that the API design remains consistent and adequately addresses both standard and edge-case scenarios. The DRI is also the code owner of the API documentation Markdown file. The DRI is committed to reviewing PRs within one business day. In instances where the DRI is unavailable, the Head of Product will act as the substitute code owner and reviewer. + #### Changes to tables' schema + Whenever a PR is proposed for making changes to our [tables' schema](https://fleetdm.com/tables/screenlock)(e.g. to schema/tables/screenlock.yml), it also has to be reflected in our osquery_fleet_schema.json file. The website team will [periodically](https://fleetdm.com/handbook/marketing/website-handbook#rituals) update the json file with the latest changes. If the changes should be deployed sooner, you can generate the new json file yourself by running these commands: @@ -110,14 +132,18 @@ cd website > If a table is added to our ChromeOS extension but it does not exist in osquery or if it is a table added by fleetd, add a note that mentions it, as in this [example](https://github.com/fleetdm/fleet/blob/e95e075e77b683167e86d50960e3dc17045e3c44/schema/tables/mdm.yml#L2). + ### Drafting + "Drafting" is the art of defining a change, designing and shepherding it through the drafting process until it is ready for implementation. The goal of drafting is to deliver software that works every time with less total effort and investment, without making contribution any less fun. By researching and iterating [prior to development](https://fleetdm.com/handbook/company/why-this-way#why-do-we-use-a-wireframe-first-approach), we design better product features, crystallize fewer bad, preemptive naming decisions, and achieve better throughput: getting more done in less time. > Fleet's drafting process is focused first and foremost on product development, but it can be used for any kind of change that benefits from planning or a "dry run". For example, imagine you work for a business who has decided to swap out one of your payroll or device management vendors. You will probably need to plan and execute changes to a number of complicated onboarding/offboarding processes. + #### Drafting process + The DRI for defining and drafting issues for a product group is the product manager, with close involvement from the designer and engineering manager. But drafting is a team effort, and all contributors participate. A user story is considered ready for implementation once: @@ -130,19 +156,25 @@ A user story is considered ready for implementation once: > All user stories intended for the next sprint are estimated by the last estimation session before the sprint begins. This makes sure contributors have adequate time to complete the current sprint and provide accurate estimates for the next sprint. + #### Writing a good user story + Good user stories are short, with clear, unambiguous language. - What screen are they looking at? (`As an observer on the host details page…`) - What do they want to do? (`As an observer on the host details page, I want to run a permitted query.`) - Don't get hung up on the "so that I can ________" clause. It is helpful, but optional. - Example: "As an admin I would like to be asked for confirmation before deleting a user so that I do not accidentally delete a user." + #### Is it actually a story? + User stories are small and independently valuable. - Is it small enough? Will this task be likely to fit in 1 sprint when estimated? - Is it valuable enough? Will this task drive business value when released, independent of other tasks? + #### Defining "done" + To successfully deliver a user story, the people working on it need to know what "done" means. Since the goal of a user story is to implement certain changes to the product, the "definition of done" is written and maintained by the product manager. But ultimately, this "definition of done" involves everyone in the product group. We all collectively rely on accuracy of estimations, astuteness of designs, and cohesiveness of changes envisioned in order to deliver on time and without fuss. @@ -163,7 +195,9 @@ Things to consider when writing the "definition of done" for a user story: - **QA:** Changes are tested by hand prior to submitting pull requests. In addition, quality assurance will do an extra QA check prior to considering this story "done". Any special QA notes? - **Follow-through:** Is there anything in particular that we should inform others (people who aren't in this product group) about after this user story is released? For example: communication to specific customers, tips on how best to highlight this in a release post, gotchas, etc. + #### Providing context + User story issues contain an optional section called "Context". This section is optional and hidden by default. It can be included or omitted, as time allows. As Fleet grows as an all-remote company with more asynchronous processes across timezones, we will rely on this section more and more. @@ -181,7 +215,9 @@ Here are some examples of questions that might be helpful to answer: These questions are helpful for the product team when considering what to prioritize. (The act of writing the answers is a lot of the value!) But these answers can also be helpful when users or contributors (including our future selves) have questions about how best to estimate, iterate, or refine. + #### Initiate an air guitar session + Anyone in the product group can initiate an air guitar session. 1. Initiate: Create a user story and add the `~air-guitar` label to indicate that it is going through the air guitar process. Air guitar issues are always intended to be designed right away. If they can't be, the requestor is notified via at-mention in the issue (that person is either the CSM or AE). @@ -205,9 +241,12 @@ Anyone in the product group can initiate an air guitar session. Air guitar sessions are timeboxed to ensure they are fast and focused. Documentation from this process may inform future user stories and can be invaluable when revisiting the idea at a later stage. While the air guitar process is exploratory in nature, it should be thorough enough to provide meaningful insights and data for future decision-making. + ### Implementing + #### Developing from wireframes + Please read carefully and [pay special attention](https://fleetdm.com/handbook/company/why-this-way#why-do-we-use-a-wireframe-first-approach) to UI wireframes. Designs have usually gone through multiple rounds of revisions, but they could easily still be overlooking complexities or edge cases! When you think you've discovered a blocker, here's how to proceed: @@ -226,7 +265,9 @@ At Fleet, we prioritize [iteration](https://fleetdm.com/handbook/company#results After these considerations, if you still think you've found a blocker, alert the [appropriate PM](https://fleetdm.com/handbook/company/product-groups#current-product-groups) so that the user story can be brought back for [expedited drafting](https://fleetdm.com/handbook/product#expedited-drafting). Otherwise, make a [feature request](https://fleetdm.com/handbook/product#intake). + #### Sub-tasks + The simplest way to manage work is to use a single user story issue, then pass it around between contributors/asignees as seldom as possible. But on a case-by-case basis, for particular user stories and teams, it can sometimes be worthwhile to invest additional overhead in creating separate **unestimated sub-task** issues ("sub-tasks"). A user story is estimated to fit within 1 sprint and drives business value when released, independent of other stories. Sub-tasks are not. @@ -241,20 +282,28 @@ Sub-tasks: - are NOT the best place to post GitHub comments (instead, concentrate conversation in the top-level "user story" issue) - will NOT be looked at or QA'd by quality assurance + ## Outages + At Fleet, we consider an outage to be a situation where new features or previously stable features are broken or unusable. - Occurences of outages are tracked in the [Outages](https://docs.google.com/spreadsheets/d/1a8rUk0pGlCPpPHAV60kCEUBLvavHHXbk_L3BI0ybME4/edit#gid=0) spreadsheet. - Fleet encourages embracing the inevitability of mistakes and discourages blame games. - Fleet stresses the critical importance of avoiding outages because they make customers' lives worse instead of better. + ## Scaling Fleet + Fleet, as a Go server, scales horizontally very well. It’s not very CPU or memory intensive. However, there are some specific gotchas to be aware of when implementing new features. Visit our [scaling Fleet page](https://fleetdm.com/handbook/engineering/scaling-fleet) for tips on scaling Fleet as efficiently and effectively as possible. + ## Load testing + The [load testing page](https://fleetdm.com/handbook/engineering/load-testing) outlines the process we use to load test Fleet, and contains the results of our semi-annual load test. + ## Version support + To provide the most accurate and efficient support, Fleet will only target fixes based on the latest released version. In the current version fixes, Fleet will not backport to older releases. Community version supported for bug fixes: **Latest version only** @@ -265,7 +314,9 @@ Premium version supported for bug fixes: **Latest version only** Premium support for support/troubleshooting: **All versions** + ## Release testing + When a release is in testing, QA should use the Slack channel #help-qa to keep everyone aware of issues found. All bugs found should be reported in the channel after creating the bug first. When a critical bug is found, the Fleetie who labels the bug as critical is responsible for following the [critical bug notification process](https://fleetdm.com/handbook/engineering#notify-community-members-about-a-critical-bug) below. @@ -280,7 +331,9 @@ All unreleased bugs are addressed before publishing a release. Released bugs tha - Causes irreversible damage, such as data loss - Introduces a security vulnerability + ### Notify the community about a critical bug + We inform customers and the community about critical bugs immediately so they don’t trigger it themselves. When a bug meeting the definition of critical is found, the bug finder is responsible for raising an alarm. Raising an alarm means pinging @here in the `#g-mdm` or `#g-endpoint-ops` channel with the filed bug. If the bug finder is not a Fleetie (e.g., a member of the community), then whoever sees the critical bug should raise the alarm. Note that the bug finder here is NOT necessarily the **first** person who sees the bug. If you come across a bug you think is critical, but it has not been escalated, raise the alarm! @@ -295,7 +348,9 @@ When a critical bug is identified, we will then follow the patch release process > After a critical bug is fixed, [an incident postmortem](https://fleetdm.com/handbook/engineering#preform-an-incident-postmortem) is scheduled by the EM of the product group that fixed the bug. + ## Feature fest + To stay in-sync with our customers' needs, Fleet accepts feature requests from customers and community members on a sprint-by-sprint basis in the regular 🎁🗣 Feature Fest meeting. Anyone in the company is invited to submit requests or simply listen in on the 🎁🗣 Feature Fest meeting. Folks from the wider community can also [request an invite](https://fleetdm.com/contact). ### Making a request @@ -303,7 +358,9 @@ To make a feature request or advocate for a feature request from a customer or c Requests are weighed from top to bottom while prioritizing attendee requests. This means that if the individual that added a feature request is not in attendance, the feature request will be discussed towards the end of the call if there's time. + ### How feature requests are evaluated + Digestion of these new product ideas (requests) happens at the **🎁🗣 Feature Fest** meeting. Before the **🎁🗣 Feature Fest** meeting, the [Customer renewals DRI](https://fleetdm.com/handbook/company/communications#directly-responsible-individuals-dris) goes through the "Inbox" column and removes customer requests that are not a high priority for the business. Stakeholders will be notified by the Customer renewals DRI. @@ -326,7 +383,9 @@ Requests are weighed by: - How well the request fits within Fleet's product vision and roadmap - Whether the feature seems like it can be designed, estimated, and developed in 6 weeks, given its individual complexity and when combined with other work already accepted + ### After the feature is accepted + After the 🎁🗣 Feature Fest meeting, the Feature prioritization DRI will clear the Feature Fest board as follows: **Prioritized features:** Remove `feature fest` label, add `:product` label, and move the issue to the "Ready" column in the drafting board. The request will then be assigned to a [Product Designer](https://fleetdm.com/handbook/company/product-groups#current-product-groups) during the "Design sprint kick-off" ritual. **Put to the side features:** Remove `feature fest` label and notify the requestor. @@ -363,14 +422,18 @@ You can read our guide to diagnosing issues in Fleet on the [debugging page](htt - [In engineering](https://fleetdm.com/handbook/company/product-groups#in-engineering) - [Awaiting QA](https://fleetdm.com/handbook/company/product-groups#awaiting-qa) + ### All bugs + - [See on GitHub](https://github.com/fleetdm/fleet/issues?q=is%3Aissue+is%3Aopen+label%3Abug). - **Bugs opened this week:** This filter returns all "bug" issues opened after the specified date. Simply replace the date with a YYYY-MM-DD equal to one week ago. [See on GitHub](https://github.com/fleetdm/fleet/issues?q=is%3Aissue+archived%3Afalse+label%3Abug+created%3A%3E%3DREPLACE_ME_YYYY-MM-DD). - **Bugs closed this week:** This filter returns all "bug" issues closed after the specified date. Simply replace the date with a YYYY-MM-DD equal to one week ago. [See on Github](https://github.com/fleetdm/fleet/issues?q=is%3Aissue+archived%3Afalse+is%3Aclosed+label%3Abug+closed%3A%3E%3DREPLACE_ME_YYYY-MM-DD). + #### Inbox + Quickly reproducing bug reports is a [priority for Fleet](https://fleetdm.com/handbook/company/why-this-way#why-make-it-obvious-when-stuff-breaks). When a new bug is created using the [bug report form](https://github.com/fleetdm/fleet/issues/new?assignees=&labels=bug%2C%3Areproduce&template=bug-report.md&title=), it is in the "inbox" state. At this state, the bug review DRI (QA) is responsible for going through the inbox and documenting reproduction steps, asking for more reproduction details from the reporter, or asking the product team for more guidance. QA has **1 business day** to move the bug to the next step (reproduced). @@ -379,7 +442,9 @@ For community-reported bugs, this may require QA to gather more information from Once reproduced, QA documents the reproduction steps in the description and moves it to the reproduced state. If QA or the engineering manager feels the bug report may be expected behavior, or if clarity is required on the intended behavior, it is assigned to the group's product manager. [See on GitHub](https://github.com/fleetdm/fleet/issues?q=archived%3Afalse+org%3Afleetdm+is%3Aissue+is%3Aopen+label%3Abug+label%3A%3Areproduce+sort%3Acreated-asc+). + #### Reproduced + QA has reproduced the issue successfully. It should now be transferred to engineering. Remove the “reproduce” label, add the following labels: @@ -393,10 +458,14 @@ Once the bug is properly labeled, assign it to the [relevant engineering manager > **Fast track for Fleeties:** Fleeties do not have to wait for QA to reproduce the bug. If you're confident it's reproducible, it's a bug, and the reproduction steps are well-documented, it can be moved directly to the reproduced state. + #### In product drafting (as needed) + If a bug requires input from product the `:product` label is added, the `:release` label is removed, and the PM is assigned to the issue. It will stay in this state until product closes the bug, or removes the `:product` label and assigns to an EM. + #### In engineering + A bug is in engineering after it has been reproduced and assigned to an EM. If a bug meets the criteria for a [critical bug](https://fleetdm.com/handbook/engineering#critical-bugs), the `~critical bug` label is added, and the EM follows the [critical bug notification process](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Releasing-Fleet.md#critical-bug-notification-process). During daily standup, the EM will filter the board to only `:incoming` bugs and review with the team. The EM will remove the `:incoming` label, prioritize the bug in the "Ready" coulmn, unassign themselves, and assign an engineer or leave it unassigned for the first available engineer. @@ -415,13 +484,19 @@ For Endpoint ops support on MDM bugs: Fleet [always prioritizes bugs](https://fleetdm.com/handbook/product#prioritizing-improvements). + #### Awaiting QA + Bugs will be verified as fixed by QA when they are placed in the "Awaiting QA" column of the relevant product group's sprint board. If the bug is verified as fixed, it is moved to the "Ready for release" column of the sprint board. Otherwise, the remaining issues are noted in a comment, and it is moved back to the "In progress" column of the sprint board. + ## How to reach the developer on-call + Oncall engineers do not need to actively monitor Slack channels, except when called in by the Community or Customer teams. Members of those teams are instructed to `@oncall` in `#help-engineering` to get the attention of the on-call engineer to continue discussing any issues that come up. In some cases, the Community or Customer representative will continue to communicate with the requestor. In others, the on-call engineer will communicate directly (team members should use their judgment and discuss on a case-by-case basis how to best communicate with community members and customers). + ### The developer on-call rotation + See [the internal Google Doc](https://docs.google.com/document/d/1FNQdu23wc1S9Yo6x5k04uxT2RwT77CIMzLLeEI2U7JA/edit#) for the engineers in the rotation. Fleet team members can also subscribe to the [shared calendar](https://calendar.google.com/calendar/u/0?cid=Y181MzVkYThiNzMxMGQwN2QzOWEwMzU0MWRkYzc5ZmVhYjk4MmU0NzQ1ZTFjNzkzNmIwMTAxOTllOWRmOTUxZWJhQGdyb3VwLmNhbGVuZGFyLmdvb2dsZS5jb20) for calendar events. @@ -430,7 +505,9 @@ New developers are added to the on-call rotation by their manager after they hav > The on-call rotation may be adjusted with approval from the EMs of any product groups affected. Any changes should be made before the start of the sprint so that capacity can be planned accordingly. + ### Developer on-call responsibilities + - **Second-line response** The on-call developer is a second-line responder to questions raised by customers and community members. @@ -459,7 +536,9 @@ Fleet's documentation for contributors can be found in the [Fleet GitHub repo](h The on-call developer is asked to read, understand, test, correct, and improve at least one doc page per week. Our goal is to 1, ensure accuracy and verify that our deployment guides and tutorials are up to date and work as expected. And 2, improve the readability, consistency, and simplicity of our documentation – with empathy towards first-time users. See [Writing documentation](https://fleetdm.com/handbook/marketing#writing-documentation) for writing guidelines, and don't hesitate to reach out to [#g-digital-experience](https://fleetdm.slack.com/archives/C01GQUZ91TN) on Slack for writing support. A backlog of documentation improvement needs is kept [here](https://github.com/fleetdm/fleet/issues?q=is%3Aopen+is%3Aissue+label%3A%22%3Aimprove+documentation%22). + ### Escalations + When the on-call developer is unsure of the answer, they should follow this process for escalation. To achieve quick "first-response" times, you are encouraged to say something like "I don't know the answer and I'm taking it back to the team," or "I think X, but I'm confirming that with the team (or by looking in the code)." @@ -470,7 +549,9 @@ How to escalate: 2. Create a new thread in the [#help-engineering channel](https://fleetdm.slack.com/archives/C019WG4GH0A), tagging `@lukeheath` and provide the information turned up in your research. Please include possibly relevant links (even if you didn't find what you were looking for there). Luke will work with you to craft an appropriate answer or find another team member who can help. + ### Changing of the guard + The on-call developer changes each week on Wednesday. A Slack reminder should notify the on-call of the handoff. Please do the following: @@ -487,7 +568,9 @@ In the Slack reminder thread, the on-call developer includes their retrospective 3. How did you spend the rest of your on-call week? This is a chance to demo or share what you learned. + ## Wireframes + - Showing these principles and ideas, to help remember the pros and cons and conceptualize the above visually. - Figma: [⚗️ Fleet product project](https://www.figma.com/files/project/17318630/%E2%9A%97%EF%B8%8F-Fleet-product?fuid=1234929285759903870) @@ -501,6 +584,10 @@ Use the 🧩 ["Design System (current)"](https://www.figma.com/file/8oXlYXpgCV1S Use `---`, with color `$ui-fleet-black-50` as the default UI for empty columns. +**Images** + +Simple icons (aka any images used in the icon [design system component](https://www.figma.com/design/8oXlYXpgCV1Sn4ek7OworP/%F0%9F%A7%A9-Design-system-(current)?node-id=12-2&t=iO2vXbQ9Sc1kFVEJ-1)) are exported as SVGs. All other images are exported as PNGs, following the [Fleet website image](https://github.com/fleetdm/fleet/tree/main/website/assets/images) naming conventions. + **Form behavior** Pressing the return or enter key with an open form will cause the form to be submitted. @@ -560,9 +647,12 @@ OPTIONS --host Host specified by hostname, uuid, osquery_host_id or node_key that you want to target. ``` + ## Meetings + ### User story discovery + User story discovery meetings are scheduled as needed to align on large or complicated user stories. Before a discovery meeting is scheduled, the user story must be prioritized for product drafting and go through the design and specification process. When the user story is ready to be estimated, a user story discovery meeting may be scheduled to provide more dedicated, synchronous time for the team to discuss the user story than is available during weekly estimation sessions. All participants are expected to review the user story and associated designs and specifications before the discovery meeting. @@ -582,7 +672,9 @@ All participants are expected to review the user story and associated designs an - Software Engineers: Clarifying questions and implementation details - Product Quality Specialist: Testing plan + ### Design consultation + Design consultations are scheduled as needed with the relevant participants, typically product designers and frontend engineers. It is an opportunity to collaborate and discuss design, implementation, and story requirements. The meeting is scheduled as needed by the product designer or frontend engineer when a user story is in the "Prioritized" column on the [drafting board](https://app.zenhub.com/workspaces/-drafting-ships-in-6-weeks-6192dd66ea2562000faea25c/board). **Participants:** @@ -595,27 +687,37 @@ Design consultations are scheduled as needed with the relevant participants, typ - Discuss design input - Discuss implementation details + ### Design reviews -Design reviews are conducted daily between the [Head of Product Design](https://fleetdm.com/handbook/product-design#team) and contributors proposing changes to Fleet's interfaces, such as the graphical user interface (GUI) or REST API. This fast cadence shortens the feedback loop, makes progress visible, and encourages early feedback. This helps Fleet stay intentional about how the product is designed and minimize common issues like UI inconsistencies or accidental breaking changes to the API. -Product designers or other contributors come prepared to this meeting with their proposed changes in a GitHub issue. Usually these are in the form of Figma wireframes, a pull request to the API docs showing changes, or a demo of a prototype. The Head of Product Design and other participants review the changes quickly and give feedback, and then the contributor applies revisions and attends again the next day or as soon as possible for another go-round. The Head of Product Design is responsible for looping in the right engineers, community members, and other subject-matter experts to iterate on and refine upcoming product changes in the best interest of the business. +Design reviews are conducted daily between the [Head of Product Design](https://fleetdm.com/handbook/product-design#team), [CTO](https://fleetdm.com/handbook/engineering#team), and contributors (most often Product Designers) proposing changes to Fleet's interfaces, such as the graphical user interface (GUI) or REST API. This fast cadence shortens the feedback loop, makes progress visible, and encourages early feedback. This helps Fleet stay intentional about how the product is designed and minimize common issues like UI inconsistencies or accidental breaking changes to the API. + +Design reviews operate in "Feedback" mode on Mondays and "Final review" mode all other days of the week: + - **Feedback** mode — contributor knows the design is not ready for final review, but would like to get early feedback. Anyone at Fleet can attend and provide feedback. + - **Final review** mode — contributor is 70% sure the design is 100% done. Only Head of Product Design + CTO + Product Designers give feedback. Anyone at Fleet can attend as a shadow. This helps the team iterate and move designs to ready for dev faster. + +> In addition to design reviews, Fleeties or community member can provide feedback asynchronously at any time by finding the GitHub issue (user story) associated with the designs and @ mentioning the assigned Product Designer in the comment section. + +Product Designers or other contributors come prepared to this meeting with their proposed changes in a GitHub issue. Usually these are in the form of Figma wireframes, a pull request to the API docs showing changes, or a demo of a prototype. + +After the meeting, the contributor applies revisions and attends again the next day or as soon as possible for another go-round. The Head of Product Design is responsible for looping in the right engineers, community members, and other subject-matter experts to iterate on and refine upcoming product changes in the best interest of the business. Here are some tips for making this meeting effective: -- Bring 1 key engineer who has been helping out with the user story, when possible and helpful. - Say the user story out loud to remind participants of what it is. -- At the beginning of describing your change, indicate whether you are 70% sure you are 100% done, or are looking for early feedback. - Avoid explaining or showing multiple ways it could work. Show the one way you think it should work and let your work speak for itself. - For follow-ups, repeat the user story, but show only what has changed or been added since the last review. +- Bring 1 key engineer who has been helping out with the user story, when possible and helpful. - Read Fleet's [best practices for meetings](https://fleetdm.com/handbook/company/communications#meetings). -> To allow for asynchronous participation, instead of attending, contributors can alternatively choose to add an agenda item to the "Product design review" meeting with a GitHub link. Then, the Head of Product Design will review during the meeting and provide feedback. Every "Product design review" is recorded and automatically transcribed to a Google Doc so that it is searchable by every Fleet team member. - ### Weekly bug review + QA has weekly check-in with product to go over the inbox items. QA is responsible for proposing “not a bug”, closing due to lack of response (with a nice message), or raising other relevant questions. All requires product agreement QA may also propose that a reported bug is not actually a bug. A bug is defined as “behavior that is not according to spec or implied by spec.” If agreed that it is not a bug, then it's assigned to the relevant product manager to determine its priority. + ### Group weeklies + A chance for deeper, synchronous discussion on topics relevant across product groups like “Frontend weekly”, “Backend weekly”, etc. **Participants:** Anyone who wishes to participate. @@ -625,7 +727,9 @@ A chance for deeper, synchronous discussion on topics relevant across product gr - Review difficult frontend bugs - Write engineering-initiated stories + ### Eng Together + This meeting is to disseminate engineering-wide announcements, promote cohesion across groups within the engineering team, and connect with engineers (and the "engineering-curious") in other departments. Held monthly for one hour. **Participants:** Everyone at the company is welcome to attend. All engineers are asked to attend. The subject matter is focused on engineering. @@ -639,14 +743,18 @@ This meeting is to disseminate engineering-wide announcements, promote cohesion - Social - Structured and/or unstructured social activities + ## Development best practices + - Remember the user. What would you do if you saw that error message? [🔴](https://fleetdm.com/handbook/company#empathy) - Communicate any blockers ASAP in your group Slack channel or standup. [🟠](https://fleetdm.com/handbook/company#ownership) - Think fast and iterate. [🟢](https://fleetdm.com/handbook/company#results) - If it probably works, assume it's still broken. Assume it's your fault. [🔵](https://fleetdm.com/handbook/company#objectivity) - Speak up and have short toes. Write things down to make them complete. [🟣](https://fleetdm.com/handbook/company#openness) + ## Product design conventions + Behind every [wireframe at Fleet](https://fleetdm.com/handbook/company/why-this-way#why-do-we-use-a-wireframe-first-approach), there are 3 foundational design principles: - **Use-case first.** Taking advantage of top-level features vs. per-platform options allows us to take advantage of similarities and avoid having two different ways to configure the same thing. @@ -657,13 +765,17 @@ Start off cross-platform for every option, setting, and feature. If we **prove** - **Control the noise.** Bring the needs surface level, tuck away things you don't need by default (when possible, given time). For example, hide Windows controls if there are no Windows devices (based on number of Windows hosts). + ## Scrum at Fleet + Fleet product groups employ scrum, an agile methodology, as a core practice in software development. This process is designed around sprints, which last three weeks to align with our release cadence. New tickets are estimated, specified, and prioritized on the roadmap: - [Roadmap](https://app.zenhub.com/workspaces/-roadmap-ships-in-6-weeks-6192dd66ea2562000faea25c/board) + ### Scrum items + Our scrum boards are exclusively composed of four types of scrum items: 1. **User stories**: These are simple and concise descriptions of features or requirements from the user's perspective, marked with the `story` label. They keep our focus on delivering value to our customers. Occasionally, due to ZenHub's ticket sub-task structure, the term "epic" may be seen. However, we treat these as regular user stories. @@ -676,17 +788,23 @@ Our scrum boards are exclusively composed of four types of scrum items: > Our sprint boards do not accommodate any other type of ticket. By strictly adhering to these four types of scrum items, we maintain an organized and focused workflow that consistently adds value for our users. + ## Sprints + Sprints align with Fleet's [3-week release cycle](https://fleetdm.com/handbook/company/why-this-way#why-a-three-week-cadence). On the first day of each release, all estimated issues are moved into the relevant section of the new "Release" board, which has a kanban view per group. Sprints are managed in [Zenhub](https://fleetdm.com/handbook/company/why-this-way#why-make-work-visible). To plan capacity for a sprint, [create a "Sprint" issue](https://github.com/fleetdm/confidential/issues/new/choose), replace the fake constants with real numbers, and attach the appropriate labels for your product group. + ### Sprint numbering + Sprints are numbered according to the release version. For example, for the sprint ending on June 30th, 2023, on which date we expect to release Fleet v4.34, the sprint is called the 4.34 sprint. + ### Sprint ceremonies + Each sprint is marked by five essential ceremonies: 1. **Sprint kickoff**: On the first day of the sprint, the team, along with stakeholders, select items from the backlog to work on. The team then commits to completing these items within the sprint. @@ -695,7 +813,9 @@ Each sprint is marked by five essential ceremonies: 4. **Sprint demo**: On the last day of each sprint, all engineering teams and stakeholders come together to review the next release. Engineers are allotted 3-10 minutes to showcase features, improvements, and bug fixes they have contributed to the upcoming release. We focus on changes that can be demoed live and avoid overly technical details so the presentation is accessible to everyone. Features should show what is capable and bugs should identify how this might have impacted existing customers and how this resolution fixed that. (These meetings are recorded and posted publicly to YouTube or other platforms, so participants should avoid mentioning customer names. For example, instead of "Fastly", you can say "a publicly-traded hosting company", or use the [customer's codename](https://fleetdm.com/handbook/customers#customer-codenames).) 5. **Sprint retrospective**: Also held on the last day of the sprint, this meeting encourages discussions among the team and stakeholders around three key areas: what went well, what could have been better, and what the team learned during the sprint. + ## Outside contributions + [Anyone can contribute](https://fleetdm.com/handbook/company#openness) at Fleet, from inside or outside the company. Since contributors from the wider community don't receive a paycheck from Fleet, they work on whatever they want. Many open source contributions that start as a small, seemingly innocuous pull request come with lots of additional [unplanned work](https://fleetdm.com/handbook/company/development-groups#planned-and-unplanned-changes) down the road: unforseen side effects, documentation, testing, potential breaking changes, database migrations, [and more](https://fleetdm.com/handbook/company/development-groups#defining-done). diff --git a/handbook/company/testimonials.yml b/handbook/company/testimonials.yml index 96fefc9427d9..909daf02e3b8 100644 --- a/handbook/company/testimonials.yml +++ b/handbook/company/testimonials.yml @@ -141,15 +141,6 @@ quoteAuthorProfileImageFilename: testimonial-author-dhruv-majumdar-48x48@2x.png quoteAuthorJobTitle: Director Of Cyber Risk & Advisory productCategories: [Vulnerability management, Endpoint operations] -- - quote: When we look at vendors, we look for ones that are very receptive to feedback, where you’re just part of the family, I guess. Fleet’s really good at that. - quoteImageFilename: logo-deputy-118x28@2x.png - quoteAuthorName: Harrison Ravazzolo - quoteAuthorProfileImageFilename: testimonial-author-harrison-ravazzolo-48x48@2x.png - quoteLinkUrl: https://www.linkedin.com/in/harrison-ravazzolo/ - quoteAuthorJobTitle: Lead platform and identity engineer - youtubeVideoUrl: https://www.youtube.com/watch?v=5W0q5yQE3R0 - productCategories: [Endpoint operations] - quote: Fleet has such a huge amount of use cases. My goal was to get telemetry on endpoints, but then our IR team, our TBM team, and multiple other folks in security started heavily utilizing the system in ways I didn’t expect. It spread so naturally, even our corporate and infrastructure teams want to run it. quoteAuthorName: Charles Zaffery diff --git a/handbook/company/why-this-way.md b/handbook/company/why-this-way.md index da8748cd092c..189eb4aa3983 100644 --- a/handbook/company/why-this-way.md +++ b/handbook/company/why-this-way.md @@ -12,6 +12,7 @@ Here are some of Fleet's decisions about the best way to work, and the reasoning ## Why open source? + Fleet's source code, website, documentation, company handbook, and internal tools are [public](https://github.com/fleetdm/fleet) and accessible to everyone, including engineers, executives, and end users. (Even [paid features](https://fleetdm.com/pricing) are source-available.) Meanwhile, the [company behind Fleet](https://twitter.com/fleetctl) is built on the [open-core](https://www.heavybit.com/library/video/commercial-open-source-business-strategies) business model. Openness is one of our core [values](https://fleetdm.com/handbook/company#values), and everything we do is [public by default](https://handbook.gitlab.com/handbook/values/#public-by-default). Even the [company handbook](https://fleetdm.com/handbook) is open to the world. @@ -31,6 +32,7 @@ Here are some of the reasons we build in the open: ## Why handbook-first strategy? + The Fleet handbook provides team members with up-to-date information about how to do things in the company. At Fleet, we make changes to the handbook first. That means, before any change to how we run the business is "live" or "official", it is first changed in the relevant [handbook pages](https://fleetdm.com/handbook) and [issue templates](https://github.com/fleetdm/confidential/tree/main/.github/ISSUE_TEMPLATE). @@ -43,6 +45,7 @@ To contribute to the handbook, click "Edit this page" and make your [edits in Ma ## Why read documentation? + There are three reasons for visiting [the docs](https://fleetdm.com/docs): - **Tire-kicking**: "I think this is cool, now is it something that I could ACTUALLY use? Does it ACTUALLY work? What all's in it? What links can I share with my colleagues to help them see what I'm seeing?" - **Committed learning**: "I've decided to learn this. I need a curriculum to get me there; with content that makes it as easy as possible, surface-level as possible. I want to learn how Fleet works and how to do all the things." @@ -61,6 +64,7 @@ Everyone [can contribute](https://fleetdm.com/handbook/company#openness) to Flee <!-- 🔌🚪🪟 --> ## Why the emphasis on training? + Investing in people and providing generous, prioritized training, especially up front, helps contributors understand what is going on at Fleet. By making training a prerequisite at Fleet, we can: - help team members feel confident in the better decisions they make at work. - create a culture of helping others, which results in team members feeling more comfortable even if they aren’t familiar with the osquery, security, startup, or IT space. @@ -74,6 +78,7 @@ Here are a few examples of how Fleet prioritizes training: ## Why direct responsibility? + Like Apple and GitLab, Fleet uses the concept of [directly responsible individuals (DRIs)](https://about.gitlab.com/handbook/people-group/directly-responsible-individuals/) to know who is responsible for what. DRIs help us collaborate efficiently by knowing exactly who is responsible and can make decisions about the work they're doing. This saves time by eliminating a requirement for consensus decisions or political presenteeism, enables faster decision-making, and ensures a single individual is aware of what to do next. @@ -85,9 +90,8 @@ DRIs help us collaborate efficiently by knowing exactly who is responsible and c - **Multiple maintainers**: In some cases, multiple subject-matter experts called "maintainers" can merge changes to certain file paths, even though there is already a dedicated DRI configured as the "CODEOWNER". For examples of this, see the auto-approval flows configured as `sails.config.custom.githubRepoMaintainersByPath` and related configuration in [`website/config/custom.js`](https://github.com/fleetdm/fleet/blob/main/website/config/custom.js). - - ## Why do we use a wireframe-first approach? + Wireframing (usually as part of what Fleet calls ["drafting"](https://fleetdm.com/handbook/company/development-groups#making-changes)) provides a clear overview of page layout, information architecture, user flow, and functionality. The [wireframe-first approach](https://speakerdeck.com/mikermcneil/i-love-apis?slide=28) extends beyond what users see on their screens. Wireframe-first is also excellent for drafting APIs, config settings, CLI options, and even business processes. It's design thinking, applied to software development. @@ -109,8 +113,8 @@ Here's why Fleet uses a wireframe-first approach: - While the "wireframe first" practice is [still sometimes misunderstood](https://about.gitlab.com/handbook/product-development-flow/#but-wait-isnt-this-waterfall), today many modern high-performing teams now use a [wireframe-first methodology](https://speakerdeck.com/mikermcneil/i-love-apis), including [startups](https://www.forbes.com/sites/danwoods/2015/10/19/dont-get-ubered-apis-hold-key-to-digital-transformation/?sh=50112fea182c#:~:text=One%20recommendation%20that,deep%20experience) and [publicly-traded companies](https://about.gitlab.com/handbook/product-development-flow/#validation-phase-3-design). - ## Why do we use one repo? + At Fleet, we keep everything in one repo ([`fleetdm/fleet`](https://github.com/fleetdm/fleet)). Here's why: - One repo is easier to manage. It has less surface area for keeping content up to date and reduces the risk of things getting lost and forgotten. @@ -138,8 +142,8 @@ Besides the exceptions above, Fleet does not use any other repositories. Other > _**Tip:** In addition to the built-in search available for the public handbook on fleetdm.com, you can also [search any public AND non-public content, including issue templates, at the same time](https://github.com/search?q=org%3Afleetdm+path%3A.github%2FISSUE_TEMPLATE+path%3Ahandbook%2F+path%3Adocs%2F+foo&type=code)._ - ## Why not continuously generate REST API reference docs from javadoc-style code comments? + Here are a few of the drawbacks that we have experienced when generating docs via tools like Swagger or OpenAPI, and some of the advantages of doing it by hand with Markdown. - Markdown gives us more control over how the docs are compiled, what annotations we can include, and how we [present the information to the end-user](https://x.com/wesleytodd/status/1769810305448616185?s=46&t=4_cwTxqV5IXDLBvCm8KI6Q). @@ -153,12 +157,14 @@ Here are a few of the drawbacks that we have experienced when generating docs vi ## Why group Slack channels? + Groups (`g-*`) are organized around goals. Connecting people with the same goals helps them produce better results by fostering freer communication. Some groups align with teams in the org chart. Other groups, such as [product groups](https://fleetdm.com/handbook/company/development-groups), are cross-functional, with some group members who do not report to the same manager. Every group at Fleet maintains their own Slack channel, which all group members join and keep unmuted. Everyone else at Fleet is encouraged to mute these channels, using them only as needed. Each channel has a directly responsible individual responsible for keeping up with all new messages, even if they aren't explicitly mentioned (`@`). ## Why make work visible? + Work is tracked in [GitHub issues](https://github.com/issues?q=archived%3Afalse+org%3Afleetdm+is%3Aissue+is%3Aopen+). Every department organizes their work into [team-based kanban boards](https://app.zenhub.com/workspaces/-g-business-operations-63f3dc3cc931f6247fcf55a9/board?sprints=none). This provides a consistent framework for how every team works, plans, and requests things from each other. @@ -167,7 +173,9 @@ Every department organizes their work into [team-based kanban boards](https://ap 2. **Planning:** Give the team's manager and other team members a way to plan the [next three-week iteration](https://fleetdm.com/handbook/company/why-this-way#why-a-three-week-cadence) of what the team is working on. Provide a world (the kanban board) where the team has clarity, and the appropriate [DRI](https://fleetdm.com/handbook/company#why-direct-responsibility) can confidently [prioritize and plan changes](https://fleetdm.com/handbook/company/development-groups#planned-and-unplanned-changes) with enough context to make the right decisions. 3. **Shared to-do list:** What should I work on next? Who needs help? What important work is blocked? Is that bug fix merged yet? When will it be released? When will that new feature ship? What did I do yesterday? + ## Why agile? + Releasing software [🟢iteratively](https://fleetdm.com/handbook/company#results) gets changes and improvements into the hands of users faster and generally results in [🔵software that works](https://fleetdm.com/handbook/company#objectivity). This makes contributors fitter, happier, and more productive. We apply the [twelve principles of agile](https://agilemanifesto.org) to Fleet's [development process](https://fleetdm.com/handbook/company/product-groups#making-changes): @@ -187,6 +195,7 @@ We apply the [twelve principles of agile](https://agilemanifesto.org) to Fleet's ### Why scrum? + Scrum is an agile framework for software development that helps teams deliver high quality software faster. It emphasizes teamwork, collaboration, and continuous improvement to achieve business objectives. Here are some of the key reasons why [we use scrum at Fleet](https://fleetdm.com/handbook/engineering#scrum)): - Improved collaboration and communication: Scrum emphasizes teamwork and collaboration, which leads to better communication between team members and stakeholders. This helps ensure that everyone is aligned and working towards the same goals. - Flexibility and adaptability: Scrum allows teams to respond quickly to changing requirements and market conditions. By working in short sprints, teams can continuously adapt to new information and feedback, and adjust their approach as needed. @@ -194,7 +203,9 @@ Scrum is an agile framework for software development that helps teams deliver hi - Faster delivery of working software: Scrum helps teams deliver working software faster by breaking down the development process into manageable chunks that can be completed within a sprint. Stakeholders can see progress and provide feedback more quickly, which helps ensure the final product meets their needs. - Higher quality software: Scrum includes regular testing and quality assurance activities, which help ensure that the software being developed is of high quality and meets the required standards. + ### Why lean software development? + [Lean software development](https://en.wikipedia.org/wiki/Lean_software_development) is an iterative and incremental approach to software development that aims to eliminate waste and deliver value to customers quickly. It is based on the principles of [lean manufacturing](https://en.wikipedia.org/wiki/Lean_manufacturing) and emphasizes continuous improvement, collaboration, and customer focus. Lean development can be summarized by its seven principles: @@ -206,7 +217,9 @@ Lean development can be summarized by its seven principles: 6. Build integrity in: Build quality into the software by continuously testing, reviewing, and improving the code throughout the development process. 7. Optimize the whole: Optimize the entire process and focus on the system's overall performance rather than just individual parts to ensure the most efficient and effective use of resources. + ## Why a three-week cadence? + The Fleet product is released every three weeks. By syncing the whole company to this schedule, we can: - Keep all team members (especially those who aren't directly involved with the core product) aware of the current version of Fleet and when the next release is shipping. @@ -228,7 +241,9 @@ Why bother with all that? And why do it in this particular order? - **Better customer experience.** Understanding the impact of every production issue means we can reach out to affected users ASAP and acknowledge their challenge, showing them that Fleet takes quality and stability seriously. This kind of customer support is rare and memorable. - **It helps us prevent future outages.** By finding outages sooner, we incentivize ourselves to fix the root cause sooner. And by fixing bugs sooner, we prevent them from stacking and bleeding into one another, and we prevent ourselves from implementing future fixes and improvements on top of shaky foundations. This makes contributions less risky and reduces the number of outages. + ## Why make it obvious when stuff breaks? + At Fleet, we detect and fix bugs as quickly as possible. Breaking loudly means we can fix the break sooner and improve how fast and certain we are about making future changes. Especially in an all-remote environment, this provides contributors with discipline around quality and stability of the main branch. This is ["good annoying"](https://agilehope.blogspot.com/2014/12/diy-build-light-indicator.html). @@ -239,7 +254,9 @@ If that happens by mistake, first priority is merging a fix, then notifying the > Here is [an example of a deliberate decision to make broken images in Fleet fail more loudly](https://github.com/fleetdm/fleet/issues/12305#issuecomment-1671924257) so that they can't be overlooked, even though this might slow down short-term development. + ## Why keep issue templates simple? + At Fleet, we optimize for the person submitting the issue, not the person receiving it. We avoid making the submitter read anything. We prompt for as little information as possible. Why? @@ -251,6 +268,7 @@ For example, here is the [philosophy behind Fleet's bug report template](https:/ ## Why spend less? + - **Default to efficiency. Reward richly.** At Fleet, we celebrate success and reward hard work. But we do everyday things cheap. And that is very important, because it shapes the kind of people we hire, and the kind of expectations we set for the team about what "comfortable" feels like. - **Offsites are not rewards.** Day to day, Fleet does not look rich. Rich !== welcoming. The company is open, not closed. Work here means flexible collaboration, accessible people, and clear expectations. And a rich, exciting future worth working for. Not a rich, complacent baseline worth coasting for. - **Minimally viable comfort.** We stay at La Quintas by the train tracks every single time unless customers are coming into the room and we need more space. Even then, we accommodate in the spirit of _hospitality_, not to show off how well Fleet is doing. They'll know how well we're doing by how great the product is, how great the support is, and [how that makes them feel](https://fleetdm.com/handbook/company#purpose). They'll remember openness, flexibility, accessibility, and clarity in all of their interactions with the brand. Not the view from our hotel rooms. @@ -258,6 +276,7 @@ For example, here is the [philosophy behind Fleet's bug report template](https:/ ## Why don't we sell like everyone else? + Many companies encourage salespeople to ["spray and pray"](https://www.linkedin.com/posts/amstech_the-rampant-abuse-of-linkedin-connections-activity-7178412289413246978-Ci0I?utm_source=share&utm_medium=member_ios) email blasts, and to do whatever it takes to close deals. This can sometimes be temporarily effective. But Fleet takes a [🟠longer-term](https://fleetdm.com/handbook/company#ownership) approach: - **No spam.** Fleet is deliberate and thoughtful in the way we do outreach, whether that's for community-building, education, or [🧊 conversation-starting](https://github.com/fleetdm/confidential/blob/main/cold-outbound-strategy.md). - **Be a helper.** We focus on [🔴being helpers](https://fleetdm.com/handbook/company#empathy). Always be depositing value. This is how we create a virtuous cycle. (That doesn't mean sharing a random article; it means genuinely hearing, doing whatever it takes to fully understand, and offering only advice or links that we would actually want.) We are genuinely curious and desperate to help, because creating real value for people is the way we win. @@ -268,39 +287,8 @@ Many companies encourage salespeople to ["spray and pray"](https://www.linkedin. - **Step up.** We look at the [🟠big picture](https://fleetdm.com/handbook/company#ownership). The goal is for the organization using Fleet to be successful, as well as the individuals who decide to use or buy the product. There are multiple versions of Fleet, and so many ways to "do" open-source security and IT. It is in the company's best interest to help engineers pick the right one; even if that's Fleet Free, or another solution altogether. We think about our customer's needs like they are our own. -## Why don't we track leads differently? -There are about as many "MQL" definitions as there are sales orgs in the world. Exaggerating here, but only somewhat. - -Fleet documents all KPI's with clear definitions that are simple to evaluate, easy to track, and highly iterable. - -- **Lead** == A "Lead" row in Salesforce. - -- **MQL** == a human from an in-ICP organization that meets these parameters when the lead is created: - - Their organization is _not_ already a Fleet customer - - Their organization is _not_ already considering buying Fleet as part of a qualified, mutually beneficial opportunity - - Our friend, the human, has chosen to open or widen their line of communication with the company. This could come from an event or or sending a contact form message requesting a call. - - Attendees at events are considered MQLs if they have done any of the following: - - Had a 5+ minute conversation or a badge scan at a physical event. - - RSVPed yes to extracurricular in-person side event (e.g. dinner or activity) OR attended the in-person side event and a Fleetie was able to track their attendance. - - RSVPed yes to a virtual event OR indicated intent by sending a follow-up email. - - Multiple people from the same org each count as _separate_ MQLs. <!-- However, Once the opportunity is created for an account, all MQLs associated with that account become "converted". Meaning they no longer count towards "open MQLs", though they still count as MQLs created. --> - - When an account converts to an opportunity, all subsequent new leads created for that account are ***NOT*** MQLs (i.e. do not count towards "MQLs created".). If an opportunity is marked "closed lost", then it is _no longer_ open, so subsequent new leads associated with the prospective customer are considered MQLs again. - -- **Open MQL** == An MQL whose lead status is neither _"disqualified" nor "converted"_. - -- **SQL** == An MQL whose lead status in Salesforce has exceeded a _certain threshold_, for **any** reason, from **any** source (threshold TBD: we aren't reporting these yet in KPIs) - -- **Lead source** == where a lead came from. To determine attribution, we will consider the lead source. (e.g. sales-sourced vs. marketing-sourced vs. misc-sourced leads can be determined by looking at the lead source. No need to establish any other *QL or change these.) - - - Instead of saying _"outbound lead"_ or _"inbound lead"_, you can say _"a lead from a badge scan at an event"_ or _"a lead from a customer referral"_ or _"a lead from the website"_. - -- **Opportunity** == A _"Opportunity"_ row in Salesforce. - -- **Open opportunity** == An opportunity whose stats is not _"closed lost"_ nor _"closed won"_. - - - ## Why does Fleet support query packs? + As originally envisioned by Zach Wasserman and the team when creating osquery, packs are a way to import and export queries into (and out of!) any platform that speaks osquery, whether that's Fleet, [Security Onion](https://securityonionsolutions.com/), an EDR, or even Rapid7. Queries [should be portable](https://github.com/fleetdm/fleet/blob/f711e60de47c69ab8be5bc13cf73fedf88adc338/README.md#lighter-than-air) to minimize lock-in to particular tools. The "Packs" section of the UI that began in `kolide/fleet` c. 2017 was an early attempt to segment and target formations of hosts that share certain characteristics. This came with some difficulties with debugging and collaboration, since it could be hard to tell which queries were running on which hosts. It also made it harder to understand what performance impact running all those queries might cause. @@ -311,7 +299,9 @@ The first step was to add a simpler way to schedule queries, and tuck away the l Packs will always be supported in Fleet. + ## Why does Fleet use sentence case? + Fleet uses sentence case capitalization for all headings, subheadings, button text in the Fleet product, fleetdm.com, the documentation, the handbook, marketing material, direct emails, in Slack, and in every other conceivable situation. In sentence case, we write and capitalize words as if they were in sentences: @@ -322,7 +312,9 @@ As we use sentence case, only the first word is capitalized. But, if a word woul The reason for sentence case at Fleet is that everyone capitalizes differently in English, and capitalization conventions have not been taught very consistently in schools. Sentence case simplifies capitalization rules so that contributors can deliver more natural, even-looking content with a voice that feels similar no matter where you're reading it. + ## Why not use superlatives? + A superlative is an adjective or adverb that expresses the degree of a quality, such as "best," "worst," or "most beautiful." A superlative is a judgment or evaluation, [which only the customer can decide](https://twitter.com/mikermcneil/status/1686837625187930112). @@ -341,6 +333,7 @@ Avoid using too many unnecessary words or superlatives, so your writing is short ## Why does Fleet use "MDM on/off" instead of "MDM enrolled/unenrolled"? + MDM should be a capability, not a product category. In Fleet, the word "enrolled" means "the host shows up in the dashboard and API". @@ -351,7 +344,9 @@ Since Fleet is more than MDM, you can collect logs and health data on any comput That means you can collect logs from Linux servers or Windows factory workstations without enabling remote script execution on those computers, even if you're using script execution on your Macs. + ## Why not mention the CEO in Slack threads? + Everyone else who works at Fleet is expected to read (and reply or acknowledge with an emoji reaction) every time they're mentioned in Slack, even deep inside long threads. Now that the company has grown, the CEO gets mentioned in threads [too often](https://docs.google.com/document/d/1vK-Dy2BVrw7doYUzabOPyCiN4RfolWFgOKMm23l91s0/edit) to keep up with thread replies, even for threads he participates in. @@ -377,6 +372,7 @@ Thank you so much!" 🙇 #### Stubs + The following stubs are included only so that old links continue to work (for backwards compatibility.) ##### Reporting structure diff --git a/handbook/customer-success/customer-success.rituals.yml b/handbook/customer-success/customer-success.rituals.yml index 017063ae964f..2c493ccf7472 100644 --- a/handbook/customer-success/customer-success.rituals.yml +++ b/handbook/customer-success/customer-success.rituals.yml @@ -2,7 +2,7 @@ task: "Prioritize for next sprint" # Title that will actually show in rituals table startedOn: "2023-09-04" # Needs to align with frequency e.g. if frequency is every thrid Thursday startedOn === any third thursday frequency: "Triweekly" # must be supported by - description: "Drag next sprint's priorities to the top of the 'Not yet' column." # example of a longer thing: description: "[Prioritizing next sprint](https://fleetdm.com/handbook/company/communication)" + description: "Using your departmental kanban board, prioritize and finalize next sprint's goals for your team by draging the appropriate issues to the top of the 'Not yet' column." # example of a longer thing: description: "[Prioritizing next sprint](https://fleetdm.com/handbook/company/communication)" moreInfoUrl: "https://fleetdm.com/handbook/company/why-this-way#why-make-work-visible" #URL used to highlight "description:" test in table dri: "zayhanlon" # DRI for ritual (assignee if autoIssue) (TODO display GitHub proflie pic instead of name or title) autoIssue: # Enables automation of GitHub issues diff --git a/handbook/demand/README.md b/handbook/demand/README.md index d292cfba1f9d..40843ada9b7c 100644 --- a/handbook/demand/README.md +++ b/handbook/demand/README.md @@ -58,6 +58,49 @@ To propose an ad, or a change to an ad: 7. Create a calendar reminder to check ad performance two weeks from the date changes were made. +### Measure intent signals + +Intent signals help measure an individual's current level of engagement with the Fleet brand. Use the following steps to decide if: + +(a) A contact and/or account needs to be created/updated. + +(b) An account should be prioritized for [manual research](https://fleetdm.com/handbook/demand#research-an-account). + +(c) An account/contact would benefit from a sales conversation. + +(in order of how worthwhile it is to spend time looking at the intent signal) + +1. Accounts currently assigned to reps (i.e. pipeline + stage0 + pre-pipeline IQMs). +2. Accounts with trending psychological progression (as measured by fleetdm.com website signups (i.e. new contacts ± contacts that have increased their psystage to a certain point). +3. Accounts that fleeties have suggested to go after in ABM maneuver sheet. +4. [MacAdmins Slack traffic](https://macadmins.slack.com/archives/C0214NELAE7/p1722561481530559) in the #fleet AND #osquery channels (channel joins, posts, reactions, thread replies, thread reactions). +5. [LinkedIn page follows](https://www.linkedin.com/company/71111416/admin/analytics/followers/). +6. [GitHub stars to fleetdm/fleet](https://github.com/fleetdm/fleet/stargazers) from non-fleeties. + + +### Research an account + +Follow these steps to research an account and move it toward sales-readiness **after** discovering [relevant intent signals](https://fleetdm.com/handbook/demand#measure-intent-signals). + +1. Create the account in SalesForce if it doesn't already exist. +2. Update any incorrect, mistagged, or incomplete contacts already on the account and merge any duplicates that are found. Verify the following data is current for each existing contact: + - "Title" + - "Role" + - "Primary buying situation" + - "LinkedIn" + - "Psychological stage" + - "intent signals" +3. If you any reason that the account organization wouldn't benefit from a relationship with Fleet, change the "Type" to "Distraction" stop here. If you haven't disqualified the account at this point, update the "Marketing stage" to "Research-ready". +After an account is marked "[Research-ready](https://fleetdm.lightning.force.com/lightning/r/Report/00OUG000001LerV2AS/view?queryScope=userFolders)". + +1. Research missing contacts and add them to salesforce if they are real by using the [ABM maneuvers spreadsheet](https://docs.google.com/spreadsheets/d/1ijtBKTjPg_AodnKEZY0ivia70ttDR3VMURT8rpYwYiw/edit?gid=0#gid=0) to generate a Sales Nav search. Make sure they have "role", "buying situation", "linkedinUrl", "psychological stage", "intent signals" completely filled out and correct. +2. For "Contact source" for any new contacts, use "Manual research". +3. Rank the account in terms of closability and fit based on what we see from it and its contacts. Mark any account that is not a fit as "Distraction" instead of "Prospect". +4. Research and discover mutual connections between fleeties and Mac admin community members within those contacts to help determine fit. +5. Check Snitcher activity for the account and the psystages of its contacts in Salesforce. +6. Update the "marketing stage" AND "type" accordingly (qualify or disqualify based on whether the contacts look good). Start running ABM ads on the account if moving it to "Ads running" for a total of 60 days otherwise, stop them if moving it out of "Ads running". + + ### Promote a post on LinkedIn 1. Create a classic campaign under ["Experiments"](https://www.linkedin.com/campaignmanager/accounts/509911695/campaigns?campaignGroupIds=%5B678398233%5D) following the YYYY-MM-DD.buying-situation - ad description with a goal of website visits or engagement to run for two weeks. diff --git a/handbook/demand/demand.rituals.yml b/handbook/demand/demand.rituals.yml index f64b087da04e..dc1ae27b478c 100644 --- a/handbook/demand/demand.rituals.yml +++ b/handbook/demand/demand.rituals.yml @@ -9,7 +9,7 @@ task: "Prioritize for next sprint" # Title that will actually show in rituals table startedOn: "2023-09-04" # Needs to align with frequency e.g. if frequency is every thrid Thursday startedOn === any third thursday frequency: "Triweekly" - description: "Drag next sprint's priorities to the top of the 'Not yet' column." # example of a longer thing: description: "[Prioritizing next sprint](https://fleetdm.com/handbook/company/communication)" + description: "Using your departmental kanban board, prioritize and finalize next sprint's goals for your team by draging the appropriate issues to the top of the 'Not yet' column." # example of a longer thing: description: "[Prioritizing next sprint](https://fleetdm.com/handbook/company/communication)" moreInfoUrl: "https://fleetdm.com/handbook/company/why-this-way#why-make-work-visible" #URL used to highlight "description:" test in table dri: "mikermcneil" # DRI for ritual (assignee if autoIssue) (TODO display GitHub proflie pic instead of name or title) autoIssue: # « Enable automation of GitHub issues @@ -22,6 +22,13 @@ description: "https://fleetdm.com/handbook/demand#settle-event-strategy" moreInfoUrl: "https://fleetdm.com/handbook/demand#settle-event-strategy" dri: "Drew-P-drawers" +- + task: "🫧 Pipeline sync" + startedOn: "2024-08-29" + frequency: "Weekly" + description: "Allign with CRO and AEs on pipeline processes and incoming leads" + moreInfoUrl: "" + dri: "Drew-P-drawers" - task: "Optimize ads" startedOn: "2024-02-26" @@ -50,6 +57,20 @@ description: "Every release cycle, upload the ☁️🌈 Sprint demos video to YouTube" moreInfoUrl: "https://fleetdm.com/handbook/demand#upload-to-youtube" dri: "Drew-P-drawers" +- + task: "Measure intent signals" + startedOn: "2024-08-09" + frequency: "Daily" + description: "Measure intent signals and update SalesForce" + moreInfoUrl: "https://fleetdm.com/handbook/demand#measure-intent-signals" + dri: "Drew-P-drawers" +- + task: "Research accounts" + startedOn: "2024-08-09" + frequency: "Daily" + description: "Research SalesForce accounts and begin ABM ads" + moreInfoUrl: "https://fleetdm.com/handbook/demand#warm-up-actions" + dri: "Drew-P-drawers" # - # task: "Propose a fleet event" # startedOn: "2023-10-02" diff --git a/handbook/digital-experience/README.md b/handbook/digital-experience/README.md index 337e2e6172d8..9232c11b6f35 100644 --- a/handbook/digital-experience/README.md +++ b/handbook/digital-experience/README.md @@ -365,6 +365,14 @@ Time management for the CEO is essential. The Apprentice processes the CEO's ca 6. Edit the calendar event description, changing “Notes” to “Agenda” when you're finished preparing the document to signify that this meeting has been prepped. +### Confirm CEO shadow dates + +After the team member notifies the Head of Digital Experience (via Slack), the Head of DigExp will bring the dates to the next roundup as a "DISCUSS: CEO shadow dates". Use the following steps to confirm CEO shadow dates: +1. Create an "All day", "Free" event on the CEO's calendar that matches the CEO shadow dates and name the calendar event "CEO shadow - [NAME] (Job title)". +3. Confirm the "shadowability" for external and nonrecurring internal meetings with the CEO during the next daily 🐈⬛🌪️ Roundup. +4. Go through the calendar and make sure all private meetings (e.g. 1:1's, E-Group, and quarterly board meetings) have "[no shadows]" in the event title. + + ### Process the CEO's inbox - The Apprentice is [responsible](https://fleetdm.com/handbook/company/why-this-way#why-direct-responsibility) for [processing all email traffic](https://docs.google.com/document/d/1gH3IRRgptrqSYzBFy-77g98JROTL8wqrazJIMkp-Gb4/edit#heading=h.i7mkhr6m123r) prior to CEO review to reduce the scope of Mike's inbox to only include necessary and actionable communication. diff --git a/handbook/digital-experience/digital-experience.rituals.yml b/handbook/digital-experience/digital-experience.rituals.yml index 64224badb179..9e1999d304b2 100644 --- a/handbook/digital-experience/digital-experience.rituals.yml +++ b/handbook/digital-experience/digital-experience.rituals.yml @@ -1,5 +1,14 @@ # https://github.com/fleetdm/fleet/pull/13084 - +- + task: "Complete Digital Experience KPIs" + startedOn: "2024-08-30" + frequency: "Weekly" + description: "Complete Digital Experience KPIs for this week" + moreInfoUrl: "https://docs.google.com/spreadsheets/d/1Hso0LxqwrRVINCyW_n436bNHmoqhoLhC8bcbvLPOs9A/edit?gid=0#gid=0&range=DB1" + dri: "SFriendLee" + autoIssue: + labels: [ "#g-digital-experience" ] + repo: "fleet" - task: "Prep 1:1s for OKR planning" startedOn: "2024-09-09" @@ -65,7 +74,7 @@ task: "Prioritize for next sprint" # Title that will actually show in rituals table startedOn: "2023-08-09" # Needs to align with frequency e.g. if frequency is every thrid Thursday startedOn === any third thursday frequency: "Triweekly" # must be supported by https://github.com/fleetdm/fleet/blob/dbbb501358e226fa3fdf48865175efe3334c826c/website/scripts/build-static-content.js - description: "Drag next sprint's priorities to the top of the 'Not yet' column." # example of a longer thing: description: "[Prioritizing next sprint](https://fleetdm.com/handbook/digital-experiencemunication)" + description: "Using your departmental kanban board, prioritize and finalize next sprint's goals for your team by draging the appropriate issues to the top of the 'Not yet' column." # example of a longer thing: description: "[Prioritizing next sprint](https://fleetdm.com/handbook/digital-experiencemunication)" moreInfoUrl: "https://fleetdm.com/handbook/company/why-this-way#why-make-work-visible" #URL used to highlight "description:" test in table dri: "sampfluger88" # DRI for ritual (assignee if autoIssue) (TODO display GitHub proflie pic instead of name or title) autoIssue: # Enables automation of GitHub issues @@ -114,8 +123,8 @@ repo: "confidential" - task: "Process and backup Sid agenda" - startedOn: "2023-09-15" - frequency: "Weekly" + startedOn: "2023-09-25" + frequency: "Monthly" description: "Process and backup Sid agenda" moreInfoUrl: "https://fleetdm.com/handbook/digital-experience#process-and-backup-e-group-agenda" dri: "SFriendLee" diff --git a/handbook/engineering/README.md b/handbook/engineering/README.md index d6f18bde2c82..70d9cd75f46e 100644 --- a/handbook/engineering/README.md +++ b/handbook/engineering/README.md @@ -111,11 +111,10 @@ If there is partially merged feature work when the release candidate is created, Before kicking off release QA, confirm that we are using the latest versions of dependencies we want to keep up-to-date with each release. Currently, those dependencies are: 1. **Go**: Latest minor release -- Check the [version included in Fleet](https://github.com/fleetdm/fleet/settings/variables/actions). +- Check the [Go version specified in Fleet's go.mod file](https://github.com/fleetdm/fleet/blob/main/go.mod) (`go 1.XX.YY`). - Check the [latest minor version of Go](https://go.dev/dl/). For example, if we are using `go1.19.8`, and there is a new minor version `go1.19.9`, we will upgrade. - If the latest minor version is greater than the version included in Fleet, [file a bug](https://github.com/fleetdm/fleet/issues/new?assignees=&labels=bug%2C%3Areproduce&projects=&template=bug-report.md&title=) and assign it to the [release ritual DRI](https://fleetdm.com/handbook/engineering#rituals) and the current oncall engineer. Add the `~release blocker` label. We must upgrade to the latest minor version before publishing the next release. - If the latest major version is greater than the version included in Fleet, [create a story](https://github.com/fleetdm/fleet/issues/new?assignees=&labels=story%2C%3Aproduct&projects=&template=story.md&title=) and assign it to the [release ritual DRI](https://fleetdm.com/handbook/engineering#rituals) and the current oncall engineer. This will be considered for an upcoming sprint. The release can proceed without upgrading the major version. -- Note that major version upgrades also require an [update to go.mod](https://github.com/fleetdm/fleet/blob/7b3134498873a31ba748ca27fabb0059cef70db9/go.mod#L3). > In Go versioning, the number after the first dot is the "major" version, while the number after the second dot is the "minor" version. For example, in Go 1.19.9, "19" is the major version and "9" is the minor version. Major version upgrades are assessed separately by engineering. diff --git a/handbook/product-design/README.md b/handbook/product-design/README.md index a58f6b823c9e..9ed61bbc9c9f 100644 --- a/handbook/product-design/README.md +++ b/handbook/product-design/README.md @@ -23,71 +23,61 @@ This handbook page details processes specific to working [with](#contact-us) and The Product Design department is responsible for reviewing and collecting feedback from users, would-be users, and future users, prioritizing changes, designing the changes, and delivering these changes to the engineering team. Product Design prioritizes and shapes all changes involving functionality or usage, including the UI, REST API, command line, and webhooks. -### Release relavent Figma files +### Drafting -- Once your designs are reviewed and approved, change the status on the cover page of the relevant Figma file and move the issue to the "Settled" column. -- After each release (every 3 weeks) make sure you change the status on the cover page of the relevant Figma files that you worked on during the sprint to "Released". +At Fleet, like [GitLab](https://about.gitlab.com/handbook/product-development-flow/#but-wait-isnt-this-waterfall) and [other organizations](https://speakerdeck.com/mikermcneil/i-love-apis), every change to the product's UI gets [wireframed first](https://fleetdm.com/handbook/company/why-this-way#why-do-we-use-a-wireframe-first-approach). ->**Questions and missing information:** Take a screenshot of the area in Figma and add a comment in the story's GitHub issue. Figma does have a commenting system, but it is not easy to search for outstanding concerns and is therefore not preferred. -> ->For external contributors: please consider opening an issue with reference screenshots if you have a Figma related question you need to resolve. +- Take the top user story that is assigned to you in the "Ready" column of the [drafting board](https://app.zenhub.com/workspaces/-product-backlog-coming-soon-6192dd66ea2562000faea25c/board) and move it to "In progress." + +- Create a new file inside the [Fleet product](https://www.figma.com/files/project/17318630/%F0%9F%94%9C%F0%9F%93%A6-Fleet-EE%C2%AE-(product)?fuid=1234929285759903870) Figma project by duplicating "\[TEMPLATE\] Starter file" (pinned to the top of the project). +- The starter file includes 3 predefined pages: Cover, Ready, and Scratchpad. + - **Cover.** This page has a component with issue number, issue name, and status fields. There are 3 statuses: Work in progress, Approved, and Released (the main source of truth is still the drafting board). + - **Ready.** Use this page to communicate designs reviews and development. + - **Scratchpad.** Use this page for work in progress and design that might be useful in the future. -### Create a new Figma file +- If the story requires API or YAML file changes, open a draft PR with the proposed design. -At Fleet, like [GitLab](https://about.gitlab.com/handbook/product-development-flow/#but-wait-isnt-this-waterfall) and [other organizations](https://speakerdeck.com/mikermcneil/i-love-apis), every change to the product's UI gets [wireframed first](https://fleetdm.com/handbook/company/why-this-way#why-do-we-use-a-wireframe-first-approach). +- Add links to the Figma file's cover page and draft PRs in the user story. + +- Draft changes to the Fleet product that solve the problem specified in the story. Constantly place yourself in the shoes of a user while drafting changes. Place these drafts in the appropriate Figma file in Fleet product project. -- Take the top user story that is assigned to you in the "Prioritized" column of the [drafting board](https://app.zenhub.com/workspaces/-product-backlog-coming-soon-6192dd66ea2562000faea25c/board). - -- Create a new file inside the [Fleet product](https://www.figma.com/files/project/17318630/%F0%9F%94%9C%F0%9F%93%A6-Fleet-EE%C2%AE-(product)?fuid=1234929285759903870) Figma project. See [Working with Figma](https://fleetdm.com/handbook/product#working-with-figma) below for more details. - - Use dev notes (component available in our library) to highlight important information to engineers and other teammates. -- Draft changes to the Fleet product that solve the problem specified in the story. Constantly place yourself in the shoes of a user while drafting changes. Place these drafts in the appropriate Figma file in Fleet product project. +- To help contributors find Figma wireframes for the area of the UI you're making changes to, add page names (ex. Host details page) to the user story's title and/or description. - Be intentional about changes to design components (e.g. button border-radius or modal width) because these are expensive. They'll require code changes and QA in multiple parts of the product. Propose changes to a design component as part of an already-prioritized user story instead of [making a new request](#making-a-request) in 🎁🗣 Feature Fest. -- While drafting, reach out to sales, customer success, and demand for a business perspective. +- Reach out to sales, customer success, and demand for a business perspective. -- While drafting, engage engineering to gain insight into technical costs and feasibility. - -When starting a new draft: -- Create a new file inside the [Fleet product](https://www.figma.com/files/project/17318630/%F0%9F%94%9C%F0%9F%93%A6-Fleet-EE%C2%AE-(product)?fuid=1234929285759903870) project by duplicating "\[TEMPLATE\] Starter file" (pinned to the top of the project). -- Right-click on the duplicated file, select "Share", and ensure **anyone with the link** can view the file. -- Rename each Figma file to include the number and name of the corresponding issue on the [drafting board](https://app.zenhub.com/workspaces/-product-backlog-coming-soon-6192dd66ea2562000faea25c/board). (e.g. # 11766 Instructions for Autopilot enrollment). -- The starter file includes 3 predefined pages: Cover, Ready, and Scratchpad. - - **Cover.** This page has a component with issue number, issue name, and status fields. There are 3 statuses: Work in progress, Approved, and Released (the main source of truth is still the drafting board). - - **Ready.** Use this page to communicate designs reviews and development. - - **Scratchpad.** Use this page for work in progress and design that might be useful in the future. -- If the story requires API changes, open a draft PR with the proposed API design. - - These draft PRs are not actually merged, since they're often created weeks ahead of implementation and can artificially affect our PR open time KPI. Instead, once the documentation changes are ready for final review, the designer closes the draft PR and opens a fresh PR from the same branch. +- Engage engineering to gain insight into technical costs and feasibility. +>**Questions, missing information, and notes:** Take a screenshot of the area in Figma and add a comment in the story's GitHub issue. Figma does have a commenting system, but it is not easy to search for outstanding concerns and is therefore not preferred. Also, commenting in Figma, sends all contributors email notifications. +> +>For external contributors: please consider opening an issue with reference screenshots if you have a Figma related question you need to resolve. -### Schedule a design review +### Prepare for design review -- Prepare your draft in the user story issue. -- Prepare the agenda for your design review meeting, which should be an empty document other than the proposed changes you will present. -- Review the draft with the CEO at one of the daily design review meetings, or schedule an ad-hoc design review if you need to move faster. (Efficient access to design reviews on-demand [is a priority for Fleet's CEO](https://fleetdm.com/handbook/company/ceo). Emphasizing design helps us live our [empathy](https://fleetdm.com/handbook/company#empathy) value.) -- When introducing a story, clarify which review "mode" the CEO should operate in: - + **Final review** mode — you are 70% sure the design is 100% done. - + **Feedback** mode — you know the design is not ready for final review, but would like to get early feedback. Before bringing something in feedback mode consider whether the CEO will be best for giving feedback or if it would be better suited for someone else (engineer or PM). -- During the review meeting, take detailed notes of any feedback on the draft. -- Address the feedback by modifying your draft. -- Rinse and repeat at subsequent sessions until there is no more feedback. +1. Link to your draft in the user story issue. +2. Add the user story to the agenda for the [design review](https://fleetdm.com/handbook/company/product-groups#design-reviews) meeting. +3. Attend design review or schedule an ad-hoc design review if you need to move faster. > As drafting occurs, inevitably, the requirements will change. The main description of the issue should be the single source of truth for the problem to be solved and the required outcome. The product manager is responsible for keeping the main description of the issue up-to-date. Comments and other items can and should be kept in the issue for historical record-keeping. ### Ensure story drafting is complete -<!--TODO update responsibility to reflect reality (e.g. line 75 == "Bugs board"?)--> -Once a story has gone through design and is considered "Settled", it moves to the "Settled" column on the drafting board and assign to the Engineering Manager (EM). +Once a story is approved in [design review](https://fleetdm.com/handbook/company/product-groups#design-reviews), the Product Designer is responsible for moving the user story to the "Ready to spec" column, assigning the appropriate Engineering Manager (EM), adding a product group label, and changing the status on the cover page of the relevant Figma file to "Approved". + +The EM is responsible for moving the user story to the "Specified" and "Estimated" columns. + +Before assigning an EM, double-check that the "Product" section of the user story [checklist](https://github.com/fleetdm/fleet/issues/new?assignees=&labels=story&projects=&template=story.md&title=) is complete (no TODOs). -Before assigning an EM to [estimate](https://fleetdm.com/handbook/engineering#sprint-ceremonies) a user story, the product designer ensures the product section of the user story [checklist](https://github.com/fleetdm/fleet/issues/new?assignees=&labels=story&projects=&template=story.md&title=) is complete. +Once a bug is approved in design review, The Product Designer is responsible for moving the bug to the appropriate release board. -Once a bug has gone through design and is considered "Settled", the designer removes the `:product` label and moves the issue to the 'Sprint backlog' column on the "Bugs" board and assigns the group engineering manager. ### Revise a draft currently in development + Expedited drafting is the revision of drafted changes currently being developed by the engineering team. Expedited drafting aims to quickly adapt to unknown edge cases and changing specifications while ensuring that Fleet meets our brand and quality guidelines. @@ -95,10 +85,10 @@ changing specifications while ensuring that Fleet meets our brand and quality gu You'll know it's time for expedited drafting when: - The team discovers that a drafted user story is missing crucial information that prevents contributors from continuing the development task. - A user story is taking more effort than was originally estimated, and Product Manager wants to find ways to cut aspects of planned functionality in order to still ship the improvement in the currently scheduled release. -- A user story on the drafting board won't reach "Settled" by the last estimation session in the current sprint and cannot wait until the next sprint. This can also happen when we decide to bring a user story in mid-sprint. +- A user story on the drafting board won't reach "Ready for spec" by the last estimation session in the current sprint and cannot wait until the next sprint. This can also happen when we decide to bring a user story in mid-sprint. What happens during expedited drafting? -1. If the user story wasn't "Settled" by the last estimation session, the product group's engineering manager (EM), [release DRI](https://fleetdm.com/handbook/company/communications#directly-responsible-individuals-dris), and Head of Product Design are notified in `#g-mdm` or `#g-endpoint-ops`. Decision to allow the user story to make it into the sprint is up to the release DRI. +1. If the user story wasn't "Ready for spec" by the last estimation session, the product group's engineering manager (EM), [release DRI](https://fleetdm.com/handbook/company/communications#directly-responsible-individuals-dris), and Head of Product Design are notified in `#g-mdm` or `#g-endpoint-ops`. Decision to allow the user story to make it into the sprint is up to the release DRI. 2. If the user story is already in the sprint, the EM, release DRI, and Head of Product Design are notified in `#g-mdm` or `#g-endpoint-ops`. If there are significant changes to the requirements, then the user story might be pushed to the next sprint. Decision is up to the release DRI. 3. If the release DRI decides the user story will be worked on this sprint, drafts are updated or finished. 4. UI changes [are approved](https://fleetdm.com/handbook/company/development-groups#drafting-process), and the UI changes are brought back into the sprint or are estimated. @@ -139,7 +129,9 @@ If an issue's title or user story summary (_"as a…I want to…so that"_) does Engineering Managers estimate user stories. They are responsible for delivering planned work in the current sprint (0-3 weeks) while quickly getting user stories estimated for the next sprint (3-6 weeks). Only work that is slated to be released into the hands of users within ≤six weeks will be estimated. Estimation is run by each group's Engineering Manager and occurs on the [drafting board](https://app.zenhub.com/workspaces/-product-backlog-coming-soon-6192dd66ea2562000faea25c/board). + ### Rank features before release + These measures exist to keep all contributors (including other departments besides engineering and product) up to date with improvements and changes to the Fleet product. This helps folks plan and communicate with customers and users more effectively. After the kickoff of a product sprint, the demand and product teams decide which improvements are most important to highlight in this release, whether that's through social media "drumbeat" tweets, collaboration with partners, or emphasized [content blocks](https://about.gitlab.com/handbook/marketing/blog/release-posts/#3rd-to-10th) within the release blog post. @@ -304,5 +296,11 @@ Please see [handbook/product-groups#after-the-feature-is-accepted](https://fleet ##### Restart Algolia manually Please see [handbook/digital-experience#restart-algolia-manually](https://fleetdm.com/handbook/digital-experience#restart-algolia-manually) +##### Schedule a design review +Please see [handbook/product#prepare-for-design-review](https://fleetdm.com/handbook/product#prepare-for-design-review) + +##### Create a new Figma file +Please see [handbook/product#drafting](https://fleetdm.com/handbook/product#drafting) + <meta name="maintainedBy" value="noahtalerman"> <meta name="title" value="🦢 Product design"> diff --git a/handbook/product-design/product-design.rituals.yml b/handbook/product-design/product-design.rituals.yml index 8881e66acb7c..d9225ce778ff 100644 --- a/handbook/product-design/product-design.rituals.yml +++ b/handbook/product-design/product-design.rituals.yml @@ -30,7 +30,7 @@ task: "🦢🗣 Design review" startedOn: "2024-03-07" frequency: "Daily" - description: "Contributors present wireframes (UI changes) that are “Ready for review”. Head of Product Design provides feedback on UI/CLI/API changes." + description: "On Mondays, contributors present wireframes in 'Feedback' mode and anyone can give feedback. 'Final review' mode during all other days and only Head of Product Design + CTO + Product Designers give feedback." moreInfoUrl: "https://fleetdm.com/handbook/company/product-groups#design-reviews" dri: "noahtalerman" - diff --git a/handbook/sales/README.md b/handbook/sales/README.md index ee416ab8da3e..8213a86777c6 100644 --- a/handbook/sales/README.md +++ b/handbook/sales/README.md @@ -51,7 +51,9 @@ A recent signed copy of Fleet's W-9 form can be found in [this confidential PDF For customers with large deployments, Fleet accepts payment via wire transfer or electronic debit (ACH/SWIFT). -Provide remittance information to customers by exporting ["💸 Paying Fleet"](https://docs.google.com/document/d/1KP_-x9c1x3sS1X9Q8Wlib2H7tq69xRONn1KMA3nVFQc/edit) into a PDF, then sending that to the prospect. +Payment information for customers within the United States is on Fleet's invoices. Typically, payment information does not need to be sent separately. + +For Fleet customers outside of the United States or instances where a customer is requesting payment information prior to invoicing, provide remittance information to customers by exporting ["💸 Paying Fleet"](https://docs.google.com/document/d/1KP_-x9c1x3sS1X9Q8Wlib2H7tq69xRONn1KMA3nVFQc/edit) into a PDF, then sending that to the prospect. ### Review rep activity @@ -59,10 +61,21 @@ Provide remittance information to customers by exporting ["💸 Paying Fleet"](h Following up with people interested in Fleet is an important part of finding out whether or not they'd like to continue the process of buying the product. It is also very important not to be annoying. At Fleet, team members follow up with people, but not too often. To help coach reps and avoid being annoying to Fleet users, Fleet reviews rep activity on a regular basis following these steps: -1. In Salesforce, visit the activity report on your dashboard. (TODO: taylor will replace this and/or link it) +1. In Salesforce, visit the activity report on your dashboard. 2. For each rep, review recent activity from the last 30 days across all of that rep's accounts. 3. If outreach is too frequent or doesn't fit the company's strategy, then set up a 30 minute coaching session to discuss with the rep. +Every week, AEs will review the status of all qualified opportunities with leadership in an opportunity pipeline review meeting. For this meeting, reps will: +1. Update the following information in Salesforce for every opp: + - Contacts (and Roles) + - Amount + - Close date + - Stage + - Next steps +2. Make sure all contacts have been sent a connection request from Mike McNeil. +3. Identify and discuss where gaps are in [MEDDPICC](https://handbook.gitlab.com/handbook/sales/meddppicc/). +4. Relay how many meetings they had with attendees from both IT and security this week. + ### Validate Salesforce data (RevOps) diff --git a/handbook/sales/sales.rituals.yml b/handbook/sales/sales.rituals.yml index 6b2fd8fd0d3c..e83a43e85103 100644 --- a/handbook/sales/sales.rituals.yml +++ b/handbook/sales/sales.rituals.yml @@ -1,12 +1,18 @@ # https://github.com/fleetdm/fleet/pull/13084 - + - + task: "Close leads contacted ≥7 days ago" + startedOn: "2024-07-05" + frequency: "Daily" + description: "Close all of your leads in the 'Attempted to contact' stage and which have been there for 7 or more days. If follow-up is appropriate, and won't be bothersome, it can be done after closing the lead. (A new lead can always be opened for the contact later.)" + moreInfoUrl: "" + dri: "Every AE" - task: "Prioritize for next sprint" # Title that will actually show in rituals table startedOn: "2023-09-04" # Needs to align with frequency e.g. if frequency is every thrid Thursday startedOn === any third thursday frequency: "Triweekly" # must be supported by - description: "Drag next sprint's priorities to the top of the 'Not yet' column." # example of a longer thing: description: "[Prioritizing next sprint](https://fleetdm.com/handbook/company/communication)" + description: "Using your departmental kanban board, prioritize and finalize next sprint's goals for your team by draging the appropriate issues to the top of the 'Not yet' column." # example of a longer thing: description: "[Prioritizing next sprint](https://fleetdm.com/handbook/company/communication)" moreInfoUrl: "https://fleetdm.com/handbook/company/why-this-way#why-make-work-visible" #URL used to highlight "description:" test in table dri: "alexmitchelliii" # DRI for ritual (assignee if autoIssue) (TODO display GitHub proflie pic instead of name or title) autoIssue: # Enables automation of GitHub issues diff --git a/infrastructure/loadtesting/terraform/docker/loadtest.Dockerfile b/infrastructure/loadtesting/terraform/docker/loadtest.Dockerfile index 81425e673b73..c232760b4412 100644 --- a/infrastructure/loadtesting/terraform/docker/loadtest.Dockerfile +++ b/infrastructure/loadtesting/terraform/docker/loadtest.Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.22.6-alpine3.20@sha256:1a478681b671001b7f029f94b5016aed984a23ad99c707f6a0ab6563860ae2f3 +FROM golang:1.23.1-alpine3.20@sha256:436e2d978524b15498b98faa367553ba6c3655671226f500c72ceb7afb2ef0b1 ARG TAG RUN apk add git RUN git clone -b $TAG --depth=1 --no-tags --progress --no-recurse-submodules https://github.com/fleetdm/fleet.git && cd /go/fleet/cmd/osquery-perf/ && go build . diff --git a/infrastructure/loadtesting/terraform/readme.md b/infrastructure/loadtesting/terraform/readme.md index 378d9eaf35db..bfa120bfa188 100644 --- a/infrastructure/loadtesting/terraform/readme.md +++ b/infrastructure/loadtesting/terraform/readme.md @@ -61,13 +61,14 @@ If you need to run a load test with MDM enabled and configured you will need to 2. Then set the `fleet_config` terraform var the following way (make sure to add any extra configuration you need to this JSON): ```sh -export TF_VAR_fleet_config='{"FLEET_DEV_MDM_APPLE_DISABLE_PUSH":"1","FLEET_MDM_APPLE_SCEP_CHALLENGE":"foobar","FLEET_MDM_APPLE_SCEP_CERT_BYTES":"'$(cat /Users/foobar/mdm/fleet-mdm-apple-scep.crt | gsed -z 's/\n/\\n/g')'","FLEET_MDM_APPLE_SCEP_KEY_BYTES":"'$(cat /Users/foobar/mdm/fleet-mdm-apple-scep.key | gsed -z 's/\n/\\n/g')'","FLEET_MDM_APPLE_APNS_CERT_BYTES":"'$(cat /Users/foobar/mdm/mdmcert.download.push.pem | gsed -z 's/\n/\\n/g')'","FLEET_MDM_APPLE_APNS_KEY_BYTES":"'$(cat /Users/foobar/mdm/mdmcert.download.push.key | gsed -z 's/\n/\\n/g')'","FLEET_MDM_APPLE_BM_SERVER_TOKEN_BYTES":"'$(cat /Users/foobar/mdm/downloadtoken.p7m | gsed -z 's/\n/\\n/g' | gsed 's/"smime\.p7m"/\\"smime.p7m\\"/g' | tr -d '\r\n')'","FLEET_MDM_APPLE_BM_CERT_BYTES":"'$(cat /Users/foobar/mdm/fleet-apple-mdm-bm-public-key.crt | gsed -z 's/\n/\\n/g')'","FLEET_MDM_APPLE_BM_KEY_BYTES":"'$(cat /Users/foobar/mdm/fleet-apple-mdm-bm-private.key | gsed -z 's/\n/\\n/g')'"}' +export TF_VAR_fleet_config='{"FLEET_DEV_MDM_APPLE_DISABLE_PUSH":"1","FLEET_DEV_MDM_APPLE_DISABLE_DEVICE_INFO_CERT_VERIFY":"1","FLEET_MDM_APPLE_SCEP_CHALLENGE":"foobar","FLEET_MDM_APPLE_SCEP_CERT_BYTES":"'$(cat /Users/foobar/mdm/fleet-mdm-apple-scep.crt | gsed -z 's/\n/\\n/g')'","FLEET_MDM_APPLE_SCEP_KEY_BYTES":"'$(cat /Users/foobar/mdm/fleet-mdm-apple-scep.key | gsed -z 's/\n/\\n/g')'","FLEET_MDM_APPLE_APNS_CERT_BYTES":"'$(cat /Users/foobar/mdm/mdmcert.download.push.pem | gsed -z 's/\n/\\n/g')'","FLEET_MDM_APPLE_APNS_KEY_BYTES":"'$(cat /Users/foobar/mdm/mdmcert.download.push.key | gsed -z 's/\n/\\n/g')'","FLEET_MDM_APPLE_BM_SERVER_TOKEN_BYTES":"'$(cat /Users/foobar/mdm/downloadtoken.p7m | gsed -z 's/\n/\\n/g' | gsed 's/"smime\.p7m"/\\"smime.p7m\\"/g' | tr -d '\r\n')'","FLEET_MDM_APPLE_BM_CERT_BYTES":"'$(cat /Users/foobar/mdm/fleet-apple-mdm-bm-public-key.crt | gsed -z 's/\n/\\n/g')'","FLEET_MDM_APPLE_BM_KEY_BYTES":"'$(cat /Users/foobar/mdm/fleet-apple-mdm-bm-private.key | gsed -z 's/\n/\\n/g')'"}' ``` - The above is needed because the newline characters in the certificate/key/token files. - The value set in `FLEET_MDM_APPLE_SCEP_CHALLENGE` must match whatever you set in `osquery-perf`'s `mdm_scep_challenge` argument. - The above `export TF_VAR_fleet_config=...` command was tested on `bash`. It did not work in `zsh`. - Note that we are also setting `FLEET_DEV_MDM_APPLE_DISABLE_PUSH=1`. We don't want to generate push notifications against fake UUIDs (otherwise it may cause Apple to rate limit due to invalid requests). +- Note that we are also setting `FLEET_DEV_MDM_APPLE_DISABLE_DEVICE_INFO_CERT_VERIFY=1` to skip verification of Apple certificates for OTA enrollments. This has an impact on real devices because they will not be notified of any command to execute (it may take a reboot for them to reach out to Fleet for more commands). 3. Add the following `osquery-perf` arguments to [loadtesting.tf](./loadtesting.tf) diff --git a/it-and-security/default.yml b/it-and-security/default.yml index 9d1b126ebf2c..52baadb564d1 100644 --- a/it-and-security/default.yml +++ b/it-and-security/default.yml @@ -38,6 +38,10 @@ org_settings: zendesk: [ ] mdm: apple_bm_default_team: $DOGFOOD_APPLE_BM_DEFAULT_TEAM + end_user_authentication: + entity_id: dogfood-eula.fleetdm.com + idp_name: Google Workspace + metadata_url: $DOGFOOD_MDM_SSO_METADATA_URL org_info: contact_url: https://fleetdm.com/company/contact org_logo_url: "" diff --git a/it-and-security/lib/configuration-profiles/passcode-settings-ddm.json b/it-and-security/lib/configuration-profiles/passcode-settings-ddm.json index dabb02d1a674..f4811f765bee 100644 --- a/it-and-security/lib/configuration-profiles/passcode-settings-ddm.json +++ b/it-and-security/lib/configuration-profiles/passcode-settings-ddm.json @@ -2,7 +2,7 @@ "Type": "com.apple.configuration.passcode.settings", "Identifier": "956e0d14-6019-479b-a6f9-a69ef77668c5", "Payload": { - "MaximumFailedAttempts": "five", + "MaximumFailedAttempts": 5, "MaximumInactivityInMinutes ": 5, "MinimumLength ": 12, "MinimumComplexCharacters": 3 diff --git a/it-and-security/teams/workstations-canary.yml b/it-and-security/teams/workstations-canary.yml index e4dba2ab9b99..518b32d94e94 100644 --- a/it-and-security/teams/workstations-canary.yml +++ b/it-and-security/teams/workstations-canary.yml @@ -65,7 +65,6 @@ controls: enable_disk_encryption: true macos_settings: custom_settings: - - path: ../lib/configuration-profiles/macos-automatic-updates.mobileconfig - path: ../lib/configuration-profiles/macos-chrome-enrollment.mobileconfig - path: ../lib/configuration-profiles/macos-date-time.mobileconfig - path: ../lib/configuration-profiles/macos-disable-bluetooth-file-sharing.mobileconfig @@ -132,7 +131,7 @@ policies: platform: darwin calendar_events_enabled: false - name: macOS - System maintenance complete - query: SELECT 1 AS result FROM system_info WHERE computer_name NOT IN ('Titanosauria', 'Drew’s MacBook Pro','fleetwoodmike','Anthony’s MacBook Pro','Patricia’s MacBook Pro','Paul’s MacBook Pro','Tom’s MacBook Air'); + query: SELECT 1 AS result FROM system_info WHERE computer_name NOT IN ('Drew’s MacBook Pro','Anthony’s MacBook Pro','Patricia’s MacBook Pro','Paul’s MacBook Pro','Tom’s MacBook Air'); critical: false description: Determines if the device has completed system maintenance. resolution: We will perform system maintenance on your device. diff --git a/it-and-security/teams/workstations.yml b/it-and-security/teams/workstations.yml index 8bf73ec7454a..79dd914a7bb9 100644 --- a/it-and-security/teams/workstations.yml +++ b/it-and-security/teams/workstations.yml @@ -18,7 +18,6 @@ controls: enable_disk_encryption: true macos_settings: custom_settings: - - path: ../lib/configuration-profiles/macos-automatic-updates.mobileconfig - path: ../lib/configuration-profiles/macos-date-time.mobileconfig - path: ../lib/configuration-profiles/macos-chrome-enrollment.mobileconfig - path: ../lib/configuration-profiles/macos-disable-bluetooth-file-sharing.mobileconfig diff --git a/orbit/TUF.md b/orbit/TUF.md index 36edaecbd618..727e5f34b60d 100644 --- a/orbit/TUF.md +++ b/orbit/TUF.md @@ -7,8 +7,8 @@ Following are the currently deployed versions of fleetd components on the `stabl | Component\OS | macOS | Linux | Windows | Linux (arm64) | |--------------|--------------|--------|---------|---------------| -| orbit | 1.31.0 | 1.31.0 | 1.31.0 | 1.31.0 | -| desktop | 1.31.0 | 1.31.0 | 1.31.0 | 1.31.0 | +| orbit | 1.32.0 | 1.32.0 | 1.32.0 | 1.32.0 | +| desktop | 1.32.0 | 1.32.0 | 1.32.0 | 1.32.0 | | osqueryd | 5.13.1 | 5.13.1 | 5.13.1 | 5.13.1 | | nudge | 1.1.10.81462 | - | - | - | | swiftDialog | 2.1.0 | - | - | - | diff --git a/orbit/changes/update-go1.23.1 b/orbit/changes/update-go1.23.1 new file mode 100644 index 000000000000..d9a689e4e905 --- /dev/null +++ b/orbit/changes/update-go1.23.1 @@ -0,0 +1,2 @@ +* Updated Go to go1.23.1 + diff --git a/pkg/spec/gitops.go b/pkg/spec/gitops.go index e29e9138dcd6..558d7a1f06c6 100644 --- a/pkg/spec/gitops.go +++ b/pkg/spec/gitops.go @@ -10,6 +10,7 @@ import ( "unicode" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/ptr" "github.com/ghodss/yaml" "github.com/hashicorp/go-multierror" "golang.org/x/text/unicode/norm" @@ -39,7 +40,19 @@ type Controls struct { type Policy struct { BaseItem + GitOpsPolicySpec +} + +type GitOpsPolicySpec struct { fleet.PolicySpec + InstallSoftware *PolicyInstallSoftware `json:"install_software"` + // InstallSoftwareURL is populated after parsing the software installer yaml + // referenced by InstallSoftware.PackagePath. + InstallSoftwareURL string `json:"-"` +} + +type PolicyInstallSoftware struct { + PackagePath string `json:"package_path"` } type Query struct { @@ -47,6 +60,16 @@ type Query struct { fleet.QuerySpec } +type SoftwarePackage struct { + BaseItem + fleet.SoftwarePackageSpec +} + +type Software struct { + Packages []SoftwarePackage `json:"packages"` + AppStoreApps []fleet.TeamSpecAppStoreApp `json:"app_store_apps"` +} + type GitOps struct { TeamID *uint TeamName *string @@ -54,7 +77,7 @@ type GitOps struct { OrgSettings map[string]interface{} AgentOptions *json.RawMessage Controls Controls - Policies []*fleet.PolicySpec + Policies []*GitOpsPolicySpec Queries []*fleet.QuerySpec // Software is only allowed on teams, not on global config. Software GitOpsSoftware @@ -113,13 +136,15 @@ func GitOpsFromFile(filePath, baseDir string, appConfig *fleet.EnrichedAppConfig // Validate the required top level options multiError = parseControls(top, result, baseDir, multiError) multiError = parseAgentOptions(top, result, baseDir, multiError) - multiError = parsePolicies(top, result, baseDir, multiError) multiError = parseQueries(top, result, baseDir, multiError) if appConfig != nil && appConfig.License.IsPremium() { multiError = parseSoftware(top, result, baseDir, multiError) } + // Policies can reference software installers, thus we parse them after parseSoftware. + multiError = parsePolicies(top, result, baseDir, multiError) + return result, multiError.ErrorOrNil() } @@ -389,7 +414,11 @@ func parsePolicies(top map[string]json.RawMessage, result *GitOps, baseDir strin for _, item := range policies { item := item if item.Path == nil { - result.Policies = append(result.Policies, &item.PolicySpec) + if err := parsePolicyInstallSoftware(baseDir, result.TeamName, &item, result.Software.Packages); err != nil { + multiError = multierror.Append(multiError, fmt.Errorf("failed to parse policy install_software %q: %v", item.Name, err)) + continue + } + result.Policies = append(result.Policies, &item.GitOpsPolicySpec) } else { fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, *item.Path)) if err != nil { @@ -416,7 +445,11 @@ func parsePolicies(top map[string]json.RawMessage, result *GitOps, baseDir strin multiError, fmt.Errorf("nested paths are not supported: %s in %s", *pp.Path, *item.Path), ) } else { - result.Policies = append(result.Policies, &pp.PolicySpec) + if err := parsePolicyInstallSoftware(baseDir, result.TeamName, pp, result.Software.Packages); err != nil { + multiError = multierror.Append(multiError, fmt.Errorf("failed to parse policy install_software %q: %v", pp.Name, err)) + continue + } + result.Policies = append(result.Policies, &pp.GitOpsPolicySpec) } } } @@ -440,7 +473,7 @@ func parsePolicies(top map[string]json.RawMessage, result *GitOps, baseDir strin } } duplicates := getDuplicateNames( - result.Policies, func(p *fleet.PolicySpec) string { + result.Policies, func(p *GitOpsPolicySpec) string { return p.Name }, ) @@ -450,6 +483,39 @@ func parsePolicies(top map[string]json.RawMessage, result *GitOps, baseDir strin return multiError } +func parsePolicyInstallSoftware(baseDir string, teamName *string, policy *Policy, packages []*fleet.SoftwarePackageSpec) error { + if policy.InstallSoftware == nil { + policy.SoftwareTitleID = ptr.Uint(0) // unset the installer + return nil + } + if policy.InstallSoftware != nil && policy.InstallSoftware.PackagePath != "" && teamName == nil { + return errors.New("install_software can only be set on team policies") + } + if policy.InstallSoftware.PackagePath == "" { + return errors.New("empty package_path") + } + fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, policy.InstallSoftware.PackagePath)) + if err != nil { + return fmt.Errorf("failed to read install_software.package_path file %q: %v", policy.InstallSoftware.PackagePath, err) + } + var policyInstallSoftwareSpec fleet.SoftwarePackageSpec + if err := yaml.Unmarshal(fileBytes, &policyInstallSoftwareSpec); err != nil { + return fmt.Errorf("failed to unmarshal install_software.package_path file %s: %v", policy.InstallSoftware.PackagePath, err) + } + installerOnTeamFound := false + for _, pkg := range packages { + if pkg.URL == policyInstallSoftwareSpec.URL { + installerOnTeamFound = true + break + } + } + if !installerOnTeamFound { + return fmt.Errorf("install_software.package_path URL %s not found on team: %s", policyInstallSoftwareSpec.URL, policy.InstallSoftware.PackagePath) + } + policy.InstallSoftwareURL = policyInstallSoftwareSpec.URL + return nil +} + func parseQueries(top map[string]json.RawMessage, result *GitOps, baseDir string, multiError *multierror.Error) *multierror.Error { queriesRaw, ok := top["queries"] if !ok { @@ -530,31 +596,48 @@ func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir strin if !ok { return multierror.Append(multiError, errors.New("'software' is required")) } - var software fleet.SoftwareSpec + var software Software if len(softwareRaw) > 0 { if err := json.Unmarshal(softwareRaw, &software); err != nil { + var typeErr *json.UnmarshalTypeError + if errors.As(err, &typeErr) { + return multierror.Append(multiError, fmt.Errorf("Couldn't edit software. %q must be a %s, found %s", typeErr.Field, typeErr.Type.String(), typeErr.Value)) + } return multierror.Append(multiError, fmt.Errorf("failed to unmarshall softwarespec: %v", err)) } } - if software.AppStoreApps.Set { - for _, item := range software.AppStoreApps.Value { - item := item - if item.AppStoreID == "" { - multiError = multierror.Append(multiError, errors.New("software app store id required")) - continue - } - result.Software.AppStoreApps = append(result.Software.AppStoreApps, &item) + for _, item := range software.AppStoreApps { + item := item + if item.AppStoreID == "" { + multiError = multierror.Append(multiError, errors.New("software app store id required")) + continue } + result.Software.AppStoreApps = append(result.Software.AppStoreApps, &item) } - if software.Packages.Set { - for _, item := range software.Packages.Value { - item := item - if item.URL == "" { - multiError = multierror.Append(multiError, errors.New("software URL is required")) + for _, item := range software.Packages { + var softwarePackageSpec fleet.SoftwarePackageSpec + if item.Path != nil { + fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, *item.Path)) + if err != nil { + multiError = multierror.Append(multiError, fmt.Errorf("failed to read policies file %s: %v", *item.Path, err)) continue } - result.Software.Packages = append(result.Software.Packages, &item) + if err := yaml.Unmarshal(fileBytes, &softwarePackageSpec); err != nil { + multiError = multierror.Append(multiError, fmt.Errorf("failed to unmarshal software package file %s: %v", *item.Path, err)) + continue + } + } else { + softwarePackageSpec = item.SoftwarePackageSpec + } + if softwarePackageSpec.URL == "" { + multiError = multierror.Append(multiError, errors.New("software URL is required")) + continue + } + if len(softwarePackageSpec.URL) > fleet.SoftwareInstallerURLMaxLength { + multiError = multierror.Append(multiError, fmt.Errorf("software URL %q is too long, must be less than 256 characters", softwarePackageSpec.URL)) + continue } + result.Software.Packages = append(result.Software.Packages, &softwarePackageSpec) } return multiError diff --git a/pkg/spec/gitops_test.go b/pkg/spec/gitops_test.go index 58e12553b981..ea01fcf1dc0e 100644 --- a/pkg/spec/gitops_test.go +++ b/pkg/spec/gitops_test.go @@ -5,8 +5,10 @@ import ( "os" "path/filepath" "slices" + "strings" "testing" + "github.com/fleetdm/fleet/v4/pkg/file" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -108,7 +110,15 @@ func TestValidGitOpsYaml(t *testing.T) { t.Parallel() } - gitops, err := GitOpsFromFile(test.filePath, "./testdata", nil) + var appConfig *fleet.EnrichedAppConfig + if test.isTeam { + appConfig = &fleet.EnrichedAppConfig{} + appConfig.License = &fleet.LicenseInfo{ + Tier: fleet.TierPremium, + } + } + + gitops, err := GitOpsFromFile(test.filePath, "./testdata", appConfig) require.NoError(t, err) if test.isTeam { @@ -201,14 +211,32 @@ func TestValidGitOpsYaml(t *testing.T) { assert.Equal(t, "darwin,linux,windows", gitops.Queries[1].Platform) assert.Equal(t, "osquery_info", gitops.Queries[2].Name) + // Check software + if test.isTeam { + require.Len(t, gitops.Software.Packages, 2) + require.Equal(t, "https://statics.teams.cdn.office.net/production-osx/enterprise/webview2/lkg/MicrosoftTeams.pkg", gitops.Software.Packages[0].URL) + require.False(t, gitops.Software.Packages[0].SelfService) + require.Equal(t, "https://ftp.mozilla.org/pub/firefox/releases/129.0.2/mac/en-US/Firefox%20129.0.2.pkg", gitops.Software.Packages[1].URL) + require.True(t, gitops.Software.Packages[1].SelfService) + } + // Check policies - require.Len(t, gitops.Policies, 5) + expectedPoliciesCount := 5 + if test.isTeam { + expectedPoliciesCount = 6 + } + require.Len(t, gitops.Policies, expectedPoliciesCount) assert.Equal(t, "😊 Failing policy", gitops.Policies[0].Name) assert.Equal(t, "Passing policy", gitops.Policies[1].Name) assert.Equal(t, "No root logins (macOS, Linux)", gitops.Policies[2].Name) assert.Equal(t, "🔥 Failing policy", gitops.Policies[3].Name) assert.Equal(t, "linux", gitops.Policies[3].Platform) assert.Equal(t, "😊😊 Failing policy", gitops.Policies[4].Name) + if test.isTeam { + assert.Equal(t, "Microsoft Teams on macOS installed and up to date", gitops.Policies[5].Name) + assert.NotNil(t, gitops.Policies[5].InstallSoftware) + assert.Equal(t, "./microsoft-teams.pkg.software.yml", gitops.Policies[5].InstallSoftware.PackagePath) + } }, ) } @@ -751,6 +779,120 @@ func TestGitOpsPaths(t *testing.T) { } } +func TestGitOpsGlobalPolicyWithInstallSoftware(t *testing.T) { + t.Parallel() + config := getGlobalConfig([]string{"policies"}) + config += ` +policies: +- name: Some policy + query: SELECT 1; + install_software: + package_path: ./some_path.yml +` + _, err := gitOpsFromString(t, config) + assert.ErrorContains(t, err, "install_software can only be set on team policies") +} + +func TestGitOpsTeamPolicyWithInvalidInstallSoftware(t *testing.T) { + t.Parallel() + config := getTeamConfig([]string{"policies"}) + config += ` +policies: +- name: Some policy + query: SELECT 1; + install_software: + package_path: ./some_path.yml +` + _, err := gitOpsFromString(t, config) + assert.ErrorContains(t, err, "failed to read install_software.package_path file") + + config = getTeamConfig([]string{"policies"}) + config += ` +policies: +- name: Some policy + query: SELECT 1; + install_software: + package_path: +` + _, err = gitOpsFromString(t, config) + assert.ErrorContains(t, err, "empty package_path") + + // Software has a URL that's too big + tooBigURL := fmt.Sprintf("https://ftp.mozilla.org/%s", strings.Repeat("a", 232)) + config = getTeamConfig([]string{"software"}) + config += fmt.Sprintf(` +software: + packages: + - url: %s +`, tooBigURL) + appConfig := fleet.EnrichedAppConfig{} + appConfig.License = &fleet.LicenseInfo{ + Tier: fleet.TierPremium, + } + path, basePath := createTempFile(t, "", config) + _, err = GitOpsFromFile(path, basePath, &appConfig) + assert.ErrorContains(t, err, fmt.Sprintf("software URL \"%s\" is too long, must be less than 256 characters", tooBigURL)) + + // Policy references a software installer not present in the team. + config = getTeamConfig([]string{"policies"}) + config += ` +policies: + - path: ./team_install_software.policies.yml +software: + packages: + - url: https://ftp.mozilla.org/pub/firefox/releases/129.0.2/mac/en-US/Firefox%20129.0.2.pkg + self_service: true + +` + path, basePath = createTempFile(t, "", config) + err = file.Copy( + filepath.Join("testdata", "team_install_software.policies.yml"), + filepath.Join(basePath, "team_install_software.policies.yml"), + 0o755, + ) + require.NoError(t, err) + err = file.Copy( + filepath.Join("testdata", "microsoft-teams.pkg.software.yml"), + filepath.Join(basePath, "microsoft-teams.pkg.software.yml"), + 0o755, + ) + require.NoError(t, err) + _, err = GitOpsFromFile(path, basePath, &appConfig) + assert.ErrorContains(t, err, + "install_software.package_path URL https://statics.teams.cdn.office.net/production-osx/enterprise/webview2/lkg/MicrosoftTeams.pkg not found on team", + ) + + // Policy references a software installer file that has an invalid yaml. + config = getTeamConfig([]string{"policies"}) + config += ` +policies: + - path: ./team_install_software.policies.yml +software: + packages: + - url: https://ftp.mozilla.org/pub/firefox/releases/129.0.2/mac/en-US/Firefox%20129.0.2.pkg + self_service: true +` + path, basePath = createTempFile(t, "", config) + err = file.Copy( + filepath.Join("testdata", "team_install_software.policies.yml"), + filepath.Join(basePath, "team_install_software.policies.yml"), + 0o755, + ) + require.NoError(t, err) + err = os.WriteFile( // nolint:gosec + filepath.Join(basePath, "microsoft-teams.pkg.software.yml"), + []byte("invalid yaml"), + 0o755, + ) + require.NoError(t, err) + appConfig = fleet.EnrichedAppConfig{} + appConfig.License = &fleet.LicenseInfo{ + Tier: fleet.TierPremium, + } + _, err = GitOpsFromFile(path, basePath, &appConfig) + assert.ErrorContains(t, err, "failed to unmarshal install_software.package_path file") +} + func getGlobalConfig(optsToExclude []string) string { return getBaseConfig(topLevelOptions, optsToExclude) } diff --git a/pkg/spec/testdata/microsoft-teams.pkg.software.yml b/pkg/spec/testdata/microsoft-teams.pkg.software.yml new file mode 100644 index 000000000000..1aa50514d39a --- /dev/null +++ b/pkg/spec/testdata/microsoft-teams.pkg.software.yml @@ -0,0 +1,2 @@ +url: https://statics.teams.cdn.office.net/production-osx/enterprise/webview2/lkg/MicrosoftTeams.pkg +self_service: false diff --git a/pkg/spec/testdata/team_config.yml b/pkg/spec/testdata/team_config.yml index 127ed82bcfc0..8d593283a09b 100644 --- a/pkg/spec/testdata/team_config.yml +++ b/pkg/spec/testdata/team_config.yml @@ -25,3 +25,9 @@ policies: description: This policy should always fail. resolution: There is no resolution for this policy. query: SELECT 1 FROM osquery_info WHERE start_time < 0; + - path: ./team_install_software.policies.yml +software: + packages: + - path: ./microsoft-teams.pkg.software.yml + - url: https://ftp.mozilla.org/pub/firefox/releases/129.0.2/mac/en-US/Firefox%20129.0.2.pkg + self_service: true diff --git a/pkg/spec/testdata/team_config_no_paths.yml b/pkg/spec/testdata/team_config_no_paths.yml index 44c4ff36b0c2..c0c0742d72fa 100644 --- a/pkg/spec/testdata/team_config_no_paths.yml +++ b/pkg/spec/testdata/team_config_no_paths.yml @@ -115,3 +115,13 @@ policies: description: This policy should always fail. resolution: There is no resolution for this policy. query: SELECT 1 FROM osquery_info WHERE start_time < 0; + - name: Microsoft Teams on macOS installed and up to date + platform: darwin + query: SELECT 1 FROM apps WHERE name = 'Microsoft Teams.app' AND version_compare(bundle_short_version, '24193.1707.3028.4282') >= 0; + install_software: + package_path: ./microsoft-teams.pkg.software.yml +software: + packages: + - path: ./microsoft-teams.pkg.software.yml + - url: https://ftp.mozilla.org/pub/firefox/releases/129.0.2/mac/en-US/Firefox%20129.0.2.pkg + self_service: true diff --git a/pkg/spec/testdata/team_install_software.policies.yml b/pkg/spec/testdata/team_install_software.policies.yml new file mode 100644 index 000000000000..1811340788d8 --- /dev/null +++ b/pkg/spec/testdata/team_install_software.policies.yml @@ -0,0 +1,5 @@ +- name: Microsoft Teams on macOS installed and up to date + platform: darwin + query: SELECT 1 FROM apps WHERE name = 'Microsoft Teams.app' AND version_compare(bundle_short_version, '24193.1707.3028.4282') >= 0; + install_software: + package_path: ./microsoft-teams.pkg.software.yml diff --git a/schema/osquery_fleet_schema.json b/schema/osquery_fleet_schema.json index ed0f67627601..097534dbd1d8 100644 --- a/schema/osquery_fleet_schema.json +++ b/schema/osquery_fleet_schema.json @@ -5349,7 +5349,7 @@ }, { "name": "cryptoinfo", - "description": "Get info about the a certificate on the host.", + "description": "Get info about a certificate on the host.", "evented": false, "notes": "This table is not a core osquery table. It is included as part of fleetd, the osquery manager from Fleet. Code based on work by [Kolide](https://github.com/kolide/launcher).", "platforms": [ diff --git a/schema/tables/cryptoinfo.yml b/schema/tables/cryptoinfo.yml index 4fd8d5da374e..38055381b7db 100644 --- a/schema/tables/cryptoinfo.yml +++ b/schema/tables/cryptoinfo.yml @@ -1,5 +1,5 @@ name: cryptoinfo -description: Get info about the a certificate on the host. +description: Get info about a certificate on the host. evented: false notes: This table is not a core osquery table. It is included as part of fleetd, the osquery manager from Fleet. Code based on work by [Kolide](https://github.com/kolide/launcher). platforms: diff --git a/server/datastore/mysql/activities.go b/server/datastore/mysql/activities.go index d4cf76f3cc09..09a71a6f22fd 100644 --- a/server/datastore/mysql/activities.go +++ b/server/datastore/mysql/activities.go @@ -31,7 +31,12 @@ func (ds *Datastore) NewActivity( var userName *string var userEmail *string if user != nil { - userID = &user.ID + // To support creating activities with users that were deleted. This can happen + // for automatically installed software which uses the author of the upload as the author of + // the installation. + if user.ID != 0 { + userID = &user.ID + } userName = &user.Name userEmail = &user.Email } @@ -311,10 +316,12 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint // list pending software installs fmt.Sprintf(`SELECT hsi.execution_id as uuid, - u.name as name, - u.id as user_id, - u.gravatar_url as gravatar_url, - u.email as user_email, + -- policies with automatic installers generate a host_software_installs with (user_id=NULL,self_service=0), + -- thus the user_id for the upcoming activity needs to be the user that uploaded the software installer. + IF(hsi.user_id IS NULL AND NOT hsi.self_service, u2.name, u.name) AS name, + IF(hsi.user_id IS NULL AND NOT hsi.self_service, u2.id, u.id) as user_id, + IF(hsi.user_id IS NULL AND NOT hsi.self_service, u2.gravatar_url, u.gravatar_url) as gravatar_url, + IF(hsi.user_id IS NULL AND NOT hsi.self_service, u2.email, u.email) AS user_email, :installed_software_type as activity_type, hsi.created_at as created_at, JSON_OBJECT( @@ -334,6 +341,8 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint software_titles st ON st.id = si.title_id LEFT OUTER JOIN users u ON u.id = hsi.user_id + LEFT OUTER JOIN + users u2 ON u2.id = si.user_id LEFT OUTER JOIN host_display_names hdn ON hdn.host_id = hsi.host_id WHERE diff --git a/server/datastore/mysql/activities_test.go b/server/datastore/mysql/activities_test.go index be87524c05b6..e56f5b073745 100644 --- a/server/datastore/mysql/activities_test.go +++ b/server/datastore/mysql/activities_test.go @@ -371,6 +371,8 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { u2 := test.NewUser(t, ds, "user2", "user2@example.com", false) ctx := viewer.NewContext(noUserCtx, viewer.Viewer{User: u2}) + test.CreateInsertGlobalVPPToken(t, ds) + // create three hosts h1 := test.NewHost(t, ds, "h1.local", "10.10.10.1", "1", "1", time.Now()) nanoEnrollAndSetHostMDMData(t, ds, h1, false) @@ -401,6 +403,7 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { Title: "foo", Source: "apps", Version: "0.0.1", + UserID: u.ID, }) require.NoError(t, err) sw2, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ @@ -411,6 +414,7 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { Title: "bar", Source: "apps", Version: "0.0.2", + UserID: u.ID, }) require.NoError(t, err) sw1Meta, err := ds.GetSoftwareInstallerMetadataByID(ctx, sw1) @@ -492,7 +496,10 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { InstallScriptExitCode: ptr.Int(0), }) require.NoError(t, err) - h1Foo, err := ds.InsertSoftwareInstallRequest(noUserCtx, h1.ID, sw1Meta.InstallerID, false) // no user for this one + + // No user for this one and not Self-service, means it was installed by Fleet thus the author was decided to be the admin + // that uploaded the installer. + h1Foo, err := ds.InsertSoftwareInstallRequest(noUserCtx, h1.ID, sw1Meta.InstallerID, false) require.NoError(t, err) // create a single pending request for h2, as well as a non-pending one @@ -507,6 +514,9 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { // add a pending software install request for h2 h2Bar, err := ds.InsertSoftwareInstallRequest(ctx, h2.ID, sw2Meta.InstallerID, false) require.NoError(t, err) + // No user for this one and Self-service, means it was installed by the end user, so the user_id should be null/nil. + h2Foo, err := ds.InsertSoftwareInstallRequest(noUserCtx, h2.ID, sw1Meta.InstallerID, true) + require.NoError(t, err) // nothing for h3 @@ -515,6 +525,8 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { endTime = SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_software_installs", "execution_id", h1FooFailed, h1Bar) endTime = SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_script_results", "execution_id", h1C, h1D, h1E) endTime = SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_software_installs", "execution_id", h1FooInstalled, h1Foo) + endTime = SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_software_installs", "execution_id", h1Foo) + endTime = SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_software_installs", "execution_id", h2Foo) endTime = SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_software_installs", "execution_id", h2Bar) endTime = SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_script_results", "execution_id", h2A, h2F) SetOrderedCreatedAtTimestamps(t, ds, endTime, "host_vpp_software_installs", "command_uuid", vppCommand1, vppCommand2) @@ -527,7 +539,8 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { h1E: false, h2A: true, h2F: true, - h1Foo: false, + h1Foo: true, + h2Foo: false, h1Bar: true, h2Bar: true, vppCommand1: true, @@ -542,6 +555,10 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { h1Foo: "foo", h1Bar: "bar", h2Bar: "bar", + h2Foo: "foo", + } + execIDsWithUserAdminID := map[string]struct{}{ + h1Foo: {}, } cases := []struct { @@ -593,10 +610,10 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 8}, }, { - opts: fleet.ListOptions{PerPage: 3}, + opts: fleet.ListOptions{PerPage: 4}, hostID: h2.ID, - wantExecs: []string{h2Bar, h2A, vppCommand2}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false, TotalResults: 3}, + wantExecs: []string{h2Foo, h2Bar, h2A, vppCommand2}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false, TotalResults: 4}, }, { opts: fleet.ListOptions{}, @@ -637,7 +654,11 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { case fleet.ActivityTypeInstalledSoftware{}.ActivityName(): require.Equal(t, wantExec, details["install_uuid"], "result %d", i) require.Equal(t, execIDsSoftwareTitle[wantExec], details["software_title"], "result %d", i) - wantUser = u2 + if _, ok := execIDsWithUserAdminID[details["install_uuid"].(string)]; ok { + wantUser = u + } else { + wantUser = u2 + } case fleet.ActivityInstalledAppStoreApp{}.ActivityName(): require.Equal(t, wantExec, details["command_uuid"], "result %d", i) diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go index 387b59152125..e93c2fe9c0e8 100644 --- a/server/datastore/mysql/apple_mdm.go +++ b/server/datastore/mysql/apple_mdm.go @@ -27,6 +27,7 @@ import ( "github.com/fleetdm/fleet/v4/server/ptr" "github.com/go-kit/log" "github.com/go-kit/log/level" + "github.com/google/go-cmp/cmp" "github.com/google/uuid" "github.com/jmoiron/sqlx" ) @@ -88,7 +89,7 @@ INSERT INTO cp.LabelsExcludeAny[i].Exclude = true labels = append(labels, cp.LabelsExcludeAny[i]) } - if err := batchSetProfileLabelAssociationsDB(ctx, tx, labels, "darwin"); err != nil { + if _, err := batchSetProfileLabelAssociationsDB(ctx, tx, labels, "darwin"); err != nil { return ctxerr.Wrap(ctx, err, "inserting darwin profile label associations") } @@ -1135,7 +1136,7 @@ func upsertMDMAppleHostMDMInfoDB(ctx context.Context, tx sqlx.ExtContext, server } var mdmID int64 - if insertOnDuplicateDidInsert(result) { + if insertOnDuplicateDidInsertOrUpdate(result) { mdmID, _ = result.LastInsertId() } else { stmt := `SELECT id FROM mobile_device_management_solutions WHERE name = ? AND server_url = ?` @@ -1444,7 +1445,8 @@ func (ds *Datastore) GetNanoMDMEnrollment(ctx context.Context, id string) (*flee func (ds *Datastore) BatchSetMDMAppleProfiles(ctx context.Context, tmID *uint, profiles []*fleet.MDMAppleConfigProfile) error { return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { - return ds.batchSetMDMAppleProfilesDB(ctx, tx, tmID, profiles) + _, err := ds.batchSetMDMAppleProfilesDB(ctx, tx, tmID, profiles) + return err }) } @@ -1454,7 +1456,7 @@ func (ds *Datastore) batchSetMDMAppleProfilesDB( tx sqlx.ExtContext, tmID *uint, profiles []*fleet.MDMAppleConfigProfile, -) error { +) (updatedDB bool, err error) { const loadExistingProfiles = ` SELECT identifier, @@ -1516,13 +1518,13 @@ ON DUPLICATE KEY UPDATE if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return ctxerr.Wrap(ctx, err, "build query to load existing profiles") + return false, ctxerr.Wrap(ctx, err, "build query to load existing profiles") } if err := sqlx.SelectContext(ctx, tx, &existingProfiles, stmt, args...); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "select") { if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return ctxerr.Wrap(ctx, err, "load existing profiles") + return false, ctxerr.Wrap(ctx, err, "load existing profiles") } } @@ -1543,31 +1545,37 @@ ON DUPLICATE KEY UPDATE var ( stmt string args []interface{} - err error ) // delete the obsolete profiles (all those that are not in keepIdents or delivered by Fleet) + var result sql.Result stmt, args, err = sqlx.In(deleteProfilesNotInList, profTeamID, append(keepIdents, fleetIdents...)) if err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "indelete") { if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return ctxerr.Wrap(ctx, err, "build statement to delete obsolete profiles") + return false, ctxerr.Wrap(ctx, err, "build statement to delete obsolete profiles") } - if _, err := tx.ExecContext(ctx, stmt, args...); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "delete") { + if result, err = tx.ExecContext(ctx, stmt, args...); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "delete") { if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return ctxerr.Wrap(ctx, err, "delete obsolete profiles") + return false, ctxerr.Wrap(ctx, err, "delete obsolete profiles") + } + if result != nil { + rows, _ := result.RowsAffected() + updatedDB = rows > 0 } // insert the new profiles and the ones that have changed for _, p := range incomingProfs { - if _, err := tx.ExecContext(ctx, insertNewOrEditedProfile, profTeamID, p.Identifier, p.Name, p.Mobileconfig); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "insert") { + if result, err = tx.ExecContext(ctx, insertNewOrEditedProfile, profTeamID, p.Identifier, p.Name, + p.Mobileconfig); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "insert") { if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return ctxerr.Wrapf(ctx, err, "insert new/edited profile with identifier %q", p.Identifier) + return false, ctxerr.Wrapf(ctx, err, "insert new/edited profile with identifier %q", p.Identifier) } + updatedDB = updatedDB || insertOnDuplicateDidInsertOrUpdate(result) } // build a list of labels so the associations can be batch-set all at once @@ -1583,19 +1591,19 @@ ON DUPLICATE KEY UPDATE if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return ctxerr.Wrap(ctx, err, "build query to load newly inserted profiles") + return false, ctxerr.Wrap(ctx, err, "build query to load newly inserted profiles") } if err := sqlx.SelectContext(ctx, tx, &newlyInsertedProfs, stmt, args...); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "reselect") { if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return ctxerr.Wrap(ctx, err, "load newly inserted profiles") + return false, ctxerr.Wrap(ctx, err, "load newly inserted profiles") } for _, newlyInsertedProf := range newlyInsertedProfs { incomingProf, ok := incomingProfs[newlyInsertedProf.Identifier] if !ok { - return ctxerr.Wrapf(ctx, err, "profile %q is in the database but was not incoming", newlyInsertedProf.Identifier) + return false, ctxerr.Wrapf(ctx, err, "profile %q is in the database but was not incoming", newlyInsertedProf.Identifier) } for _, label := range incomingProf.LabelsIncludeAll { @@ -1611,13 +1619,15 @@ ON DUPLICATE KEY UPDATE } // insert label associations - if err := batchSetProfileLabelAssociationsDB(ctx, tx, incomingLabels, "darwin"); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "labels") { + var updatedLabels bool + if updatedLabels, err = batchSetProfileLabelAssociationsDB(ctx, tx, incomingLabels, + "darwin"); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "labels") { if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return ctxerr.Wrap(ctx, err, "inserting apple profile label associations") + return false, ctxerr.Wrap(ctx, err, "inserting apple profile label associations") } - return nil + return updatedDB || updatedLabels, nil } func (ds *Datastore) BulkDeleteMDMAppleHostsConfigProfiles(ctx context.Context, profs []*fleet.MDMAppleProfilePayload) error { @@ -1682,9 +1692,9 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB( ctx context.Context, tx sqlx.ExtContext, uuids []string, -) error { +) (updatedDB bool, err error) { if len(uuids) == 0 { - return nil + return false, nil } appleMDMProfilesDesiredStateQuery := generateDesiredStateQuery("profile") @@ -1752,13 +1762,14 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB( stmt, args, err := sqlx.In(toInstallStmt, batchUUIDs, batchUUIDs, batchUUIDs, fleet.MDMOperationTypeRemove) if err != nil { - return ctxerr.Wrapf(ctx, err, "building statement to select profiles to install, batch %d of %d", i, selectProfilesTotalBatches) + return false, ctxerr.Wrapf(ctx, err, "building statement to select profiles to install, batch %d of %d", i, + selectProfilesTotalBatches) } var partialResult []*fleet.MDMAppleProfilePayload err = sqlx.SelectContext(ctx, tx, &partialResult, stmt, args...) if err != nil { - return ctxerr.Wrapf(ctx, err, "selecting profiles to install, batch %d of %d", i, selectProfilesTotalBatches) + return false, ctxerr.Wrapf(ctx, err, "selecting profiles to install, batch %d of %d", i, selectProfilesTotalBatches) } wantedProfiles = append(wantedProfiles, partialResult...) @@ -1810,19 +1821,19 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB( stmt, args, err := sqlx.In(toRemoveStmt, batchUUIDs, batchUUIDs, batchUUIDs, batchUUIDs, fleet.MDMOperationTypeRemove) if err != nil { - return ctxerr.Wrap(ctx, err, "building profiles to remove statement") + return false, ctxerr.Wrap(ctx, err, "building profiles to remove statement") } var partialResult []*fleet.MDMAppleProfilePayload err = sqlx.SelectContext(ctx, tx, &partialResult, stmt, args...) if err != nil { - return ctxerr.Wrap(ctx, err, "fetching profiles to remove") + return false, ctxerr.Wrap(ctx, err, "fetching profiles to remove") } currentProfiles = append(currentProfiles, partialResult...) } if len(wantedProfiles) == 0 && len(currentProfiles) == 0 { - return nil + return false, nil } // delete all host profiles to start from a clean slate, new entries will be added next @@ -1831,8 +1842,11 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB( // // TODO part II(roberto): we found this call to be a major bottleneck during load testing // https://github.com/fleetdm/fleet/issues/21338 - if err := ds.bulkDeleteMDMAppleHostsConfigProfilesDB(ctx, tx, wantedProfiles); err != nil { - return ctxerr.Wrap(ctx, err, "bulk delete all profiles") + if len(wantedProfiles) > 0 { + if err := ds.bulkDeleteMDMAppleHostsConfigProfilesDB(ctx, tx, wantedProfiles); err != nil { + return false, ctxerr.Wrap(ctx, err, "bulk delete all profiles") + } + updatedDB = true } // profileIntersection tracks profilesToAdd ∩ profilesToRemove, this is used to avoid: @@ -1853,11 +1867,57 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB( hostProfilesToClean = append(hostProfilesToClean, p) } } - if err := ds.bulkDeleteMDMAppleHostsConfigProfilesDB(ctx, tx, hostProfilesToClean); err != nil { - return ctxerr.Wrap(ctx, err, "bulk delete profiles to clean") + if len(hostProfilesToClean) > 0 { + if err := ds.bulkDeleteMDMAppleHostsConfigProfilesDB(ctx, tx, hostProfilesToClean); err != nil { + return false, ctxerr.Wrap(ctx, err, "bulk delete profiles to clean") + } + updatedDB = true } + profilesToInsert := make(map[string]*fleet.MDMAppleProfilePayload) + executeUpsertBatch := func(valuePart string, args []any) error { + // Check if the update needs to be done at all. + selectStmt := fmt.Sprintf(` + SELECT + host_uuid, + profile_uuid, + profile_identifier, + status, + COALESCE(operation_type, '') AS operation_type, + COALESCE(detail, '') AS detail, + command_uuid, + profile_name, + checksum, + profile_uuid + FROM host_mdm_apple_profiles WHERE (host_uuid, profile_uuid) IN (%s)`, + strings.TrimSuffix(strings.Repeat("(?,?),", len(profilesToInsert)), ",")) + var selectArgs []any + for _, p := range profilesToInsert { + selectArgs = append(selectArgs, p.HostUUID, p.ProfileUUID) + } + var existingProfiles []fleet.MDMAppleProfilePayload + if err := sqlx.SelectContext(ctx, tx, &existingProfiles, selectStmt, selectArgs...); err != nil { + return ctxerr.Wrap(ctx, err, "bulk set pending profile status select existing") + } + var updateNeeded bool + if len(existingProfiles) == len(profilesToInsert) { + for _, exist := range existingProfiles { + insert, ok := profilesToInsert[fmt.Sprintf("%s\n%s", exist.HostUUID, exist.ProfileUUID)] + if !ok || !exist.Equal(*insert) { + updateNeeded = true + break + } + } + } else { + updateNeeded = true + } + if !updateNeeded { + // All profiles are already in the database, no need to update. + return nil + } + + updatedDB = true baseStmt := fmt.Sprintf(` INSERT INTO host_mdm_apple_profiles ( profile_uuid, @@ -1897,6 +1957,7 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB( resetBatch := func() { batchCount = 0 + clear(profilesToInsert) pargs = pargs[:0] psb.Reset() } @@ -1904,6 +1965,18 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB( for _, p := range wantedProfiles { if pp, ok := profileIntersection.GetMatchingProfileInCurrentState(p); ok { if pp.Status != &fleet.MDMDeliveryFailed && bytes.Equal(pp.Checksum, p.Checksum) { + profilesToInsert[fmt.Sprintf("%s\n%s", p.HostUUID, p.ProfileUUID)] = &fleet.MDMAppleProfilePayload{ + ProfileUUID: p.ProfileUUID, + ProfileIdentifier: p.ProfileIdentifier, + ProfileName: p.ProfileName, + HostUUID: p.HostUUID, + HostPlatform: p.HostPlatform, + Checksum: p.Checksum, + Status: pp.Status, + OperationType: pp.OperationType, + Detail: pp.Detail, + CommandUUID: pp.CommandUUID, + } pargs = append(pargs, p.ProfileUUID, p.HostUUID, p.ProfileIdentifier, p.ProfileName, p.Checksum, pp.OperationType, pp.Status, pp.CommandUUID, pp.Detail) psb.WriteString("(?, ?, ?, ?, ?, ?, ?, ?, ?),") @@ -1911,7 +1984,7 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB( if batchCount >= batchSize { if err := executeUpsertBatch(psb.String(), pargs); err != nil { - return err + return false, err } resetBatch() } @@ -1919,6 +1992,18 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB( } } + profilesToInsert[fmt.Sprintf("%s\n%s", p.HostUUID, p.ProfileUUID)] = &fleet.MDMAppleProfilePayload{ + ProfileUUID: p.ProfileUUID, + ProfileIdentifier: p.ProfileIdentifier, + ProfileName: p.ProfileName, + HostUUID: p.HostUUID, + HostPlatform: p.HostPlatform, + Checksum: p.Checksum, + OperationType: fleet.MDMOperationTypeInstall, + Status: nil, + CommandUUID: "", + Detail: "", + } pargs = append(pargs, p.ProfileUUID, p.HostUUID, p.ProfileIdentifier, p.ProfileName, p.Checksum, fleet.MDMOperationTypeInstall, nil, "", "") psb.WriteString("(?, ?, ?, ?, ?, ?, ?, ?, ?),") @@ -1926,7 +2011,7 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB( if batchCount >= batchSize { if err := executeUpsertBatch(psb.String(), pargs); err != nil { - return err + return false, err } resetBatch() } @@ -1943,6 +2028,18 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB( if p.FailedToInstallOnHost() { continue } + profilesToInsert[fmt.Sprintf("%s\n%s", p.HostUUID, p.ProfileUUID)] = &fleet.MDMAppleProfilePayload{ + ProfileUUID: p.ProfileUUID, + ProfileIdentifier: p.ProfileIdentifier, + ProfileName: p.ProfileName, + HostUUID: p.HostUUID, + HostPlatform: p.HostPlatform, + Checksum: p.Checksum, + OperationType: fleet.MDMOperationTypeRemove, + Status: nil, + CommandUUID: "", + Detail: "", + } pargs = append(pargs, p.ProfileUUID, p.HostUUID, p.ProfileIdentifier, p.ProfileName, p.Checksum, fleet.MDMOperationTypeRemove, nil, "", "") psb.WriteString("(?, ?, ?, ?, ?, ?, ?, ?, ?),") @@ -1950,7 +2047,7 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB( if batchCount >= batchSize { if err := executeUpsertBatch(psb.String(), pargs); err != nil { - return err + return false, err } resetBatch() } @@ -1958,10 +2055,10 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB( if batchCount > 0 { if err := executeUpsertBatch(psb.String(), pargs); err != nil { - return err + return false, err } } - return nil + return updatedDB, nil } // mdmEntityTypeToDynamicNames tracks what names should be used in the @@ -3405,7 +3502,7 @@ func (ds *Datastore) SetOrUpdateMDMAppleSetupAssistant(ctx context.Context, asst // because the updated_at update condition is too complex?), so at the moment // this clears the profile uuids at all times, even if the profile did not // change. - if insertOnDuplicateDidUpdate(res) { + if insertOnDuplicateDidInsertOrUpdate(res) { // profile was updated, need to clear the profile uuids if err := ds.SetMDMAppleSetupAssistantProfileUUID(ctx, asst.TeamID, "", ""); err != nil { return nil, ctxerr.Wrap(ctx, err, "clear mdm apple setup assistant profiles") @@ -3758,9 +3855,9 @@ WHERE // depCooldownPeriod is the waiting period following a failed DEP assign profile request for a host. const depCooldownPeriod = 1 * time.Hour // TODO: Make this a test config option? -func (ds *Datastore) ScreenDEPAssignProfileSerialsForCooldown(ctx context.Context, serials []string) (skipSerials []string, serialsByOrgName map[string][]string, err error) { +func (ds *Datastore) ScreenDEPAssignProfileSerialsForCooldown(ctx context.Context, serials []string) (skipSerialsByOrgName map[string][]string, serialsByOrgName map[string][]string, err error) { if len(serials) == 0 { - return skipSerials, serialsByOrgName, nil + return skipSerialsByOrgName, serialsByOrgName, nil } stmt := ` @@ -3795,19 +3892,20 @@ WHERE } serialsByOrgName = make(map[string][]string) + skipSerialsByOrgName = make(map[string][]string) for _, r := range rows { switch r.Status { case "assign": serialsByOrgName[r.ABMOrgName] = append(serialsByOrgName[r.ABMOrgName], r.HardwareSerial) case "skip": - skipSerials = append(skipSerials, r.HardwareSerial) + skipSerialsByOrgName[r.ABMOrgName] = append(skipSerialsByOrgName[r.ABMOrgName], r.HardwareSerial) default: return nil, nil, ctxerr.New(ctx, fmt.Sprintf("screen dep serials: %s unrecognized status: %s", r.HardwareSerial, r.Status)) } } - return skipSerials, serialsByOrgName, nil + return skipSerialsByOrgName, serialsByOrgName, nil } func (ds *Datastore) GetDEPAssignProfileExpiredCooldowns(ctx context.Context) (map[uint][]string, error) { @@ -3954,7 +4052,9 @@ WHERE h.uuid = ? return nil } -func (ds *Datastore) batchSetMDMAppleDeclarations(ctx context.Context, tx sqlx.ExtContext, tmID *uint, incomingDeclarations []*fleet.MDMAppleDeclaration) ([]*fleet.MDMAppleDeclaration, error) { +func (ds *Datastore) batchSetMDMAppleDeclarations(ctx context.Context, tx sqlx.ExtContext, tmID *uint, + incomingDeclarations []*fleet.MDMAppleDeclaration, +) (declarations []*fleet.MDMAppleDeclaration, updatedDB bool, err error) { const insertStmt = ` INSERT INTO mdm_apple_declarations ( declaration_uuid, @@ -4021,13 +4121,13 @@ WHERE if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return nil, ctxerr.Wrap(ctx, err, "build query to load existing declarations") + return nil, false, ctxerr.Wrap(ctx, err, "build query to load existing declarations") } if err := sqlx.SelectContext(ctx, tx, &existingDecls, stmt, args...); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "select") { if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return nil, ctxerr.Wrap(ctx, err, "load existing declarations") + return nil, false, ctxerr.Wrap(ctx, err, "load existing declarations") } } @@ -4050,23 +4150,29 @@ WHERE // delete the obsolete declarations (all those that are not in keepNames) stmt, args, err := sqlx.In(fmt.Sprintf(fmtDeleteStmt, andNameNotInList), declTeamID, keepNames) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "build query to delete obsolete profiles") + return nil, false, ctxerr.Wrap(ctx, err, "build query to delete obsolete profiles") } delStmt = stmt delArgs = args } - if _, err := tx.ExecContext(ctx, delStmt, delArgs...); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "delete") { + var result sql.Result + if result, err = tx.ExecContext(ctx, delStmt, delArgs...); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, + "delete") { if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return nil, ctxerr.Wrap(ctx, err, "delete obsolete declarations") + return nil, false, ctxerr.Wrap(ctx, err, "delete obsolete declarations") + } + if result != nil { + rows, _ := result.RowsAffected() + updatedDB = rows > 0 } for _, d := range incomingDeclarations { checksum := md5ChecksumScriptContent(string(d.RawJSON)) declUUID := fleet.MDMAppleDeclarationUUIDPrefix + uuid.NewString() - if _, err := tx.ExecContext(ctx, insertStmt, + if result, err = tx.ExecContext(ctx, insertStmt, declUUID, d.Identifier, d.Name, @@ -4076,8 +4182,9 @@ WHERE if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return nil, ctxerr.Wrapf(ctx, err, "insert new/edited declaration with identifier %q", d.Identifier) + return nil, false, ctxerr.Wrapf(ctx, err, "insert new/edited declaration with identifier %q", d.Identifier) } + updatedDB = updatedDB || insertOnDuplicateDidInsertOrUpdate(result) } incomingLabels := []fleet.ConfigurationProfileLabel{} @@ -4092,16 +4199,16 @@ WHERE // optimization for a later iteration. stmt, args, err := sqlx.In(loadExistingDecls, declTeamID, incomingNames) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "build query to load newly inserted declarations") + return nil, false, ctxerr.Wrap(ctx, err, "build query to load newly inserted declarations") } if err := sqlx.SelectContext(ctx, tx, &newlyInsertedDecls, stmt, args...); err != nil { - return nil, ctxerr.Wrap(ctx, err, "load newly inserted declarations") + return nil, false, ctxerr.Wrap(ctx, err, "load newly inserted declarations") } for _, newlyInsertedDecl := range newlyInsertedDecls { incomingDecl, ok := incomingDecls[newlyInsertedDecl.Name] if !ok { - return nil, ctxerr.Wrapf(ctx, err, "declaration %q is in the database but was not incoming", newlyInsertedDecl.Name) + return nil, false, ctxerr.Wrapf(ctx, err, "declaration %q is in the database but was not incoming", newlyInsertedDecl.Name) } for _, label := range incomingDecl.LabelsIncludeAll { @@ -4116,14 +4223,16 @@ WHERE } } - if err := batchSetDeclarationLabelAssociationsDB(ctx, tx, incomingLabels); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "labels") { + var updatedLabels bool + if updatedLabels, err = batchSetDeclarationLabelAssociationsDB(ctx, tx, + incomingLabels); err != nil || strings.HasPrefix(ds.testBatchSetMDMAppleProfilesErr, "labels") { if err == nil { err = errors.New(ds.testBatchSetMDMAppleProfilesErr) } - return nil, ctxerr.Wrap(ctx, err, "inserting apple declaration label associations") + return nil, false, ctxerr.Wrap(ctx, err, "inserting apple declaration label associations") } - return incomingDeclarations, nil + return incomingDeclarations, updatedDB || updatedLabels, nil } func (ds *Datastore) NewMDMAppleDeclaration(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) { @@ -4220,7 +4329,7 @@ func (ds *Datastore) insertOrUpsertMDMAppleDeclaration(ctx context.Context, insO declaration.LabelsExcludeAny[i].Exclude = true labels = append(labels, declaration.LabelsExcludeAny[i]) } - if err := batchSetDeclarationLabelAssociationsDB(ctx, tx, labels); err != nil { + if _, err := batchSetDeclarationLabelAssociationsDB(ctx, tx, labels); err != nil { return ctxerr.Wrap(ctx, err, "inserting mdm declaration label associations") } @@ -4234,9 +4343,10 @@ func (ds *Datastore) insertOrUpsertMDMAppleDeclaration(ctx context.Context, insO return declaration, nil } -func batchSetDeclarationLabelAssociationsDB(ctx context.Context, tx sqlx.ExtContext, declarationLabels []fleet.ConfigurationProfileLabel) error { +func batchSetDeclarationLabelAssociationsDB(ctx context.Context, tx sqlx.ExtContext, + declarationLabels []fleet.ConfigurationProfileLabel) (updatedDB bool, err error) { if len(declarationLabels) == 0 { - return nil + return false, nil } // delete any profile+label tuple that is NOT in the list of provided tuples @@ -4258,38 +4368,72 @@ func batchSetDeclarationLabelAssociationsDB(ctx context.Context, tx sqlx.ExtCont exclude = VALUES(exclude) ` + selectStmt := ` + SELECT apple_declaration_uuid as profile_uuid, label_name, label_id, exclude FROM mdm_declaration_labels + WHERE (apple_declaration_uuid, label_name) IN (%s) + ` + var ( - insertBuilder strings.Builder - deleteBuilder strings.Builder - insertParams []any - deleteParams []any + insertBuilder strings.Builder + selectOrDeleteBuilder strings.Builder + selectParams []any + insertParams []any + deleteParams []any setProfileUUIDs = make(map[string]struct{}) + labelsToInsert = make(map[string]*fleet.ConfigurationProfileLabel, len(declarationLabels)) ) for i, pl := range declarationLabels { + labelsToInsert[fmt.Sprintf("%s\n%s", pl.ProfileUUID, pl.LabelName)] = &declarationLabels[i] if i > 0 { insertBuilder.WriteString(",") - deleteBuilder.WriteString(",") + selectOrDeleteBuilder.WriteString(",") } insertBuilder.WriteString("(?, ?, ?, ?)") - deleteBuilder.WriteString("(?, ?)") + selectOrDeleteBuilder.WriteString("(?, ?)") + selectParams = append(selectParams, pl.ProfileUUID, pl.LabelName) insertParams = append(insertParams, pl.ProfileUUID, pl.LabelID, pl.LabelName, pl.Exclude) deleteParams = append(deleteParams, pl.ProfileUUID, pl.LabelID) setProfileUUIDs[pl.ProfileUUID] = struct{}{} } - _, err := tx.ExecContext(ctx, fmt.Sprintf(upsertStmt, insertBuilder.String()), insertParams...) + // Determine if we need to update the database + var existingProfileLabels []fleet.ConfigurationProfileLabel + err = sqlx.SelectContext(ctx, tx, &existingProfileLabels, + fmt.Sprintf(selectStmt, selectOrDeleteBuilder.String()), selectParams...) if err != nil { - if isChildForeignKeyError(err) { - // one of the provided labels doesn't exist - return foreignKey("mdm_declaration_labels", fmt.Sprintf("(declaration, label)=(%v)", insertParams)) + return false, ctxerr.Wrap(ctx, err, "selecting existing profile labels") + } + + updateNeeded := false + if len(existingProfileLabels) == len(labelsToInsert) { + for _, existing := range existingProfileLabels { + toInsert, ok := labelsToInsert[fmt.Sprintf("%s\n%s", existing.ProfileUUID, existing.LabelName)] + // The fleet.ConfigurationProfileLabel struct has no pointers, so we can use standard cmp.Equal + if !ok || !cmp.Equal(existing, *toInsert) { + updateNeeded = true + break + } } + } else { + updateNeeded = true + } + + if updateNeeded { + _, err = tx.ExecContext(ctx, fmt.Sprintf(upsertStmt, insertBuilder.String()), insertParams...) + if err != nil { + if isChildForeignKeyError(err) { + // one of the provided labels doesn't exist + return false, foreignKey("mdm_declaration_labels", fmt.Sprintf("(declaration, label)=(%v)", insertParams)) + } - return ctxerr.Wrap(ctx, err, "setting label associations for declarations") + return false, ctxerr.Wrap(ctx, err, "setting label associations for declarations") + } + updatedDB = true } - deleteStmt = fmt.Sprintf(deleteStmt, deleteBuilder.String()) + deleteStmt = fmt.Sprintf(deleteStmt, selectOrDeleteBuilder.String()) profUUIDs := make([]string, 0, len(setProfileUUIDs)) for k := range setProfileUUIDs { @@ -4299,13 +4443,21 @@ func batchSetDeclarationLabelAssociationsDB(ctx context.Context, tx sqlx.ExtCont deleteStmt, args, err := sqlx.In(deleteStmt, deleteArgs...) if err != nil { - return ctxerr.Wrap(ctx, err, "sqlx.In delete labels for declarations") + return false, ctxerr.Wrap(ctx, err, "sqlx.In delete labels for declarations") } - if _, err := tx.ExecContext(ctx, deleteStmt, args...); err != nil { - return ctxerr.Wrap(ctx, err, "deleting labels for declarations") + var result sql.Result + if result, err = tx.ExecContext(ctx, deleteStmt, args...); err != nil { + return false, ctxerr.Wrap(ctx, err, "deleting labels for declarations") + } + if result != nil { + rows, err := result.RowsAffected() + if err != nil { + return false, ctxerr.Wrap(ctx, err, "count rows affected by insert") + } + updatedDB = updatedDB || rows > 0 } - return nil + return updatedDB, nil } func (ds *Datastore) MDMAppleDDMDeclarationsToken(ctx context.Context, hostUUID string) (*fleet.MDMAppleDDMDeclarationsToken, error) { @@ -4392,23 +4544,24 @@ func (ds *Datastore) MDMAppleBatchSetHostDeclarationState(ctx context.Context) ( err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { var err error - uuids, err = mdmAppleBatchSetHostDeclarationStateDB(ctx, tx, batchSize, &fleet.MDMDeliveryPending) + uuids, _, err = mdmAppleBatchSetHostDeclarationStateDB(ctx, tx, batchSize, &fleet.MDMDeliveryPending) return err }) return uuids, ctxerr.Wrap(ctx, err, "upserting host declaration state") } -func mdmAppleBatchSetHostDeclarationStateDB(ctx context.Context, tx sqlx.ExtContext, batchSize int, status *fleet.MDMDeliveryStatus) ([]string, error) { +func mdmAppleBatchSetHostDeclarationStateDB(ctx context.Context, tx sqlx.ExtContext, batchSize int, + status *fleet.MDMDeliveryStatus) ([]string, bool, error) { // once all the declarations are in place, compute the desired state // and find which hosts need a DDM sync. changedDeclarations, err := mdmAppleGetHostsWithChangedDeclarationsDB(ctx, tx) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "find hosts with changed declarations") + return nil, false, ctxerr.Wrap(ctx, err, "find hosts with changed declarations") } if len(changedDeclarations) == 0 { - return []string{}, nil + return []string{}, false, nil } // a host might have more than one declaration to sync, we do this to @@ -4430,11 +4583,12 @@ func mdmAppleBatchSetHostDeclarationStateDB(ctx context.Context, tx sqlx.ExtCont // - support the DDM endpoints, which use data from the // `host_mdm_apple_declarations` table to compute which declarations to // serve - if err := mdmAppleBatchSetPendingHostDeclarationsDB(ctx, tx, batchSize, changedDeclarations, status); err != nil { - return nil, ctxerr.Wrap(ctx, err, "batch insert mdm apple host declarations") + var updatedDB bool + if updatedDB, err = mdmAppleBatchSetPendingHostDeclarationsDB(ctx, tx, batchSize, changedDeclarations, status); err != nil { + return nil, false, ctxerr.Wrap(ctx, err, "batch insert mdm apple host declarations") } - return uuids, nil + return uuids, updatedDB, nil } // mdmAppleBatchSetPendingHostDeclarationsDB tracks the current status of all @@ -4445,7 +4599,7 @@ func mdmAppleBatchSetPendingHostDeclarationsDB( batchSize int, changedDeclarations []*fleet.MDMAppleHostDeclaration, status *fleet.MDMDeliveryStatus, -) error { +) (updatedDB bool, err error) { baseStmt := ` INSERT INTO host_mdm_apple_declarations (host_uuid, status, operation_type, checksum, declaration_uuid, declaration_identifier, declaration_name) @@ -4457,7 +4611,50 @@ func mdmAppleBatchSetPendingHostDeclarationsDB( checksum = VALUES(checksum) ` + profilesToInsert := make(map[string]*fleet.MDMAppleHostDeclaration) + executeUpsertBatch := func(valuePart string, args []any) error { + // Check if the update needs to be done at all. + selectStmt := fmt.Sprintf(` + SELECT + host_uuid, + declaration_uuid, + status, + COALESCE(operation_type, '') AS operation_type, + COALESCE(detail, '') AS detail, + checksum, + declaration_uuid, + declaration_identifier, + declaration_name + FROM host_mdm_apple_declarations WHERE (host_uuid, declaration_uuid) IN (%s)`, + strings.TrimSuffix(strings.Repeat("(?,?),", len(profilesToInsert)), ",")) + var selectArgs []any + for _, p := range profilesToInsert { + selectArgs = append(selectArgs, p.HostUUID, p.DeclarationUUID) + } + var existingProfiles []fleet.MDMAppleHostDeclaration + if err := sqlx.SelectContext(ctx, tx, &existingProfiles, selectStmt, selectArgs...); err != nil { + return ctxerr.Wrap(ctx, err, "bulk set pending declarations select existing") + } + var updateNeeded bool + if len(existingProfiles) == len(profilesToInsert) { + for _, exist := range existingProfiles { + insert, ok := profilesToInsert[fmt.Sprintf("%s\n%s", exist.HostUUID, exist.DeclarationUUID)] + if !ok || !exist.Equal(*insert) { + updateNeeded = true + break + } + } + } else { + updateNeeded = true + } + clear(profilesToInsert) + if !updateNeeded { + // All profiles are already in the database, no need to update. + return nil + } + + updatedDB = true _, err := tx.ExecContext( ctx, fmt.Sprintf(baseStmt, strings.TrimSuffix(valuePart, ",")), @@ -4467,13 +4664,23 @@ func mdmAppleBatchSetPendingHostDeclarationsDB( } generateValueArgs := func(d *fleet.MDMAppleHostDeclaration) (string, []any) { + profilesToInsert[fmt.Sprintf("%s\n%s", d.HostUUID, d.DeclarationUUID)] = &fleet.MDMAppleHostDeclaration{ + HostUUID: d.HostUUID, + DeclarationUUID: d.DeclarationUUID, + Name: d.Name, + Identifier: d.Identifier, + Status: status, + OperationType: d.OperationType, + Detail: d.Detail, + Checksum: d.Checksum, + } valuePart := "(?, ?, ?, ?, ?, ?, ?)," args := []any{d.HostUUID, status, d.OperationType, d.Checksum, d.DeclarationUUID, d.Identifier, d.Name} return valuePart, args } - err := batchProcessDB(changedDeclarations, batchSize, generateValueArgs, executeUpsertBatch) - return ctxerr.Wrap(ctx, err, "inserting changed host declaration state") + err = batchProcessDB(changedDeclarations, batchSize, generateValueArgs, executeUpsertBatch) + return updatedDB, ctxerr.Wrap(ctx, err, "inserting changed host declaration state") } // mdmAppleGetHostsWithChangedDeclarationsDB returns a @@ -4971,6 +5178,7 @@ SELECT abt.apple_id, abt.terms_expired, abt.renew_at, + abt.token, abt.macos_default_team_id, abt.ios_default_team_id, abt.ipados_default_team_id, @@ -5027,6 +5235,15 @@ LEFT OUTER JOIN tok.IOSTeam = fleet.ABMTokenTeam{Name: tok.IOSTeamName, ID: iOSTeamID} tok.IPadOSTeam = fleet.ABMTokenTeam{Name: tok.IPadOSTeamName, ID: iPadIOSTeamID} + // decrypt the token with the serverPrivateKey, the resulting value will be + // the token still encrypted, but just with the ABM cert and key (it is that + // encrypted value that is stored with another layer of encryption with the + // serverPrivateKey). + decrypted, err := decrypt(tok.EncryptedToken, ds.serverPrivateKey) + if err != nil { + return nil, ctxerr.Wrapf(ctx, err, "decrypting abm token with datastore.serverPrivateKey") + } + tok.EncryptedToken = decrypted } return tokens, nil diff --git a/server/datastore/mysql/apple_mdm_test.go b/server/datastore/mysql/apple_mdm_test.go index 389d76e5cb6f..aba31a3c8ebf 100644 --- a/server/datastore/mysql/apple_mdm_test.go +++ b/server/datastore/mysql/apple_mdm_test.go @@ -1058,7 +1058,9 @@ func expectAppleDeclarations( var got []*fleet.MDMAppleDeclaration ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { ctx := context.Background() - return sqlx.SelectContext(ctx, q, &got, `SELECT * FROM mdm_apple_declarations WHERE team_id = ?`, tmID) + return sqlx.SelectContext(ctx, q, &got, + `SELECT declaration_uuid, team_id, identifier, name, raw_json, checksum, created_at, uploaded_at FROM mdm_apple_declarations WHERE team_id = ?`, + tmID) }) // create map of expected declarations keyed by identifier @@ -4862,8 +4864,11 @@ func testMDMAppleDDMDeclarationsToken(t *testing.T, ds *Datastore) { Name: "decl-1", }) require.NoError(t, err) - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl.DeclarationUUID}, nil) + updates, err := ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl.DeclarationUUID}, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.AppleDeclaration) + assert.False(t, updates.WindowsConfigProfile) toks, err = ds.MDMAppleDDMDeclarationsToken(ctx, "not-exists") require.NoError(t, err) @@ -4880,8 +4885,11 @@ func testMDMAppleDDMDeclarationsToken(t *testing.T, ds *Datastore) { }) require.NoError(t, err) nanoEnroll(t, ds, host1, true) - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl.DeclarationUUID}, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl.DeclarationUUID}, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.True(t, updates.AppleDeclaration) + assert.False(t, updates.WindowsConfigProfile) toks, err = ds.MDMAppleDDMDeclarationsToken(ctx, host1.UUID) require.NoError(t, err) @@ -4894,8 +4902,11 @@ func testMDMAppleDDMDeclarationsToken(t *testing.T, ds *Datastore) { Name: "decl-2", }) require.NoError(t, err) - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl2.DeclarationUUID}, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl2.DeclarationUUID}, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.True(t, updates.AppleDeclaration) + assert.False(t, updates.WindowsConfigProfile) toks, err = ds.MDMAppleDDMDeclarationsToken(ctx, host1.UUID) require.NoError(t, err) @@ -4906,8 +4917,11 @@ func testMDMAppleDDMDeclarationsToken(t *testing.T, ds *Datastore) { err = ds.DeleteMDMAppleConfigProfile(ctx, decl.DeclarationUUID) require.NoError(t, err) - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl2.DeclarationUUID}, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl2.DeclarationUUID}, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.True(t, updates.AppleDeclaration) + assert.False(t, updates.WindowsConfigProfile) toks, err = ds.MDMAppleDDMDeclarationsToken(ctx, host1.UUID) require.NoError(t, err) @@ -6085,8 +6099,11 @@ func testMDMAppleProfilesOnIOSIPadOS(t *testing.T, ds *Datastore) { someProfile, err := ds.NewMDMAppleConfigProfile(ctx, *generateCP("a", "a", 0)) require.NoError(t, err) - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{0}, nil, nil) + updates, err := ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{0}, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.False(t, updates.AppleDeclaration) + assert.False(t, updates.WindowsConfigProfile) profiles, err := ds.GetHostMDMAppleProfiles(ctx, "iOS0_UUID") require.NoError(t, err) diff --git a/server/datastore/mysql/calendar_events.go b/server/datastore/mysql/calendar_events.go index af8200b2b05c..21004cf83a08 100644 --- a/server/datastore/mysql/calendar_events.go +++ b/server/datastore/mysql/calendar_events.go @@ -64,7 +64,7 @@ func (ds *Datastore) CreateOrUpdateCalendarEvent( return ctxerr.Wrap(ctx, err, "insert calendar event") } - if insertOnDuplicateDidInsert(result) { + if insertOnDuplicateDidInsertOrUpdate(result) { id, _ = result.LastInsertId() } else { stmt := `SELECT id FROM calendar_events WHERE email = ?` diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go index a1008853e365..2edc5ab39353 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -6751,6 +6751,7 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) { InstallScript: "", PreInstallQuery: "", Title: "ChocolateRain", + UserID: user1.ID, }) require.NoError(t, err) _, err = ds.InsertSoftwareInstallRequest(context.Background(), host.ID, softwareInstaller, false) diff --git a/server/datastore/mysql/mdm.go b/server/datastore/mysql/mdm.go index eefce6091dd5..d3bbba82de31 100644 --- a/server/datastore/mysql/mdm.go +++ b/server/datastore/mysql/mdm.go @@ -13,6 +13,7 @@ import ( "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft" "github.com/go-kit/log/level" + "github.com/google/go-cmp/cmp" "github.com/jmoiron/sqlx" ) @@ -121,22 +122,26 @@ func (ds *Datastore) getMDMCommand(ctx context.Context, q sqlx.QueryerContext, c return &cmd, nil } -func (ds *Datastore) BatchSetMDMProfiles(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDeclarations []*fleet.MDMAppleDeclaration) error { - return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { - if err := ds.batchSetMDMWindowsProfilesDB(ctx, tx, tmID, winProfiles); err != nil { +func (ds *Datastore) BatchSetMDMProfiles(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, + winProfiles []*fleet.MDMWindowsConfigProfile, macDeclarations []*fleet.MDMAppleDeclaration) (updates fleet.MDMProfilesUpdates, + err error) { + err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + var err error + if updates.WindowsConfigProfile, err = ds.batchSetMDMWindowsProfilesDB(ctx, tx, tmID, winProfiles); err != nil { return ctxerr.Wrap(ctx, err, "batch set windows profiles") } - if err := ds.batchSetMDMAppleProfilesDB(ctx, tx, tmID, macProfiles); err != nil { + if updates.AppleConfigProfile, err = ds.batchSetMDMAppleProfilesDB(ctx, tx, tmID, macProfiles); err != nil { return ctxerr.Wrap(ctx, err, "batch set apple profiles") } - if _, err := ds.batchSetMDMAppleDeclarations(ctx, tx, tmID, macDeclarations); err != nil { + if _, updates.AppleDeclaration, err = ds.batchSetMDMAppleDeclarations(ctx, tx, tmID, macDeclarations); err != nil { return ctxerr.Wrap(ctx, err, "batch set apple declarations") } return nil }) + return updates, err } func (ds *Datastore) ListMDMConfigProfiles(ctx context.Context, teamID *uint, opt fleet.ListOptions) ([]*fleet.MDMConfigProfilePayload, *fleet.PaginationMetadata, error) { @@ -335,10 +340,12 @@ func (ds *Datastore) BulkSetPendingMDMHostProfiles( ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string, -) error { - return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { - return ds.bulkSetPendingMDMHostProfilesDB(ctx, tx, hostIDs, teamIDs, profileUUIDs, hostUUIDs) +) (updates fleet.MDMProfilesUpdates, err error) { + err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + updates, err = ds.bulkSetPendingMDMHostProfilesDB(ctx, tx, hostIDs, teamIDs, profileUUIDs, hostUUIDs) + return err }) + return updates, err } // Note that team ID 0 is used for profiles that apply to hosts in no team @@ -349,7 +356,7 @@ func (ds *Datastore) bulkSetPendingMDMHostProfilesDB( tx sqlx.ExtContext, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string, -) error { +) (updates fleet.MDMProfilesUpdates, err error) { var ( countArgs int macProfUUIDs []string @@ -384,10 +391,10 @@ func (ds *Datastore) bulkSetPendingMDMHostProfilesDB( countArgs++ } if countArgs > 1 { - return errors.New("only one of hostIDs, teamIDs, profileUUIDs or hostUUIDs can be provided") + return updates, errors.New("only one of hostIDs, teamIDs, profileUUIDs or hostUUIDs can be provided") } if countArgs == 0 { - return nil + return updates, nil } var countProfUUIDs int @@ -401,7 +408,7 @@ func (ds *Datastore) bulkSetPendingMDMHostProfilesDB( countProfUUIDs++ } if countProfUUIDs > 1 { - return errors.New("profile uuids must be all Apple profiles, all Apple declarations, or all Windows profiles") + return updates, errors.New("profile uuids must be all Apple profiles, all Apple declarations, or all Windows profiles") } var ( @@ -471,10 +478,10 @@ WHERE if len(hosts) == 0 && !hasAppleDecls { uuidStmt, args, err := sqlx.In(uuidStmt, args...) if err != nil { - return ctxerr.Wrap(ctx, err, "prepare query to load host UUIDs") + return updates, ctxerr.Wrap(ctx, err, "prepare query to load host UUIDs") } if err := sqlx.SelectContext(ctx, tx, &hosts, uuidStmt, args...); err != nil { - return ctxerr.Wrap(ctx, err, "execute query to load host UUIDs") + return updates, ctxerr.Wrap(ctx, err, "execute query to load host UUIDs") } } @@ -495,12 +502,14 @@ WHERE } } - if err := ds.bulkSetPendingMDMAppleHostProfilesDB(ctx, tx, appleHosts); err != nil { - return ctxerr.Wrap(ctx, err, "bulk set pending apple host profiles") + updates.AppleConfigProfile, err = ds.bulkSetPendingMDMAppleHostProfilesDB(ctx, tx, appleHosts) + if err != nil { + return updates, ctxerr.Wrap(ctx, err, "bulk set pending apple host profiles") } - if err := ds.bulkSetPendingMDMWindowsHostProfilesDB(ctx, tx, winHosts); err != nil { - return ctxerr.Wrap(ctx, err, "bulk set pending windows host profiles") + updates.WindowsConfigProfile, err = ds.bulkSetPendingMDMWindowsHostProfilesDB(ctx, tx, winHosts) + if err != nil { + return updates, ctxerr.Wrap(ctx, err, "bulk set pending windows host profiles") } const defaultBatchSize = 1000 @@ -513,11 +522,12 @@ WHERE // (and my hunch is that we could even do the same for // profiles) but this could be optimized to use only a provided // set of host uuids. - if _, err := mdmAppleBatchSetHostDeclarationStateDB(ctx, tx, batchSize, nil); err != nil { - return ctxerr.Wrap(ctx, err, "bulk set pending apple declarations") + _, updates.AppleDeclaration, err = mdmAppleBatchSetHostDeclarationStateDB(ctx, tx, batchSize, nil) + if err != nil { + return updates, ctxerr.Wrap(ctx, err, "bulk set pending apple declarations") } - return nil + return updates, nil } func (ds *Datastore) UpdateHostMDMProfilesVerification(ctx context.Context, host *fleet.Host, toVerify, toFail, toRetry []string) error { @@ -984,9 +994,9 @@ func batchSetProfileLabelAssociationsDB( tx sqlx.ExtContext, profileLabels []fleet.ConfigurationProfileLabel, platform string, -) error { +) (updatedDB bool, err error) { if len(profileLabels) == 0 { - return nil + return false, nil } var platformPrefix string @@ -1001,7 +1011,7 @@ func batchSetProfileLabelAssociationsDB( case "windows": platformPrefix = "windows" default: - return fmt.Errorf("unsupported platform %s", platform) + return false, fmt.Errorf("unsupported platform %s", platform) } // delete any profile+label tuple that is NOT in the list of provided tuples @@ -1023,38 +1033,72 @@ func batchSetProfileLabelAssociationsDB( exclude = VALUES(exclude) ` + selectStmt := ` + SELECT %s_profile_uuid as profile_uuid, label_id, label_name, exclude FROM mdm_configuration_profile_labels + WHERE (%s_profile_uuid, label_name) IN (%s) + ` + var ( - insertBuilder strings.Builder - deleteBuilder strings.Builder - insertParams []any - deleteParams []any + insertBuilder strings.Builder + selectOrDeleteBuilder strings.Builder + selectParams []any + insertParams []any + deleteParams []any setProfileUUIDs = make(map[string]struct{}) ) + labelsToInsert := make(map[string]*fleet.ConfigurationProfileLabel, len(profileLabels)) for i, pl := range profileLabels { + labelsToInsert[fmt.Sprintf("%s\n%s", pl.ProfileUUID, pl.LabelName)] = &profileLabels[i] if i > 0 { insertBuilder.WriteString(",") - deleteBuilder.WriteString(",") + selectOrDeleteBuilder.WriteString(",") } insertBuilder.WriteString("(?, ?, ?, ?)") - deleteBuilder.WriteString("(?, ?)") + selectOrDeleteBuilder.WriteString("(?, ?)") + selectParams = append(selectParams, pl.ProfileUUID, pl.LabelName) insertParams = append(insertParams, pl.ProfileUUID, pl.LabelID, pl.LabelName, pl.Exclude) deleteParams = append(deleteParams, pl.ProfileUUID, pl.LabelID) setProfileUUIDs[pl.ProfileUUID] = struct{}{} } - _, err := tx.ExecContext(ctx, fmt.Sprintf(upsertStmt, platformPrefix, insertBuilder.String()), insertParams...) + // Determine if we need to update the database + var existingProfileLabels []fleet.ConfigurationProfileLabel + err = sqlx.SelectContext(ctx, tx, &existingProfileLabels, + fmt.Sprintf(selectStmt, platformPrefix, platformPrefix, selectOrDeleteBuilder.String()), selectParams...) if err != nil { - if isChildForeignKeyError(err) { - // one of the provided labels doesn't exist - return foreignKey("mdm_configuration_profile_labels", fmt.Sprintf("(profile, label)=(%v)", insertParams)) + return false, ctxerr.Wrap(ctx, err, "selecting existing profile labels") + } + + updateNeeded := false + if len(existingProfileLabels) == len(labelsToInsert) { + for _, existing := range existingProfileLabels { + toInsert, ok := labelsToInsert[fmt.Sprintf("%s\n%s", existing.ProfileUUID, existing.LabelName)] + // The fleet.ConfigurationProfileLabel struct has no pointers, so we can use standard cmp.Equal + if !ok || !cmp.Equal(existing, *toInsert) { + updateNeeded = true + break + } } + } else { + updateNeeded = true + } + + if updateNeeded { + _, err := tx.ExecContext(ctx, fmt.Sprintf(upsertStmt, platformPrefix, insertBuilder.String()), insertParams...) + if err != nil { + if isChildForeignKeyError(err) { + // one of the provided labels doesn't exist + return false, foreignKey("mdm_configuration_profile_labels", fmt.Sprintf("(profile, label)=(%v)", insertParams)) + } - return ctxerr.Wrap(ctx, err, "setting label associations for profile") + return false, ctxerr.Wrap(ctx, err, "setting label associations for profile") + } + updatedDB = true } - deleteStmt = fmt.Sprintf(deleteStmt, platformPrefix, deleteBuilder.String(), platformPrefix) + deleteStmt = fmt.Sprintf(deleteStmt, platformPrefix, selectOrDeleteBuilder.String(), platformPrefix) profUUIDs := make([]string, 0, len(setProfileUUIDs)) for k := range setProfileUUIDs { @@ -1064,13 +1108,18 @@ func batchSetProfileLabelAssociationsDB( deleteStmt, args, err := sqlx.In(deleteStmt, deleteArgs...) if err != nil { - return ctxerr.Wrap(ctx, err, "sqlx.In delete labels for profiles") + return false, ctxerr.Wrap(ctx, err, "sqlx.In delete labels for profiles") } - if _, err := tx.ExecContext(ctx, deleteStmt, args...); err != nil { - return ctxerr.Wrap(ctx, err, "deleting labels for profiles") + var result sql.Result + if result, err = tx.ExecContext(ctx, deleteStmt, args...); err != nil { + return false, ctxerr.Wrap(ctx, err, "deleting labels for profiles") + } + if result != nil { + rows, _ := result.RowsAffected() + updatedDB = updatedDB || rows > 0 } - return nil + return updatedDB, nil } func (ds *Datastore) MDMGetEULAMetadata(ctx context.Context) (*fleet.MDMEULA, error) { diff --git a/server/datastore/mysql/mdm_test.go b/server/datastore/mysql/mdm_test.go index 2f0d29412114..a8d765bacaa4 100644 --- a/server/datastore/mysql/mdm_test.go +++ b/server/datastore/mysql/mdm_test.go @@ -21,6 +21,7 @@ import ( "github.com/fleetdm/fleet/v4/server/test" "github.com/google/uuid" "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -358,13 +359,15 @@ func testBatchSetMDMProfiles(t *testing.T, ds *Datastore) { wantApple []*fleet.MDMAppleConfigProfile, wantWindows []*fleet.MDMWindowsConfigProfile, wantAppleDecl []*fleet.MDMAppleDeclaration, + wantUpdates fleet.MDMProfilesUpdates, ) { ctx := context.Background() - err := ds.BatchSetMDMProfiles(ctx, tmID, newAppleSet, newWindowsSet, newAppleDeclSet) + updates, err := ds.BatchSetMDMProfiles(ctx, tmID, newAppleSet, newWindowsSet, newAppleDeclSet) require.NoError(t, err) expectAppleProfiles(t, ds, tmID, wantApple) expectWindowsProfiles(t, ds, tmID, wantWindows) expectAppleDeclarations(t, ds, tmID, wantAppleDecl) + assert.Equal(t, wantUpdates, updates) } withTeamIDApple := func(p *fleet.MDMAppleConfigProfile, tmID uint) *fleet.MDMAppleConfigProfile { @@ -383,7 +386,7 @@ func testBatchSetMDMProfiles(t *testing.T, ds *Datastore) { } // empty set for no team (both Apple and Windows) - applyAndExpect(nil, nil, nil, nil, nil, nil, nil) + applyAndExpect(nil, nil, nil, nil, nil, nil, nil, fleet.MDMProfilesUpdates{}) // single Apple and Windows profile set for a specific team applyAndExpect( @@ -398,6 +401,7 @@ func testBatchSetMDMProfiles(t *testing.T, ds *Datastore) { withTeamIDWindows(windowsConfigProfileForTest(t, "W1", "l1"), 1), }, []*fleet.MDMAppleDeclaration{withTeamIDDecl(declForTest("D1", "D1", "foo"), 1)}, + fleet.MDMProfilesUpdates{AppleConfigProfile: true, WindowsConfigProfile: true, AppleDeclaration: true}, ) // single Apple and Windows profile set for no team @@ -409,6 +413,7 @@ func testBatchSetMDMProfiles(t *testing.T, ds *Datastore) { []*fleet.MDMAppleConfigProfile{configProfileForTest(t, "N1", "I1", "a")}, []*fleet.MDMWindowsConfigProfile{windowsConfigProfileForTest(t, "W1", "l1")}, []*fleet.MDMAppleDeclaration{declForTest("D1", "D1", "foo")}, + fleet.MDMProfilesUpdates{AppleConfigProfile: true, WindowsConfigProfile: true, AppleDeclaration: true}, ) // new Apple and Windows profile sets for a specific team @@ -438,6 +443,7 @@ func testBatchSetMDMProfiles(t *testing.T, ds *Datastore) { withTeamIDDecl(declForTest("D1", "D1", "foo"), 1), withTeamIDDecl(declForTest("D2", "D2", "foo"), 1), }, + fleet.MDMProfilesUpdates{AppleConfigProfile: true, WindowsConfigProfile: true, AppleDeclaration: true}, ) // edited profiles, unchanged profiles, and new profiles for a specific team @@ -473,6 +479,7 @@ func testBatchSetMDMProfiles(t *testing.T, ds *Datastore) { withTeamIDDecl(declForTest("D2", "D2", "foo"), 1), withTeamIDDecl(declForTest("D3", "D3", "bar"), 1), }, + fleet.MDMProfilesUpdates{AppleConfigProfile: true, WindowsConfigProfile: true, AppleDeclaration: true}, ) // new Apple and Windows profiles to no team @@ -502,10 +509,43 @@ func testBatchSetMDMProfiles(t *testing.T, ds *Datastore) { declForTest("D5", "D4", "foo"), declForTest("D4", "D5", "foo"), }, + fleet.MDMProfilesUpdates{AppleConfigProfile: true, WindowsConfigProfile: true, AppleDeclaration: true}, + ) + + // Apply the same profiles again -- no update should be detected + applyAndExpect( + []*fleet.MDMAppleConfigProfile{ + configProfileForTest(t, "N4", "I4", "d"), + configProfileForTest(t, "N5", "I5", "e"), + }, + []*fleet.MDMWindowsConfigProfile{ + windowsConfigProfileForTest(t, "W4", "l4"), + windowsConfigProfileForTest(t, "W5", "l5"), + }, + []*fleet.MDMAppleDeclaration{ + declForTest("D5", "D4", "foo"), + declForTest("D4", "D5", "foo"), + }, + nil, + []*fleet.MDMAppleConfigProfile{ + configProfileForTest(t, "N4", "I4", "d"), + configProfileForTest(t, "N5", "I5", "e"), + }, + []*fleet.MDMWindowsConfigProfile{ + windowsConfigProfileForTest(t, "W4", "l4"), + windowsConfigProfileForTest(t, "W5", "l5"), + }, + []*fleet.MDMAppleDeclaration{ + declForTest("D5", "D4", "foo"), + declForTest("D4", "D5", "foo"), + }, + fleet.MDMProfilesUpdates{AppleConfigProfile: false, WindowsConfigProfile: false, AppleDeclaration: false}, ) // Test Case 8: Clear profiles for a specific team - applyAndExpect(nil, nil, nil, ptr.Uint(1), nil, nil, nil) + applyAndExpect(nil, nil, nil, ptr.Uint(1), nil, nil, nil, + fleet.MDMProfilesUpdates{AppleConfigProfile: true, WindowsConfigProfile: true, AppleDeclaration: true}, + ) } func testListMDMConfigProfiles(t *testing.T, ds *Datastore) { @@ -1063,17 +1103,24 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { } // bulk set for no target ids, does nothing - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, nil, nil) + updates, err := ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, nil, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.AppleDeclaration) + assert.False(t, updates.WindowsConfigProfile) + // bulk set for combination of target ids, not allowed - err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{1}, []uint{2}, nil, nil) + _, err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{1}, []uint{2}, nil, nil) require.Error(t, err) // bulk set for all created hosts, no profiles yet so nothing changed allHosts := append(darwinHosts, unenrolledHost, linuxHost) allHosts = append(allHosts, windowsHosts...) - err = ds.BulkSetPendingMDMHostProfiles(ctx, hostIDsFromHosts(allHosts...), nil, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, hostIDsFromHosts(allHosts...), nil, nil, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.AppleDeclaration) + assert.False(t, updates.WindowsConfigProfile) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: {}, darwinHosts[1]: {}, @@ -1100,7 +1147,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { windowsConfigProfileForTest(t, "G2w", "L2"), windowsConfigProfileForTest(t, "G3w", "L3"), } - err = ds.BatchSetMDMProfiles( + updates, err = ds.BatchSetMDMProfiles( ctx, nil, macGlobalProfiles, @@ -1113,6 +1160,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { require.Len(t, macGlobalProfiles, 3) globalProfiles := getProfs(nil) require.Len(t, globalProfiles, 8) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.AppleDeclaration) + assert.True(t, updates.WindowsConfigProfile) // list profiles to install, should result in the global profiles for all // enrolled hosts @@ -1132,8 +1182,11 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { require.Len(t, toRemoveWindows, 0) // bulk set for all created hosts, enrolled hosts get the no-team profiles - err = ds.BulkSetPendingMDMHostProfiles(ctx, hostIDsFromHosts(allHosts...), nil, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, hostIDsFromHosts(allHosts...), nil, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.AppleDeclaration) + assert.True(t, updates.WindowsConfigProfile) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { { @@ -1311,7 +1364,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { require.Len(t, toRemoveWindows, 3) // update status of the moved host (team has no profiles) - err = ds.BulkSetPendingMDMHostProfiles( + updates, err = ds.BulkSetPendingMDMHostProfiles( ctx, hostIDsFromHosts(darwinHosts[0], windowsHosts[0]), nil, @@ -1319,6 +1372,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { nil, ) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.AppleDeclaration) + assert.True(t, updates.WindowsConfigProfile) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { { @@ -1482,7 +1538,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { require.Len(t, toRemoveWindows, 3) // update status of the moved host via its uuid (team has no profiles) - err = ds.BulkSetPendingMDMHostProfiles( + updates, err = ds.BulkSetPendingMDMHostProfiles( ctx, nil, nil, @@ -1490,6 +1546,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { []string{darwinHosts[1].UUID, windowsHosts[1].UUID}, ) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.AppleDeclaration) + assert.True(t, updates.WindowsConfigProfile) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { { @@ -1620,8 +1679,11 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { windowsConfigProfileForTest(t, "T1.1w", "T1.1"), windowsConfigProfileForTest(t, "T1.2w", "T1.2"), } - err = ds.BatchSetMDMProfiles(ctx, &team1.ID, tm1DarwinProfiles, tm1WindowsProfiles, nil) + updates, err = ds.BatchSetMDMProfiles(ctx, &team1.ID, tm1DarwinProfiles, tm1WindowsProfiles, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.False(t, updates.AppleDeclaration) + assert.True(t, updates.WindowsConfigProfile) tm1Profiles := getProfs(&team1.ID) require.Len(t, tm1Profiles, 4) @@ -1644,8 +1706,11 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { require.Len(t, toRemoveWindows, 0) // update status of the affected team - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team1.ID}, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team1.ID}, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.False(t, updates.AppleDeclaration) + assert.True(t, updates.WindowsConfigProfile) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { { @@ -1827,15 +1892,21 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { windowsConfigProfileForTest(t, "T1.3w", "T1.3"), } - err = ds.BatchSetMDMProfiles(ctx, &team1.ID, newTm1DarwinProfiles, newTm1WindowsProfiles, nil) + updates, err = ds.BatchSetMDMProfiles(ctx, &team1.ID, newTm1DarwinProfiles, newTm1WindowsProfiles, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) newTm1Profiles := getProfs(&team1.ID) require.Len(t, newTm1Profiles, 4) // update status of the affected team - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team1.ID}, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team1.ID}, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -1974,6 +2045,13 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { }, }) + // update again -- nothing should change + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team1.ID}, nil, nil) + require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) + // re-add tm1Profiles[0] to list of team1 profiles (T1.1 on Apple, T1.2 on Windows) // NOTE: even though it is the same profile, it's unique DB ID is different because // it got deleted and re-inserted from the team's profiles, so this is reflected in @@ -1989,14 +2067,20 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { windowsConfigProfileForTest(t, "T1.3w", "T1.3"), } - err = ds.BatchSetMDMProfiles(ctx, &team1.ID, newTm1DarwinProfiles, newTm1WindowsProfiles, nil) + updates, err = ds.BatchSetMDMProfiles(ctx, &team1.ID, newTm1DarwinProfiles, newTm1WindowsProfiles, nil) require.NoError(t, err) newTm1Profiles = getProfs(&team1.ID) require.Len(t, newTm1Profiles, 6) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) // update status of the affected team - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team1.ID}, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team1.ID}, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -2154,15 +2238,21 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { } // TODO(roberto): add new darwin declarations for this and all subsequent assertions - err = ds.BatchSetMDMProfiles(ctx, nil, newDarwinGlobalProfiles, newWindowsGlobalProfiles, nil) + updates, err = ds.BatchSetMDMProfiles(ctx, nil, newDarwinGlobalProfiles, newWindowsGlobalProfiles, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.True(t, updates.AppleDeclaration) newGlobalProfiles := getProfs(nil) require.Len(t, newGlobalProfiles, 6) // update status of the affected "no-team" - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{0}, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{0}, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.True(t, updates.AppleDeclaration) require.NoError(t, ds.MDMAppleStoreDDMStatusReport(ctx, darwinHosts[0].UUID, nil)) require.NoError(t, ds.MDMAppleStoreDDMStatusReport(ctx, darwinHosts[1].UUID, nil)) @@ -2289,15 +2379,21 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { windowsConfigProfileForTest(t, "G5w", "G5"), } - err = ds.BatchSetMDMProfiles(ctx, nil, newDarwinGlobalProfiles, newWindowsGlobalProfiles, nil) + updates, err = ds.BatchSetMDMProfiles(ctx, nil, newDarwinGlobalProfiles, newWindowsGlobalProfiles, nil) require.NoError(t, err) newGlobalProfiles = getProfs(nil) require.Len(t, newGlobalProfiles, 8) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) // bulk-set only those affected by the new Apple global profile newDarwinProfileUUID := newGlobalProfiles[3].ProfileUUID - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{newDarwinProfileUUID}, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{newDarwinProfileUUID}, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -2407,8 +2503,11 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { // bulk-set only those affected by the new Apple global profile newWindowsProfileUUID := newGlobalProfiles[7].ProfileUUID - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{newWindowsProfileUUID}, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{newWindowsProfileUUID}, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -2531,14 +2630,20 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { windowsConfigProfileForTest(t, "T2.1w", "T2.1"), } - err = ds.BatchSetMDMProfiles(ctx, &team2.ID, tm2DarwinProfiles, tm2WindowsProfiles, nil) + updates, err = ds.BatchSetMDMProfiles(ctx, &team2.ID, tm2DarwinProfiles, tm2WindowsProfiles, nil) require.NoError(t, err) tm2Profiles := getProfs(&team2.ID) require.Len(t, tm2Profiles, 2) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) // update status via tm2 id and the global 0 id to test that custom sql statement - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID, 0}, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID, 0}, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -2714,7 +2819,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { windowsConfigProfileForTest(t, "G7w", "G7", labels[5]), } - err = ds.BatchSetMDMProfiles(ctx, nil, newDarwinGlobalProfiles, newWindowsGlobalProfiles, nil) + updates, err = ds.BatchSetMDMProfiles(ctx, nil, newDarwinGlobalProfiles, newWindowsGlobalProfiles, nil) require.NoError(t, err) newGlobalProfiles = getProfs(nil) require.Len(t, newGlobalProfiles, 12) @@ -2723,6 +2828,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { setProfileLabels(t, newGlobalProfiles[5], labels[2]) setProfileLabels(t, newGlobalProfiles[10], labels[3], labels[4]) setProfileLabels(t, newGlobalProfiles[11], labels[5]) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) // simulate an entry with some values set to NULL ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { @@ -2737,7 +2845,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { // do a sync of all hosts, should not change anything as no host is a member // of the new label-based profiles (indices change due to new Apple and // Windows profiles) - err = ds.BulkSetPendingMDMHostProfiles( + updates, err = ds.BulkSetPendingMDMHostProfiles( ctx, hostIDsFromHosts( append(darwinHosts, append(windowsHosts, unenrolledHost, linuxHost)...)...), @@ -2746,6 +2854,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { nil, ) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -2912,7 +3023,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { // do a full sync, the new global hosts get the standard global profiles and // also the label-based profile that they are a member of - err = ds.BulkSetPendingMDMHostProfiles( + updates, err = ds.BulkSetPendingMDMHostProfiles( ctx, hostIDsFromHosts( append(darwinHosts, append(windowsHosts, unenrolledHost, linuxHost)...)...), @@ -2921,6 +3032,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { nil, ) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -3117,7 +3231,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { require.NoError(t, err) // do a sync of those hosts, they will get the two label-based profiles of their platform - err = ds.BulkSetPendingMDMHostProfiles( + updates, err = ds.BulkSetPendingMDMHostProfiles( ctx, hostIDsFromHosts(darwinHosts[2], windowsHosts[2]), nil, @@ -3125,6 +3239,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { nil, ) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -3327,7 +3444,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { require.NoError(t, ds.DeleteLabel(ctx, labels[3].Name)) // sync the affected profiles - err = ds.BulkSetPendingMDMHostProfiles( + updates, err = ds.BulkSetPendingMDMHostProfiles( ctx, nil, nil, @@ -3335,7 +3452,10 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { nil, ) require.NoError(t, err) - err = ds.BulkSetPendingMDMHostProfiles( + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) + updates, err = ds.BulkSetPendingMDMHostProfiles( ctx, nil, nil, @@ -3343,6 +3463,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { nil, ) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) // nothing changes - broken label-based profiles are simply ignored assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ @@ -3551,7 +3674,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { }) require.NoError(t, err) - err = ds.BulkSetPendingMDMHostProfiles( + updates, err = ds.BulkSetPendingMDMHostProfiles( ctx, hostIDsFromHosts(darwinHosts[2], windowsHosts[2]), nil, @@ -3559,6 +3682,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { nil, ) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -3756,7 +3882,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { setProfileLabels(t, newGlobalProfiles[4], labels[1]) setProfileLabels(t, newGlobalProfiles[10], labels[4]) - err = ds.BulkSetPendingMDMHostProfiles( + updates, err = ds.BulkSetPendingMDMHostProfiles( ctx, nil, nil, @@ -3764,7 +3890,10 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { nil, ) require.NoError(t, err) - err = ds.BulkSetPendingMDMHostProfiles( + assert.True(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) + updates, err = ds.BulkSetPendingMDMHostProfiles( ctx, nil, nil, @@ -3772,6 +3901,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { nil, ) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -3969,18 +4101,24 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { windowsConfigProfileForTest(t, "T2.2w", "T2.2", labels[4], labels[5]), } - err = ds.BatchSetMDMProfiles(ctx, &team2.ID, tm2DarwinProfiles, tm2WindowsProfiles, nil) + updates, err = ds.BatchSetMDMProfiles(ctx, &team2.ID, tm2DarwinProfiles, tm2WindowsProfiles, nil) require.NoError(t, err) tm2Profiles = getProfs(&team2.ID) require.Len(t, tm2Profiles, 4) // TODO(mna): temporary until BatchSetMDMProfiles supports labels setProfileLabels(t, tm2Profiles[1], labels[1], labels[2]) setProfileLabels(t, tm2Profiles[3], labels[4], labels[5]) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) // sync team 2, no changes because no host is a member of the labels (except // index change due to new profiles) - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -4178,8 +4316,11 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { require.NoError(t, err) // sync team 2, the label-based profile of team2 is now pending install - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -4388,8 +4529,11 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { // sync team 2, the label-based profile of team2 is left untouched (broken // profiles are ignored) - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -4603,8 +4747,11 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { // sync team 2, the label-based profile of team2 is still left untouched // because even if the hosts are not members anymore, the profile is broken - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -4808,8 +4955,11 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { // sync team 2, now it sees that the hosts are not members and the profile // gets removed - err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -5003,7 +5153,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { }) // sanity-check, a full sync does not change anything - err = ds.BulkSetPendingMDMHostProfiles( + updates, err = ds.BulkSetPendingMDMHostProfiles( ctx, hostIDsFromHosts( append(darwinHosts, append(windowsHosts, unenrolledHost, linuxHost)...)...), @@ -5012,6 +5162,9 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) { nil, ) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ darwinHosts[0]: { @@ -5237,8 +5390,11 @@ func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore) configProfileForTest(t, "T1.2", "T1.2", "e"), } - err = ds.BatchSetMDMProfiles(ctx, &team.ID, profiles, nil, nil) + updates, err := ds.BatchSetMDMProfiles(ctx, &team.ID, profiles, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) profs, _, err := ds.ListMDMConfigProfiles(ctx, &team.ID, fleet.ListOptions{}) require.NoError(t, err) @@ -5275,8 +5431,11 @@ func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore) label, err := ds.NewLabel(ctx, &fleet.Label{Name: "test_label_1"}) require.NoError(t, err) - err = ds.BatchSetMDMProfiles(ctx, &team.ID, profiles, nil, nil) + updates, err := ds.BatchSetMDMProfiles(ctx, &team.ID, profiles, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) var uid string ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { @@ -5354,8 +5513,11 @@ func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore) testLabel3, err := ds.NewLabel(ctx, &fleet.Label{Name: "test_label_3"}) require.NoError(t, err) - err = ds.BatchSetMDMProfiles(ctx, &team.ID, profiles, nil, nil) + updates, err := ds.BatchSetMDMProfiles(ctx, &team.ID, profiles, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) var uid string ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { @@ -5444,8 +5606,11 @@ func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore) testLabel4, err := ds.NewLabel(ctx, &fleet.Label{Name: "test_label_4"}) require.NoError(t, err) - err = ds.BatchSetMDMProfiles(ctx, &team.ID, profiles, nil, nil) + updates, err := ds.BatchSetMDMProfiles(ctx, &team.ID, profiles, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) var uid string ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { @@ -5524,8 +5689,11 @@ func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore) windowsConfigProfileForTest(t, "T5.2", "T5.2"), } - err = ds.BatchSetMDMProfiles(ctx, &team.ID, nil, profiles, nil) + updates, err := ds.BatchSetMDMProfiles(ctx, &team.ID, nil, profiles, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) profs, _, err := ds.ListMDMConfigProfiles(ctx, &team.ID, fleet.ListOptions{}) require.NoError(t, err) @@ -5562,8 +5730,11 @@ func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore) label, err := ds.NewLabel(ctx, &fleet.Label{Name: "test_label_6"}) require.NoError(t, err) - err = ds.BatchSetMDMProfiles(ctx, &team.ID, nil, profiles, nil) + updates, err := ds.BatchSetMDMProfiles(ctx, &team.ID, nil, profiles, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) var uid string ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { @@ -5641,8 +5812,11 @@ func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore) testLabel3, err := ds.NewLabel(ctx, &fleet.Label{Name: uuid.NewString()}) require.NoError(t, err) - err = ds.BatchSetMDMProfiles(ctx, &team.ID, nil, profiles, nil) + updates, err := ds.BatchSetMDMProfiles(ctx, &team.ID, nil, profiles, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) var uid string ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { @@ -5731,8 +5905,11 @@ func testGetHostMDMProfilesExpectedForVerification(t *testing.T, ds *Datastore) label, err := ds.NewLabel(ctx, &fleet.Label{Name: uuid.NewString()}) require.NoError(t, err) - err = ds.BatchSetMDMProfiles(ctx, &team.ID, nil, profiles, nil) + updates, err := ds.BatchSetMDMProfiles(ctx, &team.ID, nil, profiles, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) var uid string ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { @@ -5947,18 +6124,16 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) { wantOtherWin := []fleet.ConfigurationProfileLabel{ {ProfileUUID: otherWinProfile.ProfileUUID, LabelName: label.Name, LabelID: label.ID}, } - require.NoError( - t, - batchSetProfileLabelAssociationsDB(ctx, ds.writer(ctx), wantOtherWin, "windows"), - ) + updatedDB, err := batchSetProfileLabelAssociationsDB(ctx, ds.writer(ctx), wantOtherWin, "windows") + require.NoError(t, err) + assert.True(t, updatedDB) // make it an "exclude" label on the other macos profile wantOtherMac := []fleet.ConfigurationProfileLabel{ {ProfileUUID: otherMacProfile.ProfileUUID, LabelName: label.Name, LabelID: label.ID, Exclude: true}, } - require.NoError( - t, - batchSetProfileLabelAssociationsDB(ctx, ds.writer(ctx), wantOtherMac, "darwin"), - ) + updatedDB, err = batchSetProfileLabelAssociationsDB(ctx, ds.writer(ctx), wantOtherMac, "darwin") + require.NoError(t, err) + assert.True(t, updatedDB) platforms := map[string]string{ "darwin": macOSProfile.ProfileUUID, @@ -5991,7 +6166,10 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) { t.Run("empty input "+platform, func(t *testing.T) { want := []fleet.ConfigurationProfileLabel{} err := ds.withTx(ctx, func(tx sqlx.ExtContext) error { - return batchSetProfileLabelAssociationsDB(ctx, tx, want, platform) + updatedDB, err := batchSetProfileLabelAssociationsDB(ctx, tx, want, platform) + require.NoError(t, err) + assert.False(t, updatedDB) + return err }) require.NoError(t, err) expectLabels(t, uuid, platform, want) @@ -6005,7 +6183,10 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) { {ProfileUUID: uuid, LabelName: label.Name, LabelID: label.ID}, } err := ds.withTx(ctx, func(tx sqlx.ExtContext) error { - return batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, platform) + updatedDB, err := batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, platform) + require.NoError(t, err) + assert.True(t, updatedDB) + return err }) require.NoError(t, err) expectLabels(t, uuid, platform, profileLabels) @@ -6018,7 +6199,10 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) { {ProfileUUID: uuid, LabelName: label.Name, LabelID: label.ID, Exclude: true}, } err = ds.withTx(ctx, func(tx sqlx.ExtContext) error { - return batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, platform) + updatedDB, err := batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, platform) + require.NoError(t, err) + assert.True(t, updatedDB) + return err }) require.NoError(t, err) expectLabels(t, uuid, platform, profileLabels) @@ -6033,7 +6217,8 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) { } err := ds.withTx(ctx, func(tx sqlx.ExtContext) error { - return batchSetProfileLabelAssociationsDB(ctx, tx, invalidProfileLabels, platform) + _, err := batchSetProfileLabelAssociationsDB(ctx, tx, invalidProfileLabels, platform) + return err }) require.Error(t, err) }) @@ -6044,7 +6229,8 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) { {ProfileUUID: uuid, LabelName: label.Name, LabelID: 12345}, } err := ds.withTx(ctx, func(tx sqlx.ExtContext) error { - return batchSetProfileLabelAssociationsDB(ctx, tx, invalidProfileLabels, platform) + _, err := batchSetProfileLabelAssociationsDB(ctx, tx, invalidProfileLabels, platform) + return err }) require.Error(t, err) @@ -6053,7 +6239,8 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) { {ProfileUUID: uuid, LabelName: "xyz", LabelID: 1235}, } err = ds.withTx(ctx, func(tx sqlx.ExtContext) error { - return batchSetProfileLabelAssociationsDB(ctx, tx, invalidProfileLabels, platform) + _, err := batchSetProfileLabelAssociationsDB(ctx, tx, invalidProfileLabels, platform) + return err }) require.Error(t, err) }) @@ -6074,7 +6261,10 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) { {ProfileUUID: uuid, LabelName: newLabel.Name, LabelID: newLabel.ID, Exclude: true}, } err = ds.withTx(ctx, func(tx sqlx.ExtContext) error { - return batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, platform) + updatedDB, err := batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, platform) + require.NoError(t, err) + assert.True(t, updatedDB) + return err }) require.NoError(t, err) // both are stored in the DB @@ -6085,7 +6275,10 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) { {ProfileUUID: uuid, LabelName: label.Name, LabelID: label.ID}, } err = ds.withTx(ctx, func(tx sqlx.ExtContext) error { - return batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, platform) + updatedDB, err := batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, platform) + require.NoError(t, err) + assert.True(t, updatedDB) + return err }) require.NoError(t, err) expectLabels(t, uuid, platform, profileLabels) @@ -6098,12 +6291,13 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) { t.Run("unsupported platform", func(t *testing.T) { err := ds.withTx(ctx, func(tx sqlx.ExtContext) error { - return batchSetProfileLabelAssociationsDB( + _, err := batchSetProfileLabelAssociationsDB( ctx, tx, []fleet.ConfigurationProfileLabel{{}}, "unsupported", ) + return err }) require.Error(t, err) }) @@ -6185,7 +6379,7 @@ func testBatchSetMDMProfilesTransactionError(t *testing.T, ds *Datastore) { windowsConfigProfileForTest(t, "W2", "l2"), } // set the initial profiles without error - err := ds.BatchSetMDMProfiles(ctx, nil, appleProfs, winProfs, nil) + _, err := ds.BatchSetMDMProfiles(ctx, nil, appleProfs, winProfs, nil) require.NoError(t, err) // now ensure all steps are required (add a profile, delete a profile, set labels) @@ -6201,7 +6395,7 @@ func testBatchSetMDMProfilesTransactionError(t *testing.T, ds *Datastore) { ds.testBatchSetMDMAppleProfilesErr = c.appleErr ds.testBatchSetMDMWindowsProfilesErr = c.windowsErr - err = ds.BatchSetMDMProfiles(ctx, nil, appleProfs, winProfs, nil) + _, err = ds.BatchSetMDMProfiles(ctx, nil, appleProfs, winProfs, nil) require.ErrorContains(t, err, c.wantErr) }) } @@ -7139,8 +7333,11 @@ func testBulkSetPendingMDMHostProfilesExcludeAny(t *testing.T, ds *Datastore) { declForTest("D1", "D1", "{}", labels[3], labels[4], labels[5]), } - err := ds.BatchSetMDMProfiles(ctx, nil, appleProfs, windowsProfs, appleDecls) + updates, err := ds.BatchSetMDMProfiles(ctx, nil, appleProfs, windowsProfs, appleDecls) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.True(t, updates.AppleDeclaration) // must reload them to get the profile/declaration uuid getProfs := func(teamID *uint) []*fleet.MDMConfigProfilePayload { @@ -7185,8 +7382,11 @@ func testBulkSetPendingMDMHostProfilesExcludeAny(t *testing.T, ds *Datastore) { // do a sync, they get all platform-specific profiles since they are not part // of any label - err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID}, nil, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID}, nil, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.True(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ appleHost: { @@ -7225,8 +7425,11 @@ func testBulkSetPendingMDMHostProfilesExcludeAny(t *testing.T, ds *Datastore) { }) require.NoError(t, err) - err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID}, nil, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID}, nil, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.True(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ appleHost: { @@ -7257,8 +7460,11 @@ func testBulkSetPendingMDMHostProfilesExcludeAny(t *testing.T, ds *Datastore) { }) require.NoError(t, err) - err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID}, nil, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID}, nil, nil, nil) require.NoError(t, err) + assert.True(t, updates.AppleConfigProfile) + assert.True(t, updates.WindowsConfigProfile) + assert.True(t, updates.AppleDeclaration) assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ appleHost: { @@ -7293,8 +7499,11 @@ func testBulkSetPendingMDMHostProfilesExcludeAny(t *testing.T, ds *Datastore) { err = ds.DeleteLabel(ctx, labels[3].Name) require.NoError(t, err) - err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID}, nil, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID}, nil, nil, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) // broken profiles do not get reported as "to remove" assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ @@ -7345,8 +7554,11 @@ func testBulkSetPendingMDMHostProfilesExcludeAny(t *testing.T, ds *Datastore) { require.NoError(t, err) nanoEnroll(t, ds, appleHost2, false) - err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID, winHost2.ID, appleHost2.ID}, nil, nil, nil) + updates, err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID, winHost2.ID, appleHost2.ID}, nil, nil, nil) require.NoError(t, err) + assert.False(t, updates.AppleConfigProfile) + assert.False(t, updates.WindowsConfigProfile) + assert.False(t, updates.AppleDeclaration) // broken profiles do not get reported as "to install" assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{ diff --git a/server/datastore/mysql/microsoft_mdm.go b/server/datastore/mysql/microsoft_mdm.go index b7426bff009a..ce4d73a9da4f 100644 --- a/server/datastore/mysql/microsoft_mdm.go +++ b/server/datastore/mysql/microsoft_mdm.go @@ -1581,7 +1581,7 @@ INSERT INTO cp.LabelsExcludeAny[i].Exclude = true labels = append(labels, cp.LabelsExcludeAny[i]) } - if err := batchSetProfileLabelAssociationsDB(ctx, tx, labels, "windows"); err != nil { + if _, err := batchSetProfileLabelAssociationsDB(ctx, tx, labels, "windows"); err != nil { return ctxerr.Wrap(ctx, err, "inserting windows profile label associations") } @@ -1653,7 +1653,7 @@ func (ds *Datastore) batchSetMDMWindowsProfilesDB( tx sqlx.ExtContext, tmID *uint, profiles []*fleet.MDMWindowsConfigProfile, -) error { +) (updatedDB bool, err error) { const loadExistingProfiles = ` SELECT name, @@ -1721,13 +1721,13 @@ ON DUPLICATE KEY UPDATE if err == nil { err = errors.New(ds.testBatchSetMDMWindowsProfilesErr) } - return ctxerr.Wrap(ctx, err, "build query to load existing profiles") + return false, ctxerr.Wrap(ctx, err, "build query to load existing profiles") } if err := sqlx.SelectContext(ctx, tx, &existingProfiles, stmt, args...); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "select") { if err == nil { err = errors.New(ds.testBatchSetMDMWindowsProfilesErr) } - return ctxerr.Wrap(ctx, err, "load existing profiles") + return false, ctxerr.Wrap(ctx, err, "load existing profiles") } } @@ -1748,40 +1748,48 @@ ON DUPLICATE KEY UPDATE var ( stmt string args []interface{} - err error ) // delete the obsolete profiles (all those that are not in keepNames) + var result sql.Result if len(keepNames) > 0 { stmt, args, err = sqlx.In(deleteProfilesNotInList, profTeamID, keepNames) if err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "indelete") { if err == nil { err = errors.New(ds.testBatchSetMDMWindowsProfilesErr) } - return ctxerr.Wrap(ctx, err, "build statement to delete obsolete profiles") + return false, ctxerr.Wrap(ctx, err, "build statement to delete obsolete profiles") } - if _, err := tx.ExecContext(ctx, stmt, args...); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "delete") { + if result, err = tx.ExecContext(ctx, stmt, args...); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, + "delete") { if err == nil { err = errors.New(ds.testBatchSetMDMWindowsProfilesErr) } - return ctxerr.Wrap(ctx, err, "delete obsolete profiles") + return false, ctxerr.Wrap(ctx, err, "delete obsolete profiles") } } else { - if _, err := tx.ExecContext(ctx, deleteAllProfilesForTeam, profTeamID); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "delete") { + if result, err = tx.ExecContext(ctx, deleteAllProfilesForTeam, + profTeamID); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "delete") { if err == nil { err = errors.New(ds.testBatchSetMDMWindowsProfilesErr) } - return ctxerr.Wrap(ctx, err, "delete all profiles for team") + return false, ctxerr.Wrap(ctx, err, "delete all profiles for team") } } + if result != nil { + rows, _ := result.RowsAffected() + updatedDB = rows > 0 + } // insert the new profiles and the ones that have changed for _, p := range incomingProfs { - if _, err := tx.ExecContext(ctx, insertNewOrEditedProfile, profTeamID, p.Name, p.SyncML); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "insert") { + if result, err = tx.ExecContext(ctx, insertNewOrEditedProfile, profTeamID, p.Name, + p.SyncML); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "insert") { if err == nil { err = errors.New(ds.testBatchSetMDMWindowsProfilesErr) } - return ctxerr.Wrapf(ctx, err, "insert new/edited profile with name %q", p.Name) + return false, ctxerr.Wrapf(ctx, err, "insert new/edited profile with name %q", p.Name) } + updatedDB = updatedDB || insertOnDuplicateDidInsertOrUpdate(result) } // build a list of labels so the associations can be batch-set all at once @@ -1797,19 +1805,19 @@ ON DUPLICATE KEY UPDATE if err == nil { err = errors.New(ds.testBatchSetMDMWindowsProfilesErr) } - return ctxerr.Wrap(ctx, err, "build query to load newly inserted profiles") + return false, ctxerr.Wrap(ctx, err, "build query to load newly inserted profiles") } if err := sqlx.SelectContext(ctx, tx, &newlyInsertedProfs, stmt, args...); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "reselect") { if err == nil { err = errors.New(ds.testBatchSetMDMWindowsProfilesErr) } - return ctxerr.Wrap(ctx, err, "load newly inserted profiles") + return false, ctxerr.Wrap(ctx, err, "load newly inserted profiles") } for _, newlyInsertedProf := range newlyInsertedProfs { incomingProf, ok := incomingProfs[newlyInsertedProf.Name] if !ok { - return ctxerr.Wrapf(ctx, err, "profile %q is in the database but was not incoming", newlyInsertedProf.Name) + return false, ctxerr.Wrapf(ctx, err, "profile %q is in the database but was not incoming", newlyInsertedProf.Name) } for _, label := range incomingProf.LabelsIncludeAll { @@ -1825,47 +1833,56 @@ ON DUPLICATE KEY UPDATE } // insert/delete the label associations - if err := batchSetProfileLabelAssociationsDB(ctx, tx, incomingLabels, "windows"); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "labels") { + var updatedLabels bool + if updatedLabels, err = batchSetProfileLabelAssociationsDB(ctx, tx, incomingLabels, + "windows"); err != nil || strings.HasPrefix(ds.testBatchSetMDMWindowsProfilesErr, "labels") { if err == nil { err = errors.New(ds.testBatchSetMDMWindowsProfilesErr) } - return ctxerr.Wrap(ctx, err, "inserting windows profile label associations") + return false, ctxerr.Wrap(ctx, err, "inserting windows profile label associations") } - return nil + return updatedDB || updatedLabels, nil } func (ds *Datastore) bulkSetPendingMDMWindowsHostProfilesDB( ctx context.Context, tx sqlx.ExtContext, uuids []string, -) error { +) (updatedDB bool, err error) { if len(uuids) == 0 { - return nil + return false, nil } profilesToInstall, err := listMDMWindowsProfilesToInstallDB(ctx, tx, uuids) if err != nil { - return ctxerr.Wrap(ctx, err, "list profiles to install") + return false, ctxerr.Wrap(ctx, err, "list profiles to install") } profilesToRemove, err := listMDMWindowsProfilesToRemoveDB(ctx, tx, uuids) if err != nil { - return ctxerr.Wrap(ctx, err, "list profiles to remove") + return false, ctxerr.Wrap(ctx, err, "list profiles to remove") } if len(profilesToInstall) == 0 && len(profilesToRemove) == 0 { - return nil + return false, nil } - if err := ds.bulkDeleteMDMWindowsHostsConfigProfilesDB(ctx, tx, profilesToRemove); err != nil { - return ctxerr.Wrap(ctx, err, "bulk delete profiles to remove") + if len(profilesToRemove) > 0 { + if err := ds.bulkDeleteMDMWindowsHostsConfigProfilesDB(ctx, tx, profilesToRemove); err != nil { + return false, ctxerr.Wrap(ctx, err, "bulk delete profiles to remove") + } + updatedDB = true + } + if len(profilesToInstall) == 0 { + return updatedDB, nil } var ( - pargs []any - psb strings.Builder - batchCount int + pargs []any + profilesToInsert = make(map[string]*fleet.MDMWindowsProfilePayload) + psb strings.Builder + batchCount int ) const defaultBatchSize = 1000 @@ -1877,10 +1894,48 @@ func (ds *Datastore) bulkSetPendingMDMWindowsHostProfilesDB( resetBatch := func() { batchCount = 0 pargs = pargs[:0] + clear(profilesToInsert) psb.Reset() } executeUpsertBatch := func(valuePart string, args []any) error { + // Check if the update needs to be done at all. + selectStmt := fmt.Sprintf(` + SELECT + profile_uuid, + host_uuid, + status, + COALESCE(operation_type, '') AS operation_type, + COALESCE(detail, '') AS detail, + COALESCE(command_uuid, '') AS command_uuid, + COALESCE(profile_name, '') AS profile_name + FROM host_mdm_windows_profiles WHERE (profile_uuid, host_uuid) IN (%s)`, + strings.TrimSuffix(strings.Repeat("(?,?),", len(profilesToInsert)), ",")) + var selectArgs []any + for _, p := range profilesToInsert { + selectArgs = append(selectArgs, p.ProfileUUID, p.HostUUID) + } + var existingProfiles []fleet.MDMWindowsProfilePayload + if err := sqlx.SelectContext(ctx, tx, &existingProfiles, selectStmt, selectArgs...); err != nil { + return ctxerr.Wrap(ctx, err, "bulk set pending profile status select existing") + } + var updateNeeded bool + if len(existingProfiles) == len(profilesToInsert) { + for _, exist := range existingProfiles { + insert, ok := profilesToInsert[fmt.Sprintf("%s\n%s", exist.ProfileUUID, exist.HostUUID)] + if !ok || !exist.Equal(*insert) { + updateNeeded = true + break + } + } + } else { + updateNeeded = true + } + if !updateNeeded { + // All profiles are already in the database, no need to update. + return nil + } + baseStmt := fmt.Sprintf(` INSERT INTO host_mdm_windows_profiles ( profile_uuid, @@ -1898,11 +1953,25 @@ func (ds *Datastore) bulkSetPendingMDMWindowsHostProfilesDB( detail = '' `, strings.TrimSuffix(valuePart, ",")) - _, err = tx.ExecContext(ctx, baseStmt, args...) - return ctxerr.Wrap(ctx, err, "bulk set pending profile status execute batch") + _, err := tx.ExecContext(ctx, baseStmt, args...) + if err != nil { + return ctxerr.Wrap(ctx, err, "bulk set pending profile status execute batch") + } + updatedDB = true + return nil } for _, p := range profilesToInstall { + profilesToInsert[fmt.Sprintf("%s\n%s", p.ProfileUUID, p.HostUUID)] = &fleet.MDMWindowsProfilePayload{ + ProfileUUID: p.ProfileUUID, + ProfileName: p.ProfileName, + HostUUID: p.HostUUID, + Status: nil, + OperationType: fleet.MDMOperationTypeInstall, + Detail: p.Detail, + CommandUUID: p.CommandUUID, + Retries: p.Retries, + } pargs = append( pargs, p.ProfileUUID, p.HostUUID, p.ProfileName, fleet.MDMOperationTypeInstall) @@ -1910,7 +1979,7 @@ func (ds *Datastore) bulkSetPendingMDMWindowsHostProfilesDB( batchCount++ if batchCount >= batchSize { if err := executeUpsertBatch(psb.String(), pargs); err != nil { - return err + return false, err } resetBatch() } @@ -1918,11 +1987,11 @@ func (ds *Datastore) bulkSetPendingMDMWindowsHostProfilesDB( if batchCount > 0 { if err := executeUpsertBatch(psb.String(), pargs); err != nil { - return err + return false, err } } - return nil + return updatedDB, nil } func (ds *Datastore) GetHostMDMWindowsProfiles(ctx context.Context, hostUUID string) ([]fleet.HostMDMWindowsProfile, error) { diff --git a/server/datastore/mysql/microsoft_mdm_test.go b/server/datastore/mysql/microsoft_mdm_test.go index 02a4c9d149d6..263a6e549102 100644 --- a/server/datastore/mysql/microsoft_mdm_test.go +++ b/server/datastore/mysql/microsoft_mdm_test.go @@ -15,6 +15,7 @@ import ( "github.com/fleetdm/fleet/v4/server/test" "github.com/google/uuid" "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -1885,7 +1886,7 @@ func testSetOrReplaceMDMWindowsConfigProfile(t *testing.T, ds *Datastore) { } ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { return sqlx.GetContext(ctx, q, &prof, - `SELECT * FROM mdm_windows_configuration_profiles WHERE team_id = ? AND name = ?`, + `SELECT profile_uuid, team_id, name, syncml, created_at, uploaded_at FROM mdm_windows_configuration_profiles WHERE team_id = ? AND name = ?`, teamID, name) }) return &prof @@ -1983,7 +1984,9 @@ func expectWindowsProfiles( var got []*fleet.MDMWindowsConfigProfile ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { ctx := context.Background() - return sqlx.SelectContext(ctx, q, &got, `SELECT * FROM mdm_windows_configuration_profiles WHERE team_id = ?`, tmID) + return sqlx.SelectContext(ctx, q, &got, + `SELECT profile_uuid, team_id, name, syncml, created_at, uploaded_at FROM mdm_windows_configuration_profiles WHERE team_id = ?`, + tmID) }) // create map of expected profiles keyed by name @@ -2025,9 +2028,13 @@ func expectWindowsProfiles( func testBatchSetMDMWindowsProfiles(t *testing.T, ds *Datastore) { ctx := context.Background() - applyAndExpect := func(newSet []*fleet.MDMWindowsConfigProfile, tmID *uint, want []*fleet.MDMWindowsConfigProfile) map[string]string { + applyAndExpect := func(newSet []*fleet.MDMWindowsConfigProfile, tmID *uint, want []*fleet.MDMWindowsConfigProfile, + wantUpdated bool) map[string]string { err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { - return ds.batchSetMDMWindowsProfilesDB(ctx, tx, tmID, newSet) + updatedDB, err := ds.batchSetMDMWindowsProfilesDB(ctx, tx, tmID, newSet) + require.NoError(t, err) + assert.Equal(t, wantUpdated, updatedDB) + return err }) require.NoError(t, err) return expectWindowsProfiles(t, ds, tmID, want) @@ -2041,7 +2048,7 @@ func testBatchSetMDMWindowsProfiles(t *testing.T, ds *Datastore) { } ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { return sqlx.GetContext(ctx, q, &prof, - `SELECT * FROM mdm_windows_configuration_profiles WHERE team_id = ? AND name = ?`, + `SELECT profile_uuid, team_id, name, syncml, created_at, uploaded_at FROM mdm_windows_configuration_profiles WHERE team_id = ? AND name = ?`, teamID, name) }) return &prof @@ -2057,14 +2064,14 @@ func testBatchSetMDMWindowsProfiles(t *testing.T, ds *Datastore) { } // apply empty set for no-team - applyAndExpect(nil, nil, nil) + applyAndExpect(nil, nil, nil, false) // apply single profile set for tm1 mTm1 := applyAndExpect([]*fleet.MDMWindowsConfigProfile{ windowsConfigProfileForTest(t, "N1", "l1"), }, ptr.Uint(1), []*fleet.MDMWindowsConfigProfile{ withTeamID(windowsConfigProfileForTest(t, "N1", "l1"), 1), - }) + }, true) profTm1N1 := getProfileByTeamAndName(ptr.Uint(1), "N1") // apply single profile set for no-team @@ -2072,7 +2079,7 @@ func testBatchSetMDMWindowsProfiles(t *testing.T, ds *Datastore) { windowsConfigProfileForTest(t, "N1", "l1"), }, nil, []*fleet.MDMWindowsConfigProfile{ windowsConfigProfileForTest(t, "N1", "l1"), - }) + }, true) // wait a second to ensure timestamps in the DB change time.Sleep(time.Second) @@ -2084,7 +2091,7 @@ func testBatchSetMDMWindowsProfiles(t *testing.T, ds *Datastore) { }, ptr.Uint(1), []*fleet.MDMWindowsConfigProfile{ withUploadedAt(withTeamID(windowsConfigProfileForTest(t, "N1", "l1"), 1), profTm1N1.UploadedAt), withTeamID(windowsConfigProfileForTest(t, "N2", "l2"), 1), - }) + }, true) // uuid for N1-I1 is unchanged require.Equal(t, mTm1["I1"], mTm1b["I1"]) profTm1N2 := getProfileByTeamAndName(ptr.Uint(1), "N2") @@ -2102,7 +2109,7 @@ func testBatchSetMDMWindowsProfiles(t *testing.T, ds *Datastore) { withTeamID(windowsConfigProfileForTest(t, "N1", "l1b"), 1), withUploadedAt(withTeamID(windowsConfigProfileForTest(t, "N2", "l2"), 1), profTm1N2.UploadedAt), withTeamID(windowsConfigProfileForTest(t, "N3", "l3"), 1), - }) + }, true) // uuid for N1-I1 is unchanged require.Equal(t, mTm1b["I1"], mTm1c["I1"]) // uuid for N2-I2 is unchanged @@ -2119,10 +2126,19 @@ func testBatchSetMDMWindowsProfiles(t *testing.T, ds *Datastore) { }, nil, []*fleet.MDMWindowsConfigProfile{ windowsConfigProfileForTest(t, "N4", "l4"), windowsConfigProfileForTest(t, "N5", "l5"), - }) + }, true) + + // apply the same thing again -- nothing updated + applyAndExpect([]*fleet.MDMWindowsConfigProfile{ + windowsConfigProfileForTest(t, "N4", "l4"), + windowsConfigProfileForTest(t, "N5", "l5"), + }, nil, []*fleet.MDMWindowsConfigProfile{ + windowsConfigProfileForTest(t, "N4", "l4"), + windowsConfigProfileForTest(t, "N5", "l5"), + }, false) // clear profiles for tm1 - applyAndExpect(nil, ptr.Uint(1), nil) + applyAndExpect(nil, ptr.Uint(1), nil, true) } // if the label name starts with "exclude-", the label is considered an "exclude-any", otherwise diff --git a/server/datastore/mysql/migrations/tables/20240829170033_AddVPPTokenIDToVppAppsTeams.go b/server/datastore/mysql/migrations/tables/20240829170033_AddVPPTokenIDToVppAppsTeams.go new file mode 100644 index 000000000000..ea367f59ea69 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240829170033_AddVPPTokenIDToVppAppsTeams.go @@ -0,0 +1,43 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20240829170033, Down_20240829170033) +} + +func Up_20240829170033(tx *sql.Tx) error { + stmtAddColumn := ` +ALTER TABLE vpp_apps_teams + ADD COLUMN vpp_token_id int(10) UNSIGNED NOT NULL` + + stmtAssociate := `UPDATE vpp_apps_teams SET vpp_token_id = (SELECT id FROM vpp_tokens LIMIT 1)` + + stmtAddConstraint := ` +ALTER TABLE vpp_apps_teams + ADD CONSTRAINT fk_vpp_apps_teams_vpp_token_id + FOREIGN KEY (vpp_token_id) REFERENCES vpp_tokens(id) ON DELETE CASCADE` + + if _, err := tx.Exec(stmtAddColumn); err != nil { + return fmt.Errorf("failed to add vpp_token_id column to table: %w", err) + } + + // Associate apps with the first token available. If we're + // migrating from single-token VPP this should be correct. + if _, err := tx.Exec(stmtAssociate); err != nil { + return fmt.Errorf("failed to associate vpp apps with first token: %w", err) + } + + if _, err := tx.Exec(stmtAddConstraint); err != nil { + return fmt.Errorf("failed to add vpp token id constraint: %w", err) + } + + return nil +} + +func Down_20240829170033(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240829170044_PolicyAutomaticInstallSoftware.go b/server/datastore/mysql/migrations/tables/20240829170044_PolicyAutomaticInstallSoftware.go new file mode 100644 index 000000000000..ac1fd4180289 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240829170044_PolicyAutomaticInstallSoftware.go @@ -0,0 +1,37 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20240829170044, Down_20240829170044) +} + +func Up_20240829170044(tx *sql.Tx) error { + if _, err := tx.Exec(` + ALTER TABLE policies + ADD COLUMN software_installer_id INT UNSIGNED DEFAULT NULL, + ADD FOREIGN KEY fk_policies_software_installer_id (software_installer_id) REFERENCES software_installers (id); + `); err != nil { + return fmt.Errorf("failed to add software_installer_id to policies: %w", err) + } + + // We store `user_name` and `user_email` in case the user is deleted from Fleet (`user_id` set to NULL). + if _, err := tx.Exec(` + ALTER TABLE software_installers + ADD COLUMN user_id INT(10) UNSIGNED DEFAULT NULL, + ADD COLUMN user_name VARCHAR(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + ADD COLUMN user_email VARCHAR(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + ADD CONSTRAINT fk_software_installers_user_id FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL; + `); err != nil { + return fmt.Errorf("failed to add user_id to software_installers: %w", err) + } + + return nil +} + +func Down_20240829170044(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240905105135_AddAutoIncrementColumnToProfiles.go b/server/datastore/mysql/migrations/tables/20240905105135_AddAutoIncrementColumnToProfiles.go new file mode 100644 index 000000000000..19eed6bee79b --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240905105135_AddAutoIncrementColumnToProfiles.go @@ -0,0 +1,40 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20240905105135, Down_20240905105135) +} + +func Up_20240905105135(tx *sql.Tx) error { + // The AUTO_INCREMENT columns are used to determine if a row was updated by an INSERT ... ON DUPLICATE KEY UPDATE statement. + // This is needed because we are currently using CLIENT_FOUND_ROWS option to determine if a row was found. + // And in order to find if the row was updated, we need to check LAST_INSERT_ID(). + // MySQL docs: https://dev.mysql.com/doc/refman/8.4/en/insert-on-duplicate.html + + if !columnExists(tx, "mdm_windows_configuration_profiles", "auto_increment") { + if _, err := tx.Exec(` +ALTER TABLE mdm_windows_configuration_profiles +ADD COLUMN auto_increment BIGINT NOT NULL AUTO_INCREMENT UNIQUE +`); err != nil { + return fmt.Errorf("failed to add auto_increment to mdm_windows_configuration_profiles: %w", err) + } + } + + if !columnExists(tx, "mdm_apple_declarations", "auto_increment") { + if _, err := tx.Exec(` +ALTER TABLE mdm_apple_declarations +ADD COLUMN auto_increment BIGINT NOT NULL AUTO_INCREMENT UNIQUE +`); err != nil { + return fmt.Errorf("failed to add auto_increment to mdm_apple_declarations: %w", err) + } + } + return nil +} + +func Down_20240905105135(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240905140514_AddURLToSoftwareInstallers.go b/server/datastore/mysql/migrations/tables/20240905140514_AddURLToSoftwareInstallers.go new file mode 100644 index 000000000000..2da6ca5bdbed --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240905140514_AddURLToSoftwareInstallers.go @@ -0,0 +1,25 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20240905140514, Down_20240905140514) +} + +func Up_20240905140514(tx *sql.Tx) error { + // The new 'url' column will only be set for software uploaded in batch via GitOps. + if _, err := tx.Exec(` + ALTER TABLE software_installers + ADD COLUMN url VARCHAR(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT ''; + `); err != nil { + return fmt.Errorf("failed to add url to software_installers: %w", err) + } + return nil +} + +func Down_20240905140514(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/mysql.go b/server/datastore/mysql/mysql.go index 568761b42e82..3acfa46f638e 100644 --- a/server/datastore/mysql/mysql.go +++ b/server/datastore/mysql/mysql.go @@ -1242,21 +1242,7 @@ func (ds *Datastore) ProcessList(ctx context.Context) ([]fleet.MySQLProcess, err return processList, nil } -func insertOnDuplicateDidInsert(res sql.Result) bool { - // Note that connection string sets CLIENT_FOUND_ROWS (see - // generateMysqlConnectionString in this package), so LastInsertId is 0 - // and RowsAffected 1 when a row is set to its current values. - // - // See [the docs][1] or @mna's comment in `insertOnDuplicateDidUpdate` - // below for more details - // - // [1]: https://dev.mysql.com/doc/refman/5.7/en/insert-on-duplicate.html - lastID, _ := res.LastInsertId() - affected, _ := res.RowsAffected() - return lastID != 0 && affected == 1 -} - -func insertOnDuplicateDidUpdate(res sql.Result) bool { +func insertOnDuplicateDidInsertOrUpdate(res sql.Result) bool { // From mysql's documentation: // // With ON DUPLICATE KEY UPDATE, the affected-rows value per row is 1 if @@ -1266,7 +1252,10 @@ func insertOnDuplicateDidUpdate(res sql.Result) bool { // connecting to mysqld, the affected-rows value is 1 (not 0) if an // existing row is set to its current values. // - // https://dev.mysql.com/doc/refman/5.7/en/insert-on-duplicate.html + // If a table contains an AUTO_INCREMENT column and INSERT ... ON DUPLICATE KEY UPDATE + // inserts or updates a row, the LAST_INSERT_ID() function returns the AUTO_INCREMENT value. + // + // https://dev.mysql.com/doc/refman/8.4/en/insert-on-duplicate.html // // Note that connection string sets CLIENT_FOUND_ROWS (see // generateMysqlConnectionString in this package), so it does return 1 when @@ -1281,7 +1270,8 @@ func insertOnDuplicateDidUpdate(res sql.Result) bool { lastID, _ := res.LastInsertId() aff, _ := res.RowsAffected() - return lastID == 0 || aff != 1 + // something was updated (lastID != 0) AND row was found (aff == 1 or higher if more rows were found) + return lastID != 0 && aff > 0 } type parameterizedStmt struct { diff --git a/server/datastore/mysql/operating_system_vulnerabilities.go b/server/datastore/mysql/operating_system_vulnerabilities.go index c7598e0464f2..8a6a30dec00c 100644 --- a/server/datastore/mysql/operating_system_vulnerabilities.go +++ b/server/datastore/mysql/operating_system_vulnerabilities.go @@ -123,7 +123,7 @@ func (ds *Datastore) InsertOSVulnerability(ctx context.Context, v fleet.OSVulner return false, ctxerr.Wrap(ctx, err, "insert operating system vulnerability") } - return insertOnDuplicateDidInsert(res), nil + return insertOnDuplicateDidInsertOrUpdate(res), nil } func (ds *Datastore) DeleteOSVulnerabilities(ctx context.Context, vulnerabilities []fleet.OSVulnerability) error { diff --git a/server/datastore/mysql/operating_system_vulnerabilities_test.go b/server/datastore/mysql/operating_system_vulnerabilities_test.go index 715d8eada3eb..0b477091cbd7 100644 --- a/server/datastore/mysql/operating_system_vulnerabilities_test.go +++ b/server/datastore/mysql/operating_system_vulnerabilities_test.go @@ -7,6 +7,7 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -233,10 +234,15 @@ func testInsertOSVulnerability(t *testing.T, ds *Datastore) { require.NoError(t, err) require.True(t, didInsert) - // Inserting the same vulnerability should not insert - didInsert, err = ds.InsertOSVulnerability(ctx, vulnsUpdate, fleet.MSRCSource) + // Inserting the same vulnerability should not insert, but update + didInsertOrUpdate, err := ds.InsertOSVulnerability(ctx, vulnsUpdate, fleet.MSRCSource) require.NoError(t, err) - require.Equal(t, false, didInsert) + assert.True(t, didInsertOrUpdate) + + // Inserting the exact same vulnerability again should not insert and not update + didInsertOrUpdate, err = ds.InsertOSVulnerability(ctx, vulnsUpdate, fleet.MSRCSource) + require.NoError(t, err) + assert.False(t, didInsertOrUpdate) expected := vulnsUpdate expected.Source = fleet.MSRCSource diff --git a/server/datastore/mysql/policies.go b/server/datastore/mysql/policies.go index 4df1f9324a72..1c3cc024114c 100644 --- a/server/datastore/mysql/policies.go +++ b/server/datastore/mysql/policies.go @@ -22,12 +22,18 @@ import ( const policyCols = ` p.id, p.team_id, p.resolution, p.name, p.query, p.description, - p.author_id, p.platforms, p.created_at, p.updated_at, p.critical, p.calendar_events_enabled + p.author_id, p.platforms, p.created_at, p.updated_at, p.critical, + p.calendar_events_enabled, p.software_installer_id ` +var errSoftwareTitleIDOnGlobalPolicy = errors.New("install software title id can be set on team policies only") + var policySearchColumns = []string{"p.name"} func (ds *Datastore) NewGlobalPolicy(ctx context.Context, authorID *uint, args fleet.PolicyPayload) (*fleet.Policy, error) { + if args.SoftwareInstallerID != nil { + return nil, ctxerr.Wrap(ctx, errSoftwareTitleIDOnGlobalPolicy, "create policy") + } if args.QueryID != nil { q, err := ds.Query(ctx, *args.QueryID) if err != nil { @@ -129,15 +135,18 @@ func (ds *Datastore) PolicyLite(ctx context.Context, id uint) (*fleet.PolicyLite // // Currently, SavePolicy does not allow updating the team of an existing policy. func (ds *Datastore) SavePolicy(ctx context.Context, p *fleet.Policy, shouldRemoveAllPolicyMemberships bool, removePolicyStats bool) error { + if p.TeamID == nil && p.SoftwareInstallerID != nil { + return ctxerr.Wrap(ctx, errSoftwareTitleIDOnGlobalPolicy, "save policy") + } // We must normalize the name for full Unicode support (Unicode equivalence). p.Name = norm.NFC.String(p.Name) sql := ` UPDATE policies - SET name = ?, query = ?, description = ?, resolution = ?, platforms = ?, critical = ?, calendar_events_enabled = ?, checksum = ` + policiesChecksumComputedColumn() + ` + SET name = ?, query = ?, description = ?, resolution = ?, platforms = ?, critical = ?, calendar_events_enabled = ?, software_installer_id = ?, checksum = ` + policiesChecksumComputedColumn() + ` WHERE id = ? ` result, err := ds.writer(ctx).ExecContext( - ctx, sql, p.Name, p.Query, p.Description, p.Resolution, p.Platform, p.Critical, p.CalendarEventsEnabled, p.ID, + ctx, sql, p.Name, p.Query, p.Description, p.Resolution, p.Platform, p.Critical, p.CalendarEventsEnabled, p.SoftwareInstallerID, p.ID, ) if err != nil { return ctxerr.Wrap(ctx, err, "updating policy") @@ -484,7 +493,8 @@ func (ds *Datastore) PoliciesByID(ctx context.Context, ids []uint) (map[uint]*fl COALESCE(u.email, '') AS author_email, ps.updated_at as host_count_updated_at, COALESCE(ps.passing_host_count, 0) as passing_host_count, - COALESCE(ps.failing_host_count, 0) as failing_host_count + COALESCE(ps.failing_host_count, 0) as failing_host_count, + p.software_installer_id FROM policies p LEFT JOIN users u ON p.author_id = u.id LEFT JOIN policy_stats ps ON p.id = ps.policy_id @@ -601,11 +611,11 @@ func (ds *Datastore) NewTeamPolicy(ctx context.Context, teamID uint, authorID *u nameUnicode := norm.NFC.String(args.Name) res, err := ds.writer(ctx).ExecContext(ctx, fmt.Sprintf( - `INSERT INTO policies (name, query, description, team_id, resolution, author_id, platforms, critical, calendar_events_enabled, checksum) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, %s)`, + `INSERT INTO policies (name, query, description, team_id, resolution, author_id, platforms, critical, calendar_events_enabled, software_installer_id, checksum) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s)`, policiesChecksumComputedColumn(), ), nameUnicode, args.Query, args.Description, teamID, args.Resolution, authorID, args.Platform, args.Critical, - args.CalendarEventsEnabled, + args.CalendarEventsEnabled, args.SoftwareInstallerID, ) switch { case err == nil: @@ -691,6 +701,7 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs // Preprocess specs and group them by team teamNameToID := make(map[string]uint, 1) teamIDToPolicies := make(map[uint][]*fleet.PolicySpec, 1) + softwareInstallerIDs := make(map[uint]map[uint]*uint) // teamID -> titleID -> softwareInstallerID // Get the team IDs for _, spec := range specs { @@ -714,6 +725,30 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs teamIDToPolicies[teamID] = append(teamIDToPolicies[teamID], spec) } + // Get software installer ids from software title ids. + for _, spec := range specs { + if spec.SoftwareTitleID == nil || *spec.SoftwareTitleID == 0 { + continue + } + if spec.Team == "" { + return ctxerr.Wrap(ctx, errSoftwareTitleIDOnGlobalPolicy, "create policy from spec") + } + var softwareInstallerID uint + err := sqlx.GetContext(ctx, queryerContext, &softwareInstallerID, + `SELECT id FROM software_installers WHERE global_or_team_id = ? AND title_id = ?`, + teamNameToID[spec.Team], spec.SoftwareTitleID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ctxerr.Wrap(ctx, notFound("SoftwareInstaller").WithID(*spec.SoftwareTitleID), "get software installer id") + } + return ctxerr.Wrap(ctx, err, "get software installer id") + } + if len(softwareInstallerIDs[teamNameToID[spec.Team]]) == 0 { + softwareInstallerIDs[teamNameToID[spec.Team]] = make(map[uint]*uint) + } + softwareInstallerIDs[teamNameToID[spec.Team]][*spec.SoftwareTitleID] = &softwareInstallerID + } + // Get the query and platforms of the current policies so that we can check if query or platform changed later, if needed type policyLite struct { Name string `db:"name"` @@ -764,8 +799,9 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs platforms, critical, calendar_events_enabled, + software_installer_id, checksum - ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, %s) + ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s) ON DUPLICATE KEY UPDATE query = VALUES(query), description = VALUES(description), @@ -773,7 +809,8 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs resolution = VALUES(resolution), platforms = VALUES(platforms), critical = VALUES(critical), - calendar_events_enabled = VALUES(calendar_events_enabled) + calendar_events_enabled = VALUES(calendar_events_enabled), + software_installer_id = VALUES(software_installer_id) `, policiesChecksumComputedColumn(), ) for teamID, teamPolicySpecs := range teamIDToPolicies { @@ -782,17 +819,20 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs teamIDPtr = &teamID } for _, spec := range teamPolicySpecs { - + var softwareInstallerID *uint + if spec.SoftwareTitleID != nil { + softwareInstallerID = softwareInstallerIDs[teamNameToID[spec.Team]][*spec.SoftwareTitleID] + } res, err := tx.ExecContext( ctx, query, spec.Name, spec.Query, spec.Description, authorID, spec.Resolution, teamIDPtr, spec.Platform, spec.Critical, - spec.CalendarEventsEnabled, + spec.CalendarEventsEnabled, softwareInstallerID, ) if err != nil { return ctxerr.Wrap(ctx, err, "exec ApplyPolicySpecs insert") } - if insertOnDuplicateDidUpdate(res) { + if insertOnDuplicateDidInsertOrUpdate(res) { // when the upsert results in an UPDATE that *did* change some values, // it returns the updated ID as last inserted id. if lastID, _ := res.LastInsertId(); lastID > 0 { @@ -1429,6 +1469,22 @@ func (ds *Datastore) GetCalendarPolicies(ctx context.Context, teamID uint) ([]fl return policies, nil } +func (ds *Datastore) GetPoliciesWithAssociatedInstaller(ctx context.Context, teamID uint, policyIDs []uint) ([]fleet.PolicySoftwareInstallerData, error) { + if len(policyIDs) == 0 { + return nil, nil + } + query := `SELECT id, software_installer_id FROM policies WHERE team_id = ? AND software_installer_id IS NOT NULL AND id IN (?);` + query, args, err := sqlx.In(query, teamID, policyIDs) + if err != nil { + return nil, ctxerr.Wrapf(ctx, err, "build sqlx.In for get policies with associated installer") + } + var policies []fleet.PolicySoftwareInstallerData + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &policies, query, args...); err != nil { + return nil, ctxerr.Wrap(ctx, err, "get policies with associated installer") + } + return policies, nil +} + func (ds *Datastore) GetTeamHostsPolicyMemberships( ctx context.Context, domain string, diff --git a/server/datastore/mysql/policies_test.go b/server/datastore/mysql/policies_test.go index d3b9bdb93afe..aee58797ff64 100644 --- a/server/datastore/mysql/policies_test.go +++ b/server/datastore/mysql/policies_test.go @@ -1,6 +1,7 @@ package mysql import ( + "bytes" "context" "crypto/md5" //nolint:gosec // (only used for tests) "encoding/hex" @@ -63,6 +64,9 @@ func TestPolicies(t *testing.T) { {"TestPoliciesNameSort", testPoliciesNameSort}, {"TestGetCalendarPolicies", testGetCalendarPolicies}, {"GetTeamHostsPolicyMemberships", testGetTeamHostsPolicyMemberships}, + {"TestPoliciesNewGlobalPolicyWithInstaller", testNewGlobalPolicyWithInstaller}, + {"TestPoliciesTeamPoliciesWithInstaller", testTeamPoliciesWithInstaller}, + {"ApplyPolicySpecWithInstallers", testApplyPolicySpecWithInstallers}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -1219,9 +1223,29 @@ func testPolicyQueriesForHost(t *testing.T, ds *Datastore) { func testPoliciesByID(t *testing.T, ds *Datastore) { user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) policy1 := newTestPolicy(t, ds, user1, "policy1", "darwin", nil) - _ = newTestPolicy(t, ds, user1, "policy2", "darwin", nil) + team1, err := ds.NewTeam(context.Background(), &fleet.Team{Name: "team1"}) + require.NoError(t, err) + policy2 := newTestPolicy(t, ds, user1, "policy2", "darwin", &team1.ID) host1 := newTestHostWithPlatform(t, ds, "host1", "darwin", nil) + // Associate an installer to policy2 + installerID, err := ds.MatchOrCreateSoftwareInstaller(context.Background(), &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "hello", + PreInstallQuery: "SELECT 1", + PostInstallScript: "world", + InstallerFile: bytes.NewReader([]byte("hello")), + StorageID: "storage1", + Filename: "file1", + Title: "file1", + Version: "1.0", + Source: "apps", + UserID: user1.ID, + }) + require.NoError(t, err) + policy2.SoftwareInstallerID = ptr.Uint(installerID) + err = ds.SavePolicy(context.Background(), policy2, false, false) + require.NoError(t, err) + require.NoError(t, ds.RecordPolicyQueryExecutions(context.Background(), host1, map[uint]*bool{policy1.ID: ptr.Bool(true)}, time.Now(), false)) require.NoError(t, ds.UpdateHostPolicyCounts(context.Background())) @@ -1230,9 +1254,12 @@ func testPoliciesByID(t *testing.T, ds *Datastore) { assert.Equal(t, len(policiesByID), 2) assert.Equal(t, policiesByID[1].ID, policy1.ID) assert.Equal(t, policiesByID[1].Name, policy1.Name) + assert.Nil(t, policiesByID[1].SoftwareInstallerID) + assert.Equal(t, uint(1), policiesByID[1].PassingHostCount) assert.Equal(t, policiesByID[2].ID, uint(2)) assert.Equal(t, policiesByID[2].Name, "policy2") - assert.Equal(t, uint(1), policiesByID[1].PassingHostCount) + assert.NotNil(t, policiesByID[2].SoftwareInstallerID) + assert.Equal(t, uint(1), *policiesByID[2].SoftwareInstallerID) _, err = ds.PoliciesByID(context.Background(), []uint{1, 2, 3}) require.Error(t, err) @@ -3875,3 +3902,315 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) { require.Equal(t, "serial2", hostsTeam2[0].HostHardwareSerial) require.Equal(t, "display_name2", hostsTeam2[0].HostDisplayName) } + +func testNewGlobalPolicyWithInstaller(t *testing.T, ds *Datastore) { + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + _, err := ds.NewGlobalPolicy(context.Background(), &user1.ID, fleet.PolicyPayload{ + Query: "SELECT 1;", + SoftwareInstallerID: ptr.Uint(1), + }) + require.Error(t, err) + require.ErrorIs(t, err, errSoftwareTitleIDOnGlobalPolicy) +} + +func testTeamPoliciesWithInstaller(t *testing.T, ds *Datastore) { + ctx := context.Background() + + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"}) // team2 has no policies + require.NoError(t, err) + + // Policy p1 has no associated installer. + p1, err := ds.NewTeamPolicy(ctx, team1.ID, &user1.ID, fleet.PolicyPayload{ + Name: "p1", + Query: "SELECT 1;", + SoftwareInstallerID: nil, + }) + require.NoError(t, err) + // Create and associate an installer to p2. + installerID, err := ds.MatchOrCreateSoftwareInstaller(context.Background(), &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "hello", + PreInstallQuery: "SELECT 1", + PostInstallScript: "world", + InstallerFile: bytes.NewReader([]byte("hello")), + StorageID: "storage1", + Filename: "file1", + Title: "file1", + Version: "1.0", + Source: "apps", + UserID: user1.ID, + }) + require.NoError(t, err) + require.Nil(t, p1.SoftwareInstallerID) + p2, err := ds.NewTeamPolicy(ctx, team1.ID, &user1.ID, fleet.PolicyPayload{ + Name: "p2", + Query: "SELECT 1;", + SoftwareInstallerID: ptr.Uint(installerID), + }) + require.NoError(t, err) + require.NotNil(t, p2.SoftwareInstallerID) + require.Equal(t, installerID, *p2.SoftwareInstallerID) + // Create p3 as global policy. + _, err = ds.NewGlobalPolicy(ctx, &user1.ID, fleet.PolicyPayload{ + Name: "p3", + Query: "SELECT 1;", + }) + require.NoError(t, err) + + p2, err = ds.Policy(ctx, p2.ID) + require.NoError(t, err) + require.NotNil(t, p2.SoftwareInstallerID) + require.Equal(t, installerID, *p2.SoftwareInstallerID) + + policiesWithInstallers, err := ds.GetPoliciesWithAssociatedInstaller(ctx, team1.ID, []uint{}) + require.NoError(t, err) + require.Empty(t, policiesWithInstallers) + + // p1 has no associated installers. + policiesWithInstallers, err = ds.GetPoliciesWithAssociatedInstaller(ctx, team1.ID, []uint{p1.ID}) + require.NoError(t, err) + require.Empty(t, policiesWithInstallers) + + policiesWithInstallers, err = ds.GetPoliciesWithAssociatedInstaller(ctx, team1.ID, []uint{p2.ID}) + require.NoError(t, err) + require.Len(t, policiesWithInstallers, 1) + require.Equal(t, p2.ID, policiesWithInstallers[0].ID) + require.Equal(t, installerID, policiesWithInstallers[0].InstallerID) + + // p2 has associated installer but belongs to team1. + policiesWithInstallers, err = ds.GetPoliciesWithAssociatedInstaller(ctx, team2.ID, []uint{p2.ID}) + require.NoError(t, err) + require.Empty(t, policiesWithInstallers) + + p1.SoftwareInstallerID = ptr.Uint(installerID) + err = ds.SavePolicy(ctx, p1, false, false) + require.NoError(t, err) + + p2, err = ds.Policy(ctx, p2.ID) + require.NoError(t, err) + require.NotNil(t, p2.SoftwareInstallerID) + require.Equal(t, installerID, *p2.SoftwareInstallerID) + + policiesWithInstallers, err = ds.GetPoliciesWithAssociatedInstaller(ctx, team1.ID, []uint{p1.ID, p2.ID}) + require.NoError(t, err) + require.Len(t, policiesWithInstallers, 2) + require.Equal(t, p1.ID, policiesWithInstallers[0].ID) + require.Equal(t, installerID, policiesWithInstallers[0].InstallerID) + require.Equal(t, p2.ID, policiesWithInstallers[1].ID) + require.Equal(t, installerID, policiesWithInstallers[1].InstallerID) + + policiesWithInstallers, err = ds.GetPoliciesWithAssociatedInstaller(ctx, team2.ID, []uint{p1.ID, p2.ID}) + require.NoError(t, err) + require.Empty(t, policiesWithInstallers) +} + +func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) { + ctx := context.Background() + + user1 := test.NewUser(t, ds, "User1", "user1@example.com", true) + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"}) + require.NoError(t, err) + installer1ID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "hello", + PreInstallQuery: "SELECT 1;", + PostInstallScript: "world1", + InstallerFile: bytes.NewReader([]byte("hello1")), + StorageID: "storage1", + Filename: "file1", + Title: "file1", + Version: "1.0", + Source: "apps", + UserID: user1.ID, + TeamID: &team1.ID, + }) + require.NoError(t, err) + installer1, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer1ID) + require.NoError(t, err) + require.NotNil(t, installer1.TitleID) + installer2ID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "hello2", + PreInstallQuery: "SELECT 2;", + PostInstallScript: "world2", + InstallerFile: bytes.NewReader([]byte("hello2")), + StorageID: "storage2", + Filename: "file2", + Title: "file2", + Version: "1.0", + Source: "deb_packages", + UserID: user1.ID, + TeamID: &team2.ID, + }) + require.NoError(t, err) + installer2, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer2ID) + require.NoError(t, err) + require.NotNil(t, installer2.TitleID) + + // Installers cannot be assigned to global policies. + err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{ + { + Name: "Global policy", + Query: "SELECT 1;", + Description: "Description", + Resolution: "Resolution", + Team: "", + Platform: "darwin", + SoftwareTitleID: installer1.TitleID, + }, + }) + require.Error(t, err) + require.ErrorIs(t, err, errSoftwareTitleIDOnGlobalPolicy) + + // Apply two team policies associated to two installers. + err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{ + { + Name: "Team policy 1", + Query: "SELECT 1;", + Description: "Description 1", + Resolution: "Resolution 1", + Team: "team1", + Platform: "darwin", + SoftwareTitleID: installer1.TitleID, + }, + { + Name: "Team policy 2", + Query: "SELECT 2;", + Description: "Description 2", + Resolution: "Resolution 2", + Team: "team2", + Platform: "linux", + SoftwareTitleID: installer2.TitleID, + }, + }) + require.NoError(t, err) + team1Policies, _, err := ds.ListTeamPolicies(ctx, team1.ID, fleet.ListOptions{}, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, team1Policies, 1) + require.NotNil(t, team1Policies[0].SoftwareInstallerID) + require.Equal(t, installer1.InstallerID, *team1Policies[0].SoftwareInstallerID) + team2Policies, _, err := ds.ListTeamPolicies(ctx, team2.ID, fleet.ListOptions{}, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, team2Policies, 1) + require.NotNil(t, team2Policies[0].SoftwareInstallerID) + require.Equal(t, installer2.InstallerID, *team2Policies[0].SoftwareInstallerID) + + // Unset software installer from "Team policy 1". + err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{ + { + Name: "Team policy 1", + Query: "SELECT 1;", + Description: "Description 1", + Resolution: "Resolution 1", + Team: "team1", + Platform: "darwin", + SoftwareTitleID: nil, + }, + }) + require.NoError(t, err) + team1Policies, _, err = ds.ListTeamPolicies(ctx, team1.ID, fleet.ListOptions{}, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, team1Policies, 1) + require.Nil(t, team1Policies[0].SoftwareInstallerID) + + // Set software installer "Team policy 1" to a software installer on team2. + err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{ + { + Name: "Team policy 1", + Query: "SELECT 1;", + Description: "Description 1", + Resolution: "Resolution 1", + Team: "team1", + Platform: "darwin", + SoftwareTitleID: installer2.TitleID, + }, + }) + require.Error(t, err) + var notFoundErr *notFoundError + require.ErrorAs(t, err, ¬FoundErr) + + // Set software installer "Team policy 1" to a software title that doesn't exist. + err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{ + { + Name: "Team policy 1", + Query: "SELECT 1;", + Description: "Description 1", + Resolution: "Resolution 1", + Team: "team1", + Platform: "darwin", + SoftwareTitleID: ptr.Uint(999_999), + }, + }) + require.Error(t, err) + require.ErrorAs(t, err, ¬FoundErr) + + // Unset software installer from "Team policy 2" using 0. + err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{ + { + Name: "Team policy 2", + Query: "SELECT 2;", + Description: "Description 2", + Resolution: "Resolution 2", + Team: "team2", + Platform: "linux", + SoftwareTitleID: ptr.Uint(0), + }, + }) + require.NoError(t, err) + team2Policies, _, err = ds.ListTeamPolicies(ctx, team2.ID, fleet.ListOptions{}, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, team2Policies, 1) + require.Nil(t, team2Policies[0].SoftwareInstallerID) + + // Apply team policies associated to two installers (again, with two installers with the same title). + installer3ID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "hello3", + PreInstallQuery: "SELECT 3;", + PostInstallScript: "world3", + InstallerFile: bytes.NewReader([]byte("hello3")), + StorageID: "storage3", + Filename: "file1", + Title: "file1", // same title as installer1. + Version: "1.0", + Source: "apps", + UserID: user1.ID, + TeamID: &team2.ID, + }) + require.NoError(t, err) + installer3, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer3ID) + require.NoError(t, err) + require.NotNil(t, installer2.TitleID) + err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{ + { + Name: "Team policy 1", + Query: "SELECT 1;", + Description: "Description 1", + Resolution: "Resolution 1", + Team: "team1", + Platform: "darwin", + SoftwareTitleID: installer1.TitleID, + }, + { + Name: "Team policy 2", + Query: "SELECT 2;", + Description: "Description 2", + Resolution: "Resolution 2", + Team: "team2", + Platform: "linux", + SoftwareTitleID: installer3.TitleID, + }, + }) + require.NoError(t, err) + team1Policies, _, err = ds.ListTeamPolicies(ctx, team1.ID, fleet.ListOptions{}, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, team1Policies, 1) + require.NotNil(t, team1Policies[0].SoftwareInstallerID) + require.Equal(t, installer1.InstallerID, *team1Policies[0].SoftwareInstallerID) + team2Policies, _, err = ds.ListTeamPolicies(ctx, team2.ID, fleet.ListOptions{}, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, team2Policies, 1) + require.NotNil(t, team2Policies[0].SoftwareInstallerID) + require.Equal(t, installer3.InstallerID, *team2Policies[0].SoftwareInstallerID) +} diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 0055f0de1d52..595878a83909 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -810,10 +810,12 @@ CREATE TABLE `mdm_apple_declarations` ( `checksum` binary(16) NOT NULL, `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `uploaded_at` timestamp NULL DEFAULT NULL, + `auto_increment` bigint NOT NULL AUTO_INCREMENT, PRIMARY KEY (`declaration_uuid`), UNIQUE KEY `idx_mdm_apple_declaration_team_identifier` (`team_id`,`identifier`), - UNIQUE KEY `idx_mdm_apple_declaration_team_name` (`team_id`,`name`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + UNIQUE KEY `idx_mdm_apple_declaration_team_name` (`team_id`,`name`), + UNIQUE KEY `auto_increment` (`auto_increment`) +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; @@ -994,8 +996,10 @@ CREATE TABLE `mdm_windows_configuration_profiles` ( `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `uploaded_at` timestamp NULL DEFAULT NULL, `profile_uuid` varchar(37) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `auto_increment` bigint NOT NULL AUTO_INCREMENT, PRIMARY KEY (`profile_uuid`), - UNIQUE KEY `idx_mdm_windows_configuration_profiles_team_id_name` (`team_id`,`name`) + UNIQUE KEY `idx_mdm_windows_configuration_profiles_team_id_name` (`team_id`,`name`), + UNIQUE KEY `auto_increment` (`auto_increment`) ) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; @@ -1030,9 +1034,9 @@ CREATE TABLE `migration_status_tables` ( `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `id` (`id`) -) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=307 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=311 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'); +INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `mobile_device_management_solutions` ( @@ -1380,11 +1384,14 @@ CREATE TABLE `policies` ( `critical` tinyint(1) NOT NULL DEFAULT '0', `checksum` binary(16) NOT NULL, `calendar_events_enabled` tinyint unsigned NOT NULL DEFAULT '0', + `software_installer_id` int unsigned DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `idx_policies_checksum` (`checksum`), KEY `idx_policies_author_id` (`author_id`), KEY `idx_policies_team_id` (`team_id`), + KEY `fk_policies_software_installer_id` (`software_installer_id`), CONSTRAINT `policies_ibfk_2` FOREIGN KEY (`team_id`) REFERENCES `teams` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `policies_ibfk_3` FOREIGN KEY (`software_installer_id`) REFERENCES `software_installers` (`id`), CONSTRAINT `policies_queries_ibfk_1` FOREIGN KEY (`author_id`) REFERENCES `users` (`id`) ON DELETE SET NULL ) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; @@ -1667,6 +1674,10 @@ CREATE TABLE `software_installers` ( `storage_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, `uploaded_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `self_service` tinyint(1) NOT NULL DEFAULT '0', + `user_id` int unsigned DEFAULT NULL, + `user_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `user_email` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', PRIMARY KEY (`id`), UNIQUE KEY `idx_software_installers_team_id_title_id` (`global_or_team_id`,`title_id`), KEY `fk_software_installers_title` (`title_id`), @@ -1674,10 +1685,12 @@ CREATE TABLE `software_installers` ( KEY `fk_software_installers_post_install_script_content_id` (`post_install_script_content_id`), KEY `fk_software_installers_team_id` (`team_id`), KEY `idx_software_installers_platform_title_id` (`platform`,`title_id`), + KEY `fk_software_installers_user_id` (`user_id`), CONSTRAINT `fk_software_installers_install_script_content_id` FOREIGN KEY (`install_script_content_id`) REFERENCES `script_contents` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT `fk_software_installers_post_install_script_content_id` FOREIGN KEY (`post_install_script_content_id`) REFERENCES `script_contents` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT `fk_software_installers_team_id` FOREIGN KEY (`team_id`) REFERENCES `teams` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT `fk_software_installers_title` FOREIGN KEY (`title_id`) REFERENCES `software_titles` (`id`) ON DELETE SET NULL ON UPDATE CASCADE + CONSTRAINT `fk_software_installers_title` FOREIGN KEY (`title_id`) REFERENCES `software_titles` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT `fk_software_installers_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL ) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; @@ -1791,10 +1804,13 @@ CREATE TABLE `vpp_apps_teams` ( `global_or_team_id` int NOT NULL DEFAULT '0', `platform` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, `self_service` tinyint(1) NOT NULL DEFAULT '0', + `vpp_token_id` int unsigned NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `idx_global_or_team_id_adam_id` (`global_or_team_id`,`adam_id`,`platform`), KEY `team_id` (`team_id`), KEY `adam_id` (`adam_id`,`platform`), + KEY `fk_vpp_apps_teams_vpp_token_id` (`vpp_token_id`), + CONSTRAINT `fk_vpp_apps_teams_vpp_token_id` FOREIGN KEY (`vpp_token_id`) REFERENCES `vpp_tokens` (`id`) ON DELETE CASCADE, CONSTRAINT `vpp_apps_teams_ibfk_2` FOREIGN KEY (`team_id`) REFERENCES `teams` (`id`) ON DELETE CASCADE, CONSTRAINT `vpp_apps_teams_ibfk_3` FOREIGN KEY (`adam_id`, `platform`) REFERENCES `vpp_apps` (`adam_id`, `platform`) ON DELETE CASCADE ) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/server/datastore/mysql/scripts_test.go b/server/datastore/mysql/scripts_test.go index b3ccd430a416..5543a7661d2e 100644 --- a/server/datastore/mysql/scripts_test.go +++ b/server/datastore/mysql/scripts_test.go @@ -1138,6 +1138,8 @@ func testCleanupUnusedScriptContents(t *testing.T, ds *Datastore) { s, err := ds.NewScript(ctx, s) require.NoError(t, err) + user1 := test.NewUser(t, ds, "Bob", "bob@example.com", true) + // create a sync script execution res, err := ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{ScriptContents: "echo something_else", SyncRequest: true}) require.NoError(t, err) @@ -1153,6 +1155,7 @@ func testCleanupUnusedScriptContents(t *testing.T, ds *Datastore) { Title: "file1", Version: "1.0", Source: "apps", + UserID: user1.ID, }) require.NoError(t, err) @@ -1207,6 +1210,7 @@ func testCleanupUnusedScriptContents(t *testing.T, ds *Datastore) { Title: "file1", Version: "1.0", Source: "apps", + UserID: user1.ID, }) require.NoError(t, err) diff --git a/server/datastore/mysql/software.go b/server/datastore/mysql/software.go index d3c79f7e2598..4826e0cebaeb 100644 --- a/server/datastore/mysql/software.go +++ b/server/datastore/mysql/software.go @@ -2020,7 +2020,7 @@ func (ds *Datastore) InsertSoftwareVulnerability( return false, ctxerr.Wrap(ctx, err, "insert software vulnerability") } - return insertOnDuplicateDidInsert(res), nil + return insertOnDuplicateDidInsertOrUpdate(res), nil } func (ds *Datastore) ListSoftwareVulnerabilitiesByHostIDsSource( diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index 31b6ecb60b8b..21d5a0aa9538 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -117,8 +117,11 @@ INSERT INTO software_installers ( pre_install_query, post_install_script_content_id, platform, - self_service -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + self_service, + user_id, + user_name, + user_email +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, (SELECT name FROM users WHERE id = ?), (SELECT email FROM users WHERE id = ?))` args := []interface{}{ tid, @@ -132,6 +135,9 @@ INSERT INTO software_installers ( postInstallScriptID, payload.Platform, payload.SelfService, + payload.UserID, + payload.UserID, + payload.UserID, } res, err := ds.writer(ctx).ExecContext(ctx, stmt, args...) @@ -210,7 +216,8 @@ SELECT si.pre_install_query, si.post_install_script_content_id, si.uploaded_at, - COALESCE(st.name, '') AS software_title + COALESCE(st.name, '') AS software_title, + si.platform FROM software_installers si LEFT OUTER JOIN software_titles st ON st.id = si.title_id @@ -277,9 +284,21 @@ WHERE return &dest, nil } +var errDeleteInstallerWithAssociatedPolicy = errors.New("Couldn't delete. Policy automation uses this software. Please disable policy automation for this software and try again.") + func (ds *Datastore) DeleteSoftwareInstaller(ctx context.Context, id uint) error { res, err := ds.writer(ctx).ExecContext(ctx, `DELETE FROM software_installers WHERE id = ?`, id) if err != nil { + if isMySQLForeignKey(err) { + // Check if the software installer is referenced by a policy automation. + var count int + if err := sqlx.GetContext(ctx, ds.reader(ctx), &count, `SELECT COUNT(*) FROM policies WHERE software_installer_id = ?`, id); err != nil { + return ctxerr.Wrapf(ctx, err, "getting reference from policies") + } + if count > 0 { + return ctxerr.Wrap(ctx, errDeleteInstallerWithAssociatedPolicy, "delete software installer") + } + } return ctxerr.Wrap(ctx, err, "delete software installer") } @@ -345,8 +364,11 @@ SELECT hsi.user_id AS user_id, hsi.post_install_script_exit_code, hsi.install_script_exit_code, - hsi.self_service, - hsi.host_deleted_at + hsi.self_service, + hsi.host_deleted_at, + si.user_id AS software_installer_user_id, + si.user_name AS software_installer_user_name, + si.user_email AS software_installer_user_email FROM host_software_installs hsi JOIN software_installers si ON si.id = hsi.software_installer_id @@ -485,6 +507,41 @@ WHERE }) } +func (ds *Datastore) GetHostLastInstallData(ctx context.Context, hostID, installerID uint) (*fleet.HostLastInstallData, error) { + stmt := fmt.Sprintf(` + SELECT execution_id, %s AS status + FROM host_software_installs hsi + WHERE hsi.id = ( + SELECT + MAX(id) + FROM host_software_installs + WHERE + software_installer_id = :installer_id AND host_id = :host_id + GROUP BY + host_id, software_installer_id) +`, softwareInstallerHostStatusNamedQuery("hsi", "")) + + stmt, args, err := sqlx.Named(stmt, map[string]interface{}{ + "host_id": hostID, + "installer_id": installerID, + "software_status_installed": fleet.SoftwareInstallerInstalled, + "software_status_failed": fleet.SoftwareInstallerFailed, + "software_status_pending": fleet.SoftwareInstallerPending, + }) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "build named query to get host last install data") + } + + var hostLastInstall fleet.HostLastInstallData + if err := sqlx.GetContext(ctx, ds.reader(ctx), &hostLastInstall, stmt, args...); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, ctxerr.Wrap(ctx, err, "get host last install data") + } + return &hostLastInstall, nil +} + func (ds *Datastore) CleanupUnusedSoftwareInstallers(ctx context.Context, softwareInstallStore fleet.SoftwareInstallerStore, removeCreatedBefore time.Time) error { if softwareInstallStore == nil { // no-op in this case, possible if not running with a Premium license @@ -501,7 +558,7 @@ func (ds *Datastore) CleanupUnusedSoftwareInstallers(ctx context.Context, softwa return ctxerr.Wrap(ctx, err, "cleanup unused software installers") } -func (ds *Datastore) BatchSetSoftwareInstallers(ctx context.Context, tmID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { +func (ds *Datastore) BatchSetSoftwareInstallers(ctx context.Context, tmID *uint, installers []*fleet.UploadSoftwareInstallerPayload) ([]fleet.SoftwareInstaller, error) { const upsertSoftwareTitles = ` INSERT INTO software_titles (name, source, browser) @@ -520,6 +577,16 @@ FROM software_titles WHERE (name, source, browser) IN (%s) ` + + const unsetAllInstallersFromPolicies = ` +UPDATE + policies +SET + software_installer_id = NULL +WHERE + team_id = ? +` + const deleteAllInstallersInTeam = ` DELETE FROM software_installers @@ -527,6 +594,19 @@ WHERE global_or_team_id = ? ` + const unsetInstallersNotInListFromPolicies = ` +UPDATE + policies +SET + software_installer_id = NULL +WHERE + software_installer_id IN ( + SELECT id FROM software_installers + WHERE global_or_team_id = ? AND + title_id NOT IN (?) + ) +` + const deleteInstallersNotInList = ` DELETE FROM software_installers @@ -547,10 +627,15 @@ INSERT INTO software_installers ( post_install_script_content_id, platform, self_service, - title_id + title_id, + user_id, + user_name, + user_email, + url ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - (SELECT id FROM software_titles WHERE name = ? AND source = ? AND browser = '') + (SELECT id FROM software_titles WHERE name = ? AND source = ? AND browser = ''), + ?, (SELECT name FROM users WHERE id = ?), (SELECT email FROM users WHERE id = ?), ? ) ON DUPLICATE KEY UPDATE install_script_content_id = VALUES(install_script_content_id), @@ -560,7 +645,30 @@ ON DUPLICATE KEY UPDATE version = VALUES(version), pre_install_query = VALUES(pre_install_query), platform = VALUES(platform), - self_service = VALUES(self_service) + self_service = VALUES(self_service), + user_id = VALUES(user_id), + user_name = VALUES(user_name), + user_email = VALUES(user_email), + url = VALUES(url) +` + + const loadInsertedSoftwareInstallers = ` +SELECT + id, + team_id, + storage_id, + filename, + version, + install_script_content_id, + pre_install_query, + post_install_script_content_id, + platform, + self_service, + title_id, + url +FROM + software_installers +WHERE global_or_team_id = ? ` // use a team id of 0 if no-team @@ -569,12 +677,18 @@ ON DUPLICATE KEY UPDATE globalOrTeamID = *tmID } - return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + var insertedSoftwareInstallers []fleet.SoftwareInstaller + if err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { // if no installers are provided, just delete whatever was in // the table if len(installers) == 0 { - _, err := tx.ExecContext(ctx, deleteAllInstallersInTeam, globalOrTeamID) - return ctxerr.Wrap(ctx, err, "delete obsolete software installers") + if _, err := tx.ExecContext(ctx, unsetAllInstallersFromPolicies, globalOrTeamID); err != nil { + return ctxerr.Wrap(ctx, err, "unset all obsolete installers in policies") + } + if _, err := tx.ExecContext(ctx, deleteAllInstallersInTeam, globalOrTeamID); err != nil { + return ctxerr.Wrap(ctx, err, "delete obsolete software installers") + } + return nil } var args []any @@ -595,7 +709,15 @@ ON DUPLICATE KEY UPDATE return ctxerr.Wrap(ctx, err, "load existing titles") } - stmt, args, err := sqlx.In(deleteInstallersNotInList, globalOrTeamID, titleIDs) + stmt, args, err := sqlx.In(unsetInstallersNotInListFromPolicies, globalOrTeamID, titleIDs) + if err != nil { + return ctxerr.Wrap(ctx, err, "build statement to unset obsolete installers from policies") + } + if _, err := tx.ExecContext(ctx, stmt, args...); err != nil { + return ctxerr.Wrap(ctx, err, "unset obsolete software installers from policies") + } + + stmt, args, err = sqlx.In(deleteInstallersNotInList, globalOrTeamID, titleIDs) if err != nil { return ctxerr.Wrap(ctx, err, "build statement to delete obsolete installers") } @@ -634,14 +756,27 @@ ON DUPLICATE KEY UPDATE installer.SelfService, installer.Title, installer.Source, + installer.UserID, + installer.UserID, + installer.UserID, + installer.URL, } if _, err := tx.ExecContext(ctx, insertNewOrEditedInstaller, args...); err != nil { return ctxerr.Wrapf(ctx, err, "insert new/edited installer with name %q", installer.Filename) } + } + + if err := sqlx.SelectContext(ctx, tx, &insertedSoftwareInstallers, loadInsertedSoftwareInstallers, globalOrTeamID); err != nil { + return ctxerr.Wrap(ctx, err, "load inserted software installers") + } + return nil - }) + }); err != nil { + return nil, err + } + return insertedSoftwareInstallers, nil } func (ds *Datastore) HasSelfServiceSoftwareInstallers(ctx context.Context, hostPlatform string, hostTeamID *uint) (bool, error) { diff --git a/server/datastore/mysql/software_installers_test.go b/server/datastore/mysql/software_installers_test.go index 2b589708f3cc..0da31b41eeec 100644 --- a/server/datastore/mysql/software_installers_test.go +++ b/server/datastore/mysql/software_installers_test.go @@ -31,6 +31,8 @@ func TestSoftwareInstallers(t *testing.T) { {"BatchSetSoftwareInstallers", testBatchSetSoftwareInstallers}, {"GetSoftwareInstallerMetadataByTeamAndTitleID", testGetSoftwareInstallerMetadataByTeamAndTitleID}, {"HasSelfServiceSoftwareInstallers", testHasSelfServiceSoftwareInstallers}, + {"DeleteSoftwareInstallersAssignedToPolicy", testDeleteSoftwareInstallersAssignedToPolicy}, + {"GetHostLastInstallData", testGetHostLastInstallData}, } for _, c := range cases { @@ -46,6 +48,7 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) { host1 := test.NewHost(t, ds, "host1", "1", "host1key", "host1uuid", time.Now()) host2 := test.NewHost(t, ds, "host2", "2", "host2key", "host2uuid", time.Now()) + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) installerID1, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ InstallScript: "hello", @@ -57,6 +60,7 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) { Title: "file1", Version: "1.0", Source: "apps", + UserID: user1.ID, }) require.NoError(t, err) @@ -70,6 +74,7 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) { Title: "file2", Version: "2.0", Source: "apps", + UserID: user1.ID, }) require.NoError(t, err) @@ -84,6 +89,7 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) { Version: "3.0", Source: "apps", SelfService: true, + UserID: user1.ID, }) require.NoError(t, err) @@ -169,6 +175,8 @@ func testSoftwareInstallRequests(t *testing.T, ds *Datastore) { team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 2"}) require.NoError(t, err) + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + cases := map[string]*uint{ "no team": nil, "team": &team.ID, @@ -188,6 +196,7 @@ func testSoftwareInstallRequests(t *testing.T, ds *Datastore) { InstallScript: "echo", TeamID: teamID, Filename: "foo.pkg", + UserID: user1.ID, }) require.NoError(t, err) installerMeta, err := ds.GetSoftwareInstallerMetadataByID(ctx, installerID) @@ -249,6 +258,8 @@ func testGetSoftwareInstallResult(t *testing.T, ds *Datastore) { require.NoError(t, err) teamID := team.ID + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + for _, tc := range []struct { name string expectedStatus fleet.SoftwareInstallerStatus @@ -295,6 +306,7 @@ func testGetSoftwareInstallResult(t *testing.T, ds *Datastore) { InstallScript: "echo " + tc.name, TeamID: &teamID, Filename: swFilename, + UserID: user1.ID, }) require.NoError(t, err) host, err := ds.NewHost(ctx, &fleet.Host{ @@ -342,6 +354,8 @@ func testCleanupUnusedSoftwareInstallers(t *testing.T, ds *Datastore) { store, err := filesystem.NewSoftwareInstallerStore(dir) require.NoError(t, err) + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + assertExisting := func(want []string) { dirEnts, err := os.ReadDir(filepath.Join(dir, "software-installers")) require.NoError(t, err) @@ -373,6 +387,7 @@ func testCleanupUnusedSoftwareInstallers(t *testing.T, ds *Datastore) { Filename: "installer0", Title: "ins0", Source: "apps", + UserID: user1.ID, }) require.NoError(t, err) @@ -403,6 +418,8 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) { team, err := ds.NewTeam(ctx, &fleet.Team{Name: t.Name()}) require.NoError(t, err) + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + // TODO(roberto): perform better assertions, we should have evertything // to check that the actual values of everything match. assertSoftware := func(wantTitles []fleet.SoftwareTitle) { @@ -423,17 +440,19 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) { } // batch set with everything empty - err = ds.BatchSetSoftwareInstallers(ctx, &team.ID, nil) + softwareInstallers, err := ds.BatchSetSoftwareInstallers(ctx, &team.ID, nil) require.NoError(t, err) + require.Empty(t, softwareInstallers) assertSoftware(nil) - err = ds.BatchSetSoftwareInstallers(ctx, &team.ID, []*fleet.UploadSoftwareInstallerPayload{}) + softwareInstallers, err = ds.BatchSetSoftwareInstallers(ctx, &team.ID, []*fleet.UploadSoftwareInstallerPayload{}) require.NoError(t, err) + require.Empty(t, softwareInstallers) assertSoftware(nil) // add a single installer ins0 := "installer0" ins0File := bytes.NewReader([]byte("installer0")) - err = ds.BatchSetSoftwareInstallers(ctx, &team.ID, []*fleet.UploadSoftwareInstallerPayload{{ + softwareInstallers, err = ds.BatchSetSoftwareInstallers(ctx, &team.ID, []*fleet.UploadSoftwareInstallerPayload{{ InstallScript: "install", InstallerFile: ins0File, StorageID: ins0, @@ -442,8 +461,14 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) { Source: "apps", Version: "1", PreInstallQuery: "foo", + UserID: user1.ID, + Platform: "darwin", }}) require.NoError(t, err) + require.Len(t, softwareInstallers, 1) + require.Equal(t, ins0, softwareInstallers[0].Name) + require.NotNil(t, softwareInstallers[0].TitleID) + require.Equal(t, "darwin", softwareInstallers[0].Platform) assertSoftware([]fleet.SoftwareTitle{ {Name: ins0, Source: "apps", Browser: ""}, }) @@ -451,7 +476,7 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) { // add a new installer + ins0 installer ins1 := "installer1" ins1File := bytes.NewReader([]byte("installer1")) - err = ds.BatchSetSoftwareInstallers(ctx, &team.ID, []*fleet.UploadSoftwareInstallerPayload{ + softwareInstallers, err = ds.BatchSetSoftwareInstallers(ctx, &team.ID, []*fleet.UploadSoftwareInstallerPayload{ { InstallScript: "install", InstallerFile: ins0File, @@ -461,6 +486,8 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) { Source: "apps", Version: "1", PreInstallQuery: "select 0 from foo;", + UserID: user1.ID, + Platform: "darwin", }, { InstallScript: "install", @@ -472,16 +499,25 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) { Source: "apps", Version: "2", PreInstallQuery: "select 1 from bar;", + UserID: user1.ID, + Platform: "darwin", }, }) require.NoError(t, err) + require.Len(t, softwareInstallers, 2) + require.Equal(t, ins0, softwareInstallers[0].Name) + require.NotNil(t, softwareInstallers[0].TitleID) + require.Equal(t, "darwin", softwareInstallers[0].Platform) + require.Equal(t, ins1, softwareInstallers[1].Name) + require.NotNil(t, softwareInstallers[1].TitleID) + require.Equal(t, "darwin", softwareInstallers[1].Platform) assertSoftware([]fleet.SoftwareTitle{ {Name: ins0, Source: "apps", Browser: ""}, {Name: ins1, Source: "apps", Browser: ""}, }) // remove ins0 - err = ds.BatchSetSoftwareInstallers(ctx, &team.ID, []*fleet.UploadSoftwareInstallerPayload{ + softwareInstallers, err = ds.BatchSetSoftwareInstallers(ctx, &team.ID, []*fleet.UploadSoftwareInstallerPayload{ { InstallScript: "install", PostInstallScript: "post-install", @@ -492,16 +528,21 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) { Source: "apps", Version: "2", PreInstallQuery: "select 1 from bar;", + UserID: user1.ID, }, }) require.NoError(t, err) + require.Len(t, softwareInstallers, 1) + require.Equal(t, ins1, softwareInstallers[0].Name) + require.NotNil(t, softwareInstallers[0].TitleID) assertSoftware([]fleet.SoftwareTitle{ {Name: ins1, Source: "apps", Browser: ""}, }) // remove everything - err = ds.BatchSetSoftwareInstallers(ctx, &team.ID, []*fleet.UploadSoftwareInstallerPayload{}) + softwareInstallers, err = ds.BatchSetSoftwareInstallers(ctx, &team.ID, []*fleet.UploadSoftwareInstallerPayload{}) require.NoError(t, err) + require.Empty(t, softwareInstallers) assertSoftware([]fleet.SoftwareTitle{}) } @@ -509,6 +550,7 @@ func testGetSoftwareInstallerMetadataByTeamAndTitleID(t *testing.T, ds *Datastor ctx := context.Background() team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 2"}) require.NoError(t, err) + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) installerID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ Title: "foo", @@ -518,10 +560,13 @@ func testGetSoftwareInstallerMetadataByTeamAndTitleID(t *testing.T, ds *Datastor PreInstallQuery: "SELECT 1", TeamID: &team.ID, Filename: "foo.pkg", + Platform: "darwin", + UserID: user1.ID, }) require.NoError(t, err) installerMeta, err := ds.GetSoftwareInstallerMetadataByID(ctx, installerID) require.NoError(t, err) + require.Equal(t, "darwin", installerMeta.Platform) metaByTeamAndTitle, err := ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, &team.ID, *installerMeta.TitleID, true) require.NoError(t, err) @@ -536,6 +581,7 @@ func testGetSoftwareInstallerMetadataByTeamAndTitleID(t *testing.T, ds *Datastor InstallScript: "echo install", TeamID: &team.ID, Filename: "foo.pkg", + UserID: user1.ID, }) require.NoError(t, err) installerMeta, err = ds.GetSoftwareInstallerMetadataByID(ctx, installerID) @@ -554,6 +600,9 @@ func testHasSelfServiceSoftwareInstallers(t *testing.T, ds *Datastore) { team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 2"}) require.NoError(t, err) + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + + test.CreateInsertGlobalVPPToken(t, ds) const platform = "linux" // No installers @@ -573,6 +622,7 @@ func testHasSelfServiceSoftwareInstallers(t *testing.T, ds *Datastore) { Filename: "foo.pkg", Platform: platform, SelfService: false, + UserID: user1.ID, }) require.NoError(t, err) hasSelfService, err = ds.HasSelfServiceSoftwareInstallers(ctx, platform, nil) @@ -591,6 +641,7 @@ func testHasSelfServiceSoftwareInstallers(t *testing.T, ds *Datastore) { Filename: "foo2.pkg", Platform: platform, SelfService: true, + UserID: user1.ID, }) require.NoError(t, err) hasSelfService, err = ds.HasSelfServiceSoftwareInstallers(ctx, platform, nil) @@ -629,6 +680,7 @@ func testHasSelfServiceSoftwareInstallers(t *testing.T, ds *Datastore) { Filename: "foo global.pkg", Platform: platform, SelfService: true, + UserID: user1.ID, }) require.NoError(t, err) hasSelfService, err = ds.HasSelfServiceSoftwareInstallers(ctx, "ubuntu", nil) @@ -649,3 +701,191 @@ func testHasSelfServiceSoftwareInstallers(t *testing.T, ds *Datastore) { require.NoError(t, err) assert.True(t, hasSelfService) } + +func testDeleteSoftwareInstallersAssignedToPolicy(t *testing.T, ds *Datastore) { + ctx := context.Background() + + dir := t.TempDir() + store, err := filesystem.NewSoftwareInstallerStore(dir) + require.NoError(t, err) + + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + + // put an installer and save it in the DB + ins0 := "installer.pkg" + ins0File := bytes.NewReader([]byte("installer0")) + err = store.Put(ctx, ins0, ins0File) + require.NoError(t, err) + + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + + softwareInstallerID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "install", + InstallerFile: ins0File, + StorageID: ins0, + Filename: "installer.pkg", + Title: "ins0", + Source: "apps", + Platform: "darwin", + TeamID: &team1.ID, + UserID: user1.ID, + }) + require.NoError(t, err) + + p1, err := ds.NewTeamPolicy(ctx, team1.ID, &user1.ID, fleet.PolicyPayload{ + Name: "p1", + Query: "SELECT 1;", + SoftwareInstallerID: &softwareInstallerID, + }) + require.NoError(t, err) + + err = ds.DeleteSoftwareInstaller(ctx, softwareInstallerID) + require.Error(t, err) + require.ErrorIs(t, err, errDeleteInstallerWithAssociatedPolicy) + + _, err = ds.DeleteTeamPolicies(ctx, team1.ID, []uint{p1.ID}) + require.NoError(t, err) + + err = ds.DeleteSoftwareInstaller(ctx, softwareInstallerID) + require.NoError(t, err) +} + +func testGetHostLastInstallData(t *testing.T, ds *Datastore) { + ctx := context.Background() + + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + + host1 := test.NewHost(t, ds, "host1", "1", "host1key", "host1uuid", time.Now(), test.WithTeamID(team1.ID)) + host2 := test.NewHost(t, ds, "host2", "2", "host2key", "host2uuid", time.Now(), test.WithTeamID(team1.ID)) + + dir := t.TempDir() + store, err := filesystem.NewSoftwareInstallerStore(dir) + require.NoError(t, err) + + // put an installer and save it in the DB + ins0 := "installer.pkg" + ins0File := bytes.NewReader([]byte("installer0")) + err = store.Put(ctx, ins0, ins0File) + require.NoError(t, err) + + softwareInstallerID1, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "install", + InstallerFile: ins0File, + StorageID: ins0, + Filename: "installer.pkg", + Title: "ins1", + Source: "apps", + Platform: "darwin", + TeamID: &team1.ID, + UserID: user1.ID, + }) + require.NoError(t, err) + softwareInstallerID2, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "install2", + InstallerFile: ins0File, + StorageID: ins0, + Filename: "installer2.pkg", + Title: "ins2", + Source: "apps", + Platform: "darwin", + TeamID: &team1.ID, + UserID: user1.ID, + }) + require.NoError(t, err) + + // No installations on host1 yet. + host1LastInstall, err := ds.GetHostLastInstallData(ctx, host1.ID, softwareInstallerID1) + require.NoError(t, err) + require.Nil(t, host1LastInstall) + + // Install installer.pkg on host1. + installUUID1, err := ds.InsertSoftwareInstallRequest(ctx, host1.ID, softwareInstallerID1, false) + require.NoError(t, err) + require.NotEmpty(t, installUUID1) + + // Last installation should be pending. + host1LastInstall, err = ds.GetHostLastInstallData(ctx, host1.ID, softwareInstallerID1) + require.NoError(t, err) + require.NotNil(t, host1LastInstall) + require.Equal(t, installUUID1, host1LastInstall.ExecutionID) + require.NotNil(t, host1LastInstall.Status) + require.Equal(t, fleet.SoftwareInstallerPending, *host1LastInstall.Status) + + // Set result of last installation. + err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{ + HostID: host1.ID, + InstallUUID: installUUID1, + + InstallScriptExitCode: ptr.Int(0), + }) + require.NoError(t, err) + + // Last installation should be "installed". + host1LastInstall, err = ds.GetHostLastInstallData(ctx, host1.ID, softwareInstallerID1) + require.NoError(t, err) + require.NotNil(t, host1LastInstall) + require.Equal(t, installUUID1, host1LastInstall.ExecutionID) + require.NotNil(t, host1LastInstall.Status) + require.Equal(t, fleet.SoftwareInstallerInstalled, *host1LastInstall.Status) + + // Install installer2.pkg on host1. + installUUID2, err := ds.InsertSoftwareInstallRequest(ctx, host1.ID, softwareInstallerID2, false) + require.NoError(t, err) + require.NotEmpty(t, installUUID2) + + // Last installation for installer1.pkg should be "installed". + host1LastInstall, err = ds.GetHostLastInstallData(ctx, host1.ID, softwareInstallerID1) + require.NoError(t, err) + require.NotNil(t, host1LastInstall) + require.Equal(t, installUUID1, host1LastInstall.ExecutionID) + require.NotNil(t, host1LastInstall.Status) + require.Equal(t, fleet.SoftwareInstallerInstalled, *host1LastInstall.Status) + // Last installation for installer2.pkg should be "pending". + host1LastInstall, err = ds.GetHostLastInstallData(ctx, host1.ID, softwareInstallerID2) + require.NoError(t, err) + require.NotNil(t, host1LastInstall) + require.Equal(t, installUUID2, host1LastInstall.ExecutionID) + require.NotNil(t, host1LastInstall.Status) + require.Equal(t, fleet.SoftwareInstallerPending, *host1LastInstall.Status) + + // Perform another installation of installer1.pkg. + installUUID3, err := ds.InsertSoftwareInstallRequest(ctx, host1.ID, softwareInstallerID1, false) + require.NoError(t, err) + require.NotEmpty(t, installUUID3) + + // Last installation for installer1.pkg should be "pending" again. + host1LastInstall, err = ds.GetHostLastInstallData(ctx, host1.ID, softwareInstallerID1) + require.NoError(t, err) + require.NotNil(t, host1LastInstall) + require.Equal(t, installUUID3, host1LastInstall.ExecutionID) + require.NotNil(t, host1LastInstall.Status) + require.Equal(t, fleet.SoftwareInstallerPending, *host1LastInstall.Status) + + // Set result of last installer1.pkg installation. + err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{ + HostID: host1.ID, + InstallUUID: installUUID3, + + InstallScriptExitCode: ptr.Int(1), + }) + require.NoError(t, err) + + // Last installation for installer1.pkg should be "failed". + host1LastInstall, err = ds.GetHostLastInstallData(ctx, host1.ID, softwareInstallerID1) + require.NoError(t, err) + require.NotNil(t, host1LastInstall) + require.Equal(t, installUUID3, host1LastInstall.ExecutionID) + require.NotNil(t, host1LastInstall.Status) + require.Equal(t, fleet.SoftwareInstallerFailed, *host1LastInstall.Status) + + // No installations on host2. + host2LastInstall, err := ds.GetHostLastInstallData(ctx, host2.ID, softwareInstallerID1) + require.NoError(t, err) + require.Nil(t, host2LastInstall) + host2LastInstall, err = ds.GetHostLastInstallData(ctx, host2.ID, softwareInstallerID2) + require.NoError(t, err) + require.Nil(t, host2LastInstall) +} diff --git a/server/datastore/mysql/software_test.go b/server/datastore/mysql/software_test.go index e5ff9403ead9..75dfc8d2d04a 100644 --- a/server/datastore/mysql/software_test.go +++ b/server/datastore/mysql/software_test.go @@ -1958,11 +1958,14 @@ func testInsertSoftwareVulnerability(t *testing.T, ds *Datastore) { require.NoError(t, err) require.True(t, inserted) - inserted, err = ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{ + // Sleep so that the updated_at timestamp is guaranteed to be updated. + time.Sleep(1 * time.Second) + insertedOrUpdated, err := ds.InsertSoftwareVulnerability(ctx, fleet.SoftwareVulnerability{ SoftwareID: host.Software[0].ID, CVE: "cve-1", }, fleet.UbuntuOVALSource) require.NoError(t, err) - require.False(t, inserted) + // This will always return true because we always update the timestamp + assert.True(t, insertedOrUpdated) storedVulns, err := ds.ListSoftwareVulnerabilitiesByHostIDsSource(ctx, []uint{host.ID}, fleet.UbuntuOVALSource) require.NoError(t, err) @@ -2001,9 +2004,12 @@ func testInsertSoftwareVulnerability(t *testing.T, ds *Datastore) { require.NoError(t, err) require.True(t, inserted) - inserted, err = ds.InsertSoftwareVulnerability(ctx, vulns[0], fleet.UbuntuOVALSource) + // Sleep so that the updated_at timestamp is guaranteed to be updated. + time.Sleep(1 * time.Second) + insertedOrUpdated, err := ds.InsertSoftwareVulnerability(ctx, vulns[0], fleet.UbuntuOVALSource) require.NoError(t, err) - require.False(t, inserted) + // This will always return true because we always update the timestamp + assert.True(t, insertedOrUpdated) storedVulns, err := ds.ListSoftwareVulnerabilitiesByHostIDsSource(ctx, []uint{host.ID}, fleet.UbuntuOVALSource) require.NoError(t, err) @@ -2567,9 +2573,9 @@ func testDeleteOutOfDateVulnerabilities(t *testing.T, ds *Datastore) { require.NoError(t, err) // This should update the 'updated_at' timestamp. - inserted, err = ds.InsertSoftwareVulnerability(ctx, vulns[0], fleet.NVDSource) + insertedOrUpdated, err := ds.InsertSoftwareVulnerability(ctx, vulns[0], fleet.NVDSource) require.NoError(t, err) - require.False(t, inserted) + assert.True(t, insertedOrUpdated) err = ds.DeleteOutOfDateVulnerabilities(ctx, fleet.NVDSource, 2*time.Hour) require.NoError(t, err) @@ -3171,6 +3177,8 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { }) require.NoError(t, err) + test.CreateInsertGlobalVPPToken(t, ds) + expectStatus := func(s fleet.SoftwareInstallerStatus) *fleet.SoftwareInstallerStatus { return &s } @@ -3715,26 +3723,36 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { // add VPP apps, one for both no team and team, and two for no-team only. va1, err := ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1", - BundleIdentifier: "com.app.vpp1"}, nil) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1", + BundleIdentifier: "com.app.vpp1", + }, nil) require.NoError(t, err) _, err = ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.IOSPlatform}}, Name: "vpp1", - BundleIdentifier: "com.app.vpp1"}, nil) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.IOSPlatform}}, Name: "vpp1", + BundleIdentifier: "com.app.vpp1", + }, nil) require.NoError(t, err) _, err = ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1", - BundleIdentifier: "com.app.vpp1"}, &tm.ID) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1", + BundleIdentifier: "com.app.vpp1", + }, &tm.ID) require.NoError(t, err) vpp1 := va1.AdamID va2, err := ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_2", Platform: fleet.MacOSPlatform}}, Name: "vpp2", - BundleIdentifier: "com.app.vpp2"}, nil) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_2", Platform: fleet.MacOSPlatform}}, Name: "vpp2", + BundleIdentifier: "com.app.vpp2", + }, nil) require.NoError(t, err) // create vpp3 app that allows self-service va3, err := ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_3", Platform: fleet.MacOSPlatform}, SelfService: true}, Name: "vpp3", - BundleIdentifier: "com.app.vpp3"}, nil) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_3", Platform: fleet.MacOSPlatform}, SelfService: true}, Name: "vpp3", + BundleIdentifier: "com.app.vpp3", + }, nil) require.NoError(t, err) vpp2, vpp3 := va2.AdamID, va3.AdamID @@ -3927,8 +3945,12 @@ func testListIOSHostSoftware(t *testing.T, ds *Datastore) { ctx := context.Background() host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now(), test.WithPlatform("ios")) nanoEnroll(t, ds, host, false) - opts := fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 10, IncludeMetadata: true, OrderKey: "name", - TestSecondaryOrderKey: "source"}} + opts := fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{ + PerPage: 10, IncludeMetadata: true, OrderKey: "name", + TestSecondaryOrderKey: "source", + }} + + test.CreateInsertGlobalVPPToken(t, ds) user, err := ds.NewUser(ctx, &fleet.User{ Password: []byte("p4ssw0rd.123"), @@ -4012,24 +4034,31 @@ func testListIOSHostSoftware(t *testing.T, ds *Datastore) { require.NoError(t, err) expected := map[string]fleet.HostSoftwareWithInstaller{ - byNSV[a1].Name + byNSV[a1].Source: {Name: byNSV[a1].Name, Source: byNSV[a1].Source, + byNSV[a1].Name + byNSV[a1].Source: { + Name: byNSV[a1].Name, Source: byNSV[a1].Source, InstalledVersions: []*fleet.HostSoftwareInstalledVersion{ {Version: byNSV[a1].Version, Vulnerabilities: []string{vulns[0].CVE, vulns[1].CVE, vulns[2].CVE}}, - }}, - byNSV[b].Name + byNSV[b].Source: {Name: byNSV[b].Name, Source: byNSV[b].Source, + }, + }, + byNSV[b].Name + byNSV[b].Source: { + Name: byNSV[b].Name, Source: byNSV[b].Source, InstalledVersions: []*fleet.HostSoftwareInstalledVersion{ {Version: byNSV[b].Version, Vulnerabilities: []string{vulns[3].CVE}}, - }}, + }, + }, // c1 and c2 are the same software title because they have the same name and source - byNSV[c1].Name + byNSV[c1].Source: {Name: byNSV[c1].Name, Source: byNSV[c1].Source, + byNSV[c1].Name + byNSV[c1].Source: { + Name: byNSV[c1].Name, Source: byNSV[c1].Source, InstalledVersions: []*fleet.HostSoftwareInstalledVersion{ {Version: byNSV[c1].Version}, {Version: byNSV[c2].Version}, - }}, + }, + }, } compareResults := func(expected map[string]fleet.HostSoftwareWithInstaller, got []*fleet.HostSoftwareWithInstaller, expectAsc bool, - expectOmitted ...string) { + expectOmitted ...string, + ) { require.Len(t, got, len(expected)-len(expectOmitted)) prev := "" for _, g := range got { @@ -4116,33 +4145,47 @@ func testListIOSHostSoftware(t *testing.T, ds *Datastore) { // add VPP apps, one for both no team and team, and three for no-team only. va1, err := ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.IOSPlatform}}, Name: "vpp1", - BundleIdentifier: "com.app.vpp1"}, nil) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.IOSPlatform}}, Name: "vpp1", + BundleIdentifier: "com.app.vpp1", + }, nil) require.NoError(t, err) _, err = ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1", - BundleIdentifier: "com.app.vpp1"}, nil) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1", + BundleIdentifier: "com.app.vpp1", + }, nil) require.NoError(t, err) _, err = ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.IPadOSPlatform}}, Name: "vpp1", - BundleIdentifier: "com.app.vpp1"}, nil) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.IPadOSPlatform}}, Name: "vpp1", + BundleIdentifier: "com.app.vpp1", + }, nil) require.NoError(t, err) _, err = ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.IPadOSPlatform}}, Name: "vpp1", - BundleIdentifier: "com.app.vpp1"}, &tm.ID) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.IPadOSPlatform}}, Name: "vpp1", + BundleIdentifier: "com.app.vpp1", + }, &tm.ID) require.NoError(t, err) vpp1 := va1.AdamID va2, err := ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_2", Platform: fleet.IOSPlatform}}, Name: "vpp2", - BundleIdentifier: "com.app.vpp2"}, nil) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_2", Platform: fleet.IOSPlatform}}, Name: "vpp2", + BundleIdentifier: "com.app.vpp2", + }, nil) require.NoError(t, err) va3, err := ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_3", Platform: fleet.IOSPlatform}}, Name: "vpp3", - BundleIdentifier: "com.app.vpp3"}, nil) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_3", Platform: fleet.IOSPlatform}}, Name: "vpp3", + BundleIdentifier: "com.app.vpp3", + }, nil) require.NoError(t, err) va4, err := ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_4", Platform: fleet.IOSPlatform}}, Name: "vpp4", - BundleIdentifier: "com.app.vpp4"}, nil) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_4", Platform: fleet.IOSPlatform}}, Name: "vpp4", + BundleIdentifier: "com.app.vpp4", + }, nil) require.NoError(t, err) vpp2, vpp3, vpp4 := va2.AdamID, va3.AdamID, va4.AdamID @@ -4358,6 +4401,8 @@ func testListHostSoftwareInstallThenTransferTeam(t *testing.T, ds *Datastore) { IncludeAvailableForInstall: true, } + test.CreateInsertGlobalVPPToken(t, ds) + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 1"}) require.NoError(t, err) team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 2"}) @@ -4384,6 +4429,7 @@ func testListHostSoftwareInstallThenTransferTeam(t *testing.T, ds *Datastore) { Version: "1.0", Source: "apps", TeamID: &team1.ID, + UserID: user.ID, }) require.NoError(t, err) @@ -4399,8 +4445,10 @@ func testListHostSoftwareInstallThenTransferTeam(t *testing.T, ds *Datastore) { // add a VPP app for team 1 vppTm1, err := ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1", - BundleIdentifier: "com.app.vpp1"}, &team1.ID) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1", + BundleIdentifier: "com.app.vpp1", + }, &team1.ID) require.NoError(t, err) // fail to install it on the host @@ -4465,6 +4513,8 @@ func testListHostSoftwareInstallThenDeleteInstallers(t *testing.T, ds *Datastore IncludeAvailableForInstall: true, } + test.CreateInsertGlobalVPPToken(t, ds) + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 1"}) require.NoError(t, err) @@ -4489,6 +4539,7 @@ func testListHostSoftwareInstallThenDeleteInstallers(t *testing.T, ds *Datastore Version: "1.0", Source: "apps", TeamID: &team1.ID, + UserID: user.ID, }) require.NoError(t, err) @@ -4504,8 +4555,10 @@ func testListHostSoftwareInstallThenDeleteInstallers(t *testing.T, ds *Datastore // add a VPP app for team 1 vppTm1, err := ds.InsertVPPAppWithTeam(ctx, - &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1", - BundleIdentifier: "com.app.vpp1", LatestVersion: "1.0"}, &team1.ID) + &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_1", Platform: fleet.MacOSPlatform}}, Name: "vpp1", + BundleIdentifier: "com.app.vpp1", LatestVersion: "1.0", + }, &team1.ID) require.NoError(t, err) // install it on the host diff --git a/server/datastore/mysql/software_titles.go b/server/datastore/mysql/software_titles.go index f7d27a020099..b4e2bb6e60d3 100644 --- a/server/datastore/mysql/software_titles.go +++ b/server/datastore/mysql/software_titles.go @@ -111,6 +111,7 @@ func (ds *Datastore) ListSoftwareTitles( PackageSelfService *bool `db:"package_self_service"` PackageName *string `db:"package_name"` PackageVersion *string `db:"package_version"` + PackageURL *string `db:"package_url"` VPPAppSelfService *bool `db:"vpp_app_self_service"` VPPAppAdamID *string `db:"vpp_app_adam_id"` VPPAppVersion *string `db:"vpp_app_version"` @@ -152,6 +153,7 @@ func (ds *Datastore) ListSoftwareTitles( Name: *title.PackageName, Version: version, SelfService: title.PackageSelfService, + PackageURL: title.PackageURL, } } @@ -262,13 +264,14 @@ SELECT si.self_service as package_self_service, si.filename as package_name, si.version as package_version, + si.url AS package_url, vat.self_service as vpp_app_self_service, vat.adam_id as vpp_app_adam_id, vap.latest_version as vpp_app_version, vap.icon_url as vpp_app_icon_url FROM software_titles st LEFT JOIN software_installers si ON si.title_id = st.id AND %s -LEFT JOIN vpp_apps vap ON vap.title_id = st.id +LEFT JOIN vpp_apps vap ON vap.title_id = st.id AND %s LEFT JOIN vpp_apps_teams vat ON vat.adam_id = vap.adam_id AND vat.platform = vap.platform AND %s LEFT JOIN software_titles_host_counts sthc ON sthc.software_title_id = st.id AND (%s) -- placeholder for JOIN on software/software_cve @@ -277,7 +280,7 @@ LEFT JOIN software_titles_host_counts sthc ON sthc.software_title_id = st.id AND WHERE %s -- placeholder for filter based on software installed on hosts + software installers AND (%s) -GROUP BY st.id, package_self_service, package_name, package_version, vpp_app_self_service, vpp_app_adam_id, vpp_app_version, vpp_app_icon_url` +GROUP BY st.id, package_self_service, package_name, package_version, package_url, vpp_app_self_service, vpp_app_adam_id, vpp_app_version, vpp_app_icon_url` cveJoinType := "LEFT" if opt.VulnerableOnly { @@ -286,6 +289,7 @@ GROUP BY st.id, package_self_service, package_name, package_version, vpp_app_sel countsJoin := "TRUE" softwareInstallersJoinCond := "TRUE" + vppAppsJoinCond := "TRUE" vppAppsTeamsJoinCond := "TRUE" includeVPPAppsAndSoftwareInstallers := "TRUE" switch { @@ -304,6 +308,11 @@ GROUP BY st.id, package_self_service, package_name, package_version, vpp_app_sel vppAppsTeamsJoinCond = fmt.Sprintf("vat.global_or_team_id = %d", *opt.TeamID) } + if opt.PackagesOnly { + vppAppsJoinCond = "FALSE" + vppAppsTeamsJoinCond = "FALSE" + } + additionalWhere := "TRUE" match := opt.ListOptions.MatchQuery softwareJoin := "" @@ -363,7 +372,7 @@ GROUP BY st.id, package_self_service, package_name, package_version, vpp_app_sel defaultFilter += ` AND ( si.self_service = 1 OR vat.self_service = 1 ) ` } - stmt = fmt.Sprintf(stmt, softwareInstallersJoinCond, vppAppsTeamsJoinCond, countsJoin, softwareJoin, additionalWhere, defaultFilter) + stmt = fmt.Sprintf(stmt, softwareInstallersJoinCond, vppAppsJoinCond, vppAppsTeamsJoinCond, countsJoin, softwareJoin, additionalWhere, defaultFilter) return stmt, args } diff --git a/server/datastore/mysql/software_titles_test.go b/server/datastore/mysql/software_titles_test.go index d0ee221968c6..c0cfa2020fab 100644 --- a/server/datastore/mysql/software_titles_test.go +++ b/server/datastore/mysql/software_titles_test.go @@ -271,6 +271,8 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) { host2 := test.NewHost(t, ds, "host2", "", "host2key", "host2uuid", time.Now()) host3 := test.NewHost(t, ds, "host3", "", "host3key", "host3uuid", time.Now()) + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + software1 := []fleet.Software{ {Name: "foo", Version: "0.0.1", Source: "chrome_extensions", Browser: "chrome"}, {Name: "foo", Version: "0.0.3", Source: "chrome_extensions", Browser: "chrome"}, @@ -303,6 +305,7 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) { Source: "apps", InstallScript: "echo", Filename: "installer1.pkg", + UserID: user1.ID, }) require.NoError(t, err) require.NotZero(t, installer1) @@ -317,10 +320,14 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) { Source: "apps", InstallScript: "echo", Filename: "installer2.pkg", + UserID: user1.ID, }) require.NoError(t, err) _, err = ds.InsertSoftwareInstallRequest(ctx, host1.ID, installer2, false) require.NoError(t, err) + + test.CreateInsertGlobalVPPToken(t, ds) + // create a VPP app not installed anywhere _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{ Name: "vpp1", BundleIdentifier: "com.app.vpp1", @@ -594,6 +601,10 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) { team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"}) require.NoError(t, err) + test.CreateInsertGlobalVPPToken(t, ds) + + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + host1 := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now()) require.NoError(t, ds.AddHostsToTeam(ctx, &team1.ID, []uint{host1.ID})) host2 := test.NewHost(t, ds, "host2", "", "host2key", "host2uuid", time.Now()) @@ -627,6 +638,7 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) { Filename: "installer1.pkg", BundleIdentifier: "foo.bar", TeamID: &team1.ID, + UserID: user1.ID, }) require.NoError(t, err) require.NotZero(t, installer1) @@ -642,6 +654,7 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) { InstallScript: "echo", Filename: "installer2.pkg", TeamID: &team2.ID, + UserID: user1.ID, }) require.NoError(t, err) require.NotZero(t, installer2) @@ -856,12 +869,17 @@ func sortTitlesByName(titles []fleet.SoftwareTitleListResult) { func testListSoftwareTitlesInstallersOnly(t *testing.T, ds *Datastore) { ctx := context.Background() + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + + test.CreateInsertGlobalVPPToken(t, ds) + // create a couple software installers not installed on any host installer1, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ Title: "installer1", Source: "apps", InstallScript: "echo", Filename: "installer1.pkg", + UserID: user1.ID, }) require.NoError(t, err) require.NotZero(t, installer1) @@ -870,6 +888,7 @@ func testListSoftwareTitlesInstallersOnly(t *testing.T, ds *Datastore) { Source: "apps", InstallScript: "echo", Filename: "installer2.pkg", + UserID: user1.ID, }) require.NoError(t, err) require.NotZero(t, installer2) @@ -955,6 +974,9 @@ func testListSoftwareTitlesInstallersOnly(t *testing.T, ds *Datastore) { func testListSoftwareTitlesAvailableForInstallFilter(t *testing.T, ds *Datastore) { ctx := context.Background() + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + + test.CreateInsertGlobalVPPToken(t, ds) // create 2 software installers installer1, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ @@ -962,6 +984,7 @@ func testListSoftwareTitlesAvailableForInstallFilter(t *testing.T, ds *Datastore Source: "apps", InstallScript: "echo", Filename: "installer1.pkg", + UserID: user1.ID, }) require.NoError(t, err) require.NotZero(t, installer1) @@ -970,6 +993,7 @@ func testListSoftwareTitlesAvailableForInstallFilter(t *testing.T, ds *Datastore Source: "apps", InstallScript: "echo", Filename: "installer2.pkg", + UserID: user1.ID, }) require.NoError(t, err) require.NotZero(t, installer2) @@ -1135,11 +1159,15 @@ func testListSoftwareTitlesAvailableForInstallFilter(t *testing.T, ds *Datastore func testListSoftwareTitlesAllTeams(t *testing.T, ds *Datastore) { ctx := context.Background() + test.CreateInsertGlobalVPPToken(t, ds) + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) require.NoError(t, err) team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"}) require.NoError(t, err) + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + // Create a macOS software foobar installer on "No team". macOSInstallerNoTeam, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ Title: "foobar", @@ -1148,6 +1176,7 @@ func testListSoftwareTitlesAllTeams(t *testing.T, ds *Datastore) { InstallScript: "echo", Filename: "foobar.pkg", TeamID: nil, + UserID: user1.ID, }) require.NoError(t, err) @@ -1322,6 +1351,7 @@ func testUploadedSoftwareExists(t *testing.T, ds *Datastore) { tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "Team Foo"}) require.NoError(t, err) + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) installer1, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ Title: "installer1", @@ -1329,6 +1359,7 @@ func testUploadedSoftwareExists(t *testing.T, ds *Datastore) { InstallScript: "echo", Filename: "installer1.pkg", BundleIdentifier: "com.foo.installer1", + UserID: user1.ID, }) require.NoError(t, err) require.NotZero(t, installer1) @@ -1339,6 +1370,7 @@ func testUploadedSoftwareExists(t *testing.T, ds *Datastore) { Filename: "installer2.pkg", TeamID: &tm.ID, BundleIdentifier: "com.foo.installer2", + UserID: user1.ID, }) require.NoError(t, err) require.NotZero(t, installer2) diff --git a/server/datastore/mysql/teams.go b/server/datastore/mysql/teams.go index c25b4a791c38..c0d1b8b61b17 100644 --- a/server/datastore/mysql/teams.go +++ b/server/datastore/mysql/teams.go @@ -106,7 +106,14 @@ func saveTeamSecretsDB(ctx context.Context, q sqlx.ExtContext, team *fleet.Team) func (ds *Datastore) DeleteTeam(ctx context.Context, tid uint) error { return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { - _, err := tx.ExecContext(ctx, `DELETE FROM teams WHERE id = ?`, tid) + // Delete team policies first, because policies can have associated installers which may be deleted on cascade + // before deleting the policies (which are also deleted on cascade). + _, err := tx.ExecContext(ctx, `DELETE FROM policies WHERE team_id = ?`, tid) + if err != nil { + return ctxerr.Wrapf(ctx, err, "deleting policies for team %d", tid) + } + + _, err = tx.ExecContext(ctx, `DELETE FROM teams WHERE id = ?`, tid) if err != nil { return ctxerr.Wrapf(ctx, err, "delete team %d", tid) } diff --git a/server/datastore/mysql/vpp.go b/server/datastore/mysql/vpp.go index 0c000712ace9..a40416697af0 100644 --- a/server/datastore/mysql/vpp.go +++ b/server/datastore/mysql/vpp.go @@ -190,9 +190,14 @@ func (ds *Datastore) SetTeamVPPApps(ctx context.Context, teamID *uint, appFleets } } + vppToken, err := ds.GetVPPTokenByTeamID(ctx, teamID) + if err != nil { + return ctxerr.Wrap(ctx, err, "SetTeamVPPApps retrieve VPP token ID") + } + return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { for _, toAdd := range toAddApps { - if err := insertVPPAppTeams(ctx, tx, toAdd, teamID); err != nil { + if err := insertVPPAppTeams(ctx, tx, toAdd, teamID, vppToken.ID); err != nil { return ctxerr.Wrap(ctx, err, "SetTeamVPPApps inserting vpp app into team") } } @@ -208,7 +213,12 @@ func (ds *Datastore) SetTeamVPPApps(ctx context.Context, teamID *uint, appFleets } func (ds *Datastore) InsertVPPAppWithTeam(ctx context.Context, app *fleet.VPPApp, teamID *uint) (*fleet.VPPApp, error) { - err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + vppToken, err := ds.GetVPPTokenByTeamID(ctx, teamID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "InsertVPPAppWithTeam unable to get VPP Token ID") + } + + err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { titleID, err := ds.getOrInsertSoftwareTitleForVPPApp(ctx, tx, app) if err != nil { return err @@ -220,14 +230,14 @@ func (ds *Datastore) InsertVPPAppWithTeam(ctx context.Context, app *fleet.VPPApp return ctxerr.Wrap(ctx, err, "InsertVPPAppWithTeam insertVPPApps transaction") } - if err := insertVPPAppTeams(ctx, tx, app.VPPAppTeam, teamID); err != nil { + if err := insertVPPAppTeams(ctx, tx, app.VPPAppTeam, teamID, vppToken.ID); err != nil { return ctxerr.Wrap(ctx, err, "InsertVPPAppWithTeam insertVPPAppTeams transaction") } return nil }) if err != nil { - return nil, err + return nil, ctxerr.Wrap(ctx, err, "InsertVPPAppWithTeam") } return app, nil @@ -288,12 +298,12 @@ ON DUPLICATE KEY UPDATE return ctxerr.Wrap(ctx, err, "insert VPP apps") } -func insertVPPAppTeams(ctx context.Context, tx sqlx.ExtContext, appID fleet.VPPAppTeam, teamID *uint) error { +func insertVPPAppTeams(ctx context.Context, tx sqlx.ExtContext, appID fleet.VPPAppTeam, teamID *uint, vppTokenID uint) error { stmt := ` INSERT INTO vpp_apps_teams - (adam_id, global_or_team_id, team_id, platform, self_service) + (adam_id, global_or_team_id, team_id, platform, self_service, vpp_token_id) VALUES - (?, ?, ?, ?, ?) + (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE self_service = VALUES(self_service) ` @@ -306,7 +316,7 @@ ON DUPLICATE KEY UPDATE self_service = VALUES(self_service) } } - _, err := tx.ExecContext(ctx, stmt, appID.AdamID, globalOrTmID, teamID, appID.Platform, appID.SelfService) + _, err := tx.ExecContext(ctx, stmt, appID.AdamID, globalOrTmID, teamID, appID.Platform, appID.SelfService, vppTokenID) if IsDuplicate(err) { err = &existsError{ Identifier: fmt.Sprintf("%s %s self_service: %v", appID.AdamID, appID.Platform, appID.SelfService), @@ -385,7 +395,7 @@ func (ds *Datastore) getOrInsertSoftwareTitleForVPPApp(ctx context.Context, tx s }, ) if err != nil { - return 0, err + return 0, ctxerr.Wrap(ctx, err, "optimistic get or insert VPP app") } return titleID, nil @@ -779,6 +789,9 @@ func (ds *Datastore) UpdateVPPTokenTeams(ctx context.Context, id uint, teams []u null_team_type ) VALUES ` stmtValues := `(?, ?, ?)` + // Delete all apps associated with a token if we change its team + stmtDeleteApps := `DELETE FROM vpp_apps_teams WHERE vpp_token_id = ?` + deleteArgs := []any{id} var values string var args []any @@ -823,12 +836,20 @@ func (ds *Datastore) UpdateVPPTokenTeams(ctx context.Context, id uint, teams []u return ctxerr.Wrap(ctx, err, "vpp token null team check") } + if _, err := tx.ExecContext(ctx, stmtDeleteApps, deleteArgs...); err != nil { + return ctxerr.Wrap(ctx, err, "deleting old vpp team apps associations") + } + if _, err := tx.ExecContext(ctx, stmtRemove, id); err != nil { return ctxerr.Wrap(ctx, err, "removing old vpp team associations") } if len(args) > 0 { if _, err := tx.ExecContext(ctx, stmtInsertFull, args...); err != nil { + if isChildForeignKeyError(err) { + return foreignKey("team", fmt.Sprintf("(team_id)=(%v)", values)) + } + return ctxerr.Wrap(ctx, err, "updating vpp token team") } } @@ -1077,6 +1098,37 @@ TEAMLOOP: func checkVPPNullTeam(ctx context.Context, tx sqlx.ExtContext, currentID *uint, nullTeam fleet.NullTeamType) error { nullTeamStmt := `SELECT vpp_token_id FROM vpp_token_teams WHERE null_team_type = ?` + anyTeamStmt := `SELECT vpp_token_id FROM vpp_token_teams WHERE null_team_type = 'allteams' OR null_team_type = 'noteam' OR team_id IS NOT NULL` + + if nullTeam == fleet.NullTeamAllTeams { + var ids []uint + if err := sqlx.SelectContext(ctx, tx, &ids, anyTeamStmt); err != nil { + return ctxerr.Wrap(ctx, err, "scanning row in check vpp token null team") + } + + if len(ids) > 0 { + if len(ids) > 1 { + return ctxerr.Wrap(ctx, errors.New("Cannot assign token to All teams, other teams have tokens")) + } + if currentID == nil || ids[0] != *currentID { + return ctxerr.Wrap(ctx, errors.New("Cannot assign token to All teams, other teams have tokens")) + } + } + } + + var id uint + allTeamsFound := true + if err := sqlx.GetContext(ctx, tx, &id, nullTeamStmt, fleet.NullTeamAllTeams); err != nil { + if errors.Is(err, sql.ErrNoRows) { + allTeamsFound = false + } else { + return ctxerr.Wrap(ctx, err, "scanning row in check vpp token null team") + } + } + + if allTeamsFound && currentID != nil && *currentID != id { + return ctxerr.Wrap(ctx, errors.New("All teams token already exists")) + } if nullTeam != fleet.NullTeamNone { var id uint diff --git a/server/datastore/mysql/vpp_test.go b/server/datastore/mysql/vpp_test.go index e3daadd06a80..b9bfb8dfe317 100644 --- a/server/datastore/mysql/vpp_test.go +++ b/server/datastore/mysql/vpp_test.go @@ -29,6 +29,7 @@ func TestVPP(t *testing.T) { {"VPPApps", testVPPApps}, {"GetVPPAppByTeamAndTitleID", testGetVPPAppByTeamAndTitleID}, {"VPPTokensCRUD", testVPPTokensCRUD}, + {"VPPTokenAppTeamAssociations", testVPPTokenAppTeamAssociations}, } for _, c := range cases { @@ -51,6 +52,8 @@ func testVPPAppMetadata(t *testing.T, ds *Datastore) { require.NoError(t, err) require.NotNil(t, team2) + test.CreateInsertGlobalVPPToken(t, ds) + // get for non-existing title meta, err := ds.GetVPPAppMetadataByTeamAndTitleID(ctx, nil, 1) require.Error(t, err) @@ -212,6 +215,8 @@ func testVPPAppStatus(t *testing.T, ds *Datastore) { require.NoError(t, err) require.NotNil(t, team1) + test.CreateInsertGlobalVPPToken(t, ds) + // create some apps, one for no-team, one for team1, and one in both va1, err := ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{ Name: "vpp1", BundleIdentifier: "com.app.vpp1", @@ -405,6 +410,8 @@ func testVPPApps(t *testing.T, ds *Datastore) { team, err := ds.NewTeam(ctx, &fleet.Team{Name: "foobar"}) require.NoError(t, err) + test.CreateInsertGlobalVPPToken(t, ds) + // create a host with some non-VPP software h1, err := ds.NewHost(ctx, &fleet.Host{ Hostname: "macos-test-1", @@ -515,6 +522,13 @@ func testSetTeamVPPApps(t *testing.T, ds *Datastore) { team, err := ds.NewTeam(ctx, &fleet.Team{Name: "vpp gang"}) require.NoError(t, err) + dataToken, err := test.CreateVPPTokenData(time.Now().Add(24*time.Hour), "Donkey Kong", "Jungle") + require.NoError(t, err) + tok1, err := ds.InsertVPPToken(ctx, dataToken) + assert.NoError(t, err) + _, err = ds.UpdateVPPTokenTeams(ctx, tok1.ID, []uint{}) + assert.NoError(t, err) + // Insert some VPP apps for the team app1 := &fleet.VPPApp{Name: "vpp_app_1", VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "1", Platform: fleet.MacOSPlatform}}, BundleIdentifier: "b1"} _, err = ds.InsertVPPAppWithTeam(ctx, app1, nil) @@ -593,6 +607,8 @@ func testGetVPPAppByTeamAndTitleID(t *testing.T, ds *Datastore) { team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 2"}) require.NoError(t, err) + test.CreateInsertGlobalVPPToken(t, ds) + var nfe fleet.NotFoundError fooApp, err := ds.InsertVPPAppWithTeam(ctx, @@ -631,7 +647,6 @@ func testVPPTokensCRUD(t *testing.T, ds *Datastore) { assert.NoError(t, err) team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "Zingers"}) - _ = team2 assert.NoError(t, err) tokens, err := ds.ListVPPTokens(ctx) @@ -725,7 +740,7 @@ func testVPPTokensCRUD(t *testing.T, ds *Datastore) { assert.Equal(t, dataToken.Token, tok.Token) assert.Equal(t, orgName, tok.OrgName) assert.Equal(t, location, tok.Location) - assert.NotNil(t, tok.Teams) // "All Teams" teamm array is non-nil but empty + assert.NotNil(t, tok.Teams) // "All Teams" teams array is non-nil but empty assert.Len(t, tok.Teams, 0) toks, err = ds.ListVPPTokens(ctx) @@ -760,7 +775,7 @@ func testVPPTokensCRUD(t *testing.T, ds *Datastore) { // Assign to team "No Team" upTok, err = ds.UpdateVPPTokenTeams(ctx, tok.ID, []uint{0}) - assert.NoError(t, err) + require.NoError(t, err) assert.Len(t, upTok.Teams, 1) assert.Equal(t, tokID, upTok.ID) assert.Equal(t, uint(0), upTok.Teams[0].ID) @@ -906,13 +921,33 @@ func testVPPTokensCRUD(t *testing.T, ds *Datastore) { assert.NoError(t, err) assert.Len(t, toks, 2) + // Remove tokAll from All teams + tokAll, err = ds.UpdateVPPTokenTeams(ctx, tokAll.ID, nil) + assert.NoError(t, err) + tokTeam, err := ds.InsertVPPToken(ctx, dataToken3) assert.NoError(t, err) + _, err = ds.UpdateVPPTokenTeams(ctx, tokTeam.ID, []uint{team.ID}) assert.NoError(t, err) _, err = ds.UpdateVPPTokenTeams(ctx, tokTeam.ID, []uint{team.ID, team.ID}) assert.Error(t, err) + // Cannot move tokAll to all teams now + _, err = ds.UpdateVPPTokenTeams(ctx, tokAll.ID, []uint{}) + assert.Error(t, err) + + _, err = ds.UpdateVPPTokenTeams(ctx, tokTeam.ID, []uint{0}) + assert.NoError(t, err) + + _, err = ds.UpdateVPPTokenTeams(ctx, tokAll.ID, []uint{}) + assert.Error(t, err) + + _, err = ds.UpdateVPPTokenTeams(ctx, tokTeam.ID, []uint{team.ID}) + assert.NoError(t, err) + + /// + toks, err = ds.ListVPPTokens(ctx) assert.NoError(t, err) assert.Len(t, toks, 3) @@ -953,7 +988,7 @@ func testVPPTokensCRUD(t *testing.T, ds *Datastore) { assert.NoError(t, err) assert.Len(t, toks, 5) - // Test fallback to all teams + /// tokNil, err := ds.GetVPPTokenByTeamID(ctx, nil) assert.NoError(t, err) assert.Equal(t, tokTeams.ID, tokNil.ID) @@ -978,8 +1013,7 @@ func testVPPTokensCRUD(t *testing.T, ds *Datastore) { assert.Equal(t, tokTeams.ID, tokNil.ID) tokTeam1, err = ds.GetVPPTokenByTeamID(ctx, &team.ID) - assert.NoError(t, err) - assert.Equal(t, tokAll.ID, tokTeam1.ID) + assert.Error(t, err) tokTeam2, err = ds.GetVPPTokenByTeamID(ctx, &team2.ID) assert.NoError(t, err) @@ -990,32 +1024,35 @@ func testVPPTokensCRUD(t *testing.T, ds *Datastore) { assert.NoError(t, err) tokNil, err = ds.GetVPPTokenByTeamID(ctx, nil) - assert.NoError(t, err) - assert.Equal(t, tokAll.ID, tokNil.ID) + assert.Error(t, err) + assert.True(t, fleet.IsNotFound(err)) tokTeam1, err = ds.GetVPPTokenByTeamID(ctx, &team.ID) - assert.NoError(t, err) - assert.Equal(t, tokAll.ID, tokTeam1.ID) + assert.Error(t, err) + assert.True(t, fleet.IsNotFound(err)) tokTeam2, err = ds.GetVPPTokenByTeamID(ctx, &team2.ID) - assert.NoError(t, err) - assert.Equal(t, tokAll.ID, tokTeam2.ID) + assert.Error(t, err) + assert.True(t, fleet.IsNotFound(err)) //// - err = ds.DeleteVPPToken(ctx, tokAll.ID) + tokAll, err = ds.UpdateVPPTokenTeams(ctx, tokAll.ID, []uint{}) assert.NoError(t, err) tokNil, err = ds.GetVPPTokenByTeamID(ctx, nil) - assert.Error(t, err) - assert.True(t, fleet.IsNotFound(err)) + assert.NoError(t, err) + assert.Equal(t, tokAll.ID, tokNil.ID) tokTeam1, err = ds.GetVPPTokenByTeamID(ctx, &team.ID) - assert.Error(t, err) - assert.True(t, fleet.IsNotFound(err)) + assert.NoError(t, err) + assert.Equal(t, tokAll.ID, tokTeam1.ID) tokTeam2, err = ds.GetVPPTokenByTeamID(ctx, &team2.ID) - assert.Error(t, err) - assert.True(t, fleet.IsNotFound(err)) + assert.NoError(t, err) + assert.Equal(t, tokAll.ID, tokTeam2.ID) + + err = ds.DeleteVPPToken(ctx, tokAll.ID) + assert.NoError(t, err) //// _, err = ds.UpdateVPPTokenTeams(ctx, tokNone.ID, []uint{0, team.ID, team2.ID}) @@ -1036,4 +1073,129 @@ func testVPPTokensCRUD(t *testing.T, ds *Datastore) { //// err = ds.DeleteVPPToken(ctx, tokNone.ID) assert.NoError(t, err) + +} + +func testVPPTokenAppTeamAssociations(t *testing.T, ds *Datastore) { + ctx := context.Background() + + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "Kritters"}) + assert.NoError(t, err) + + team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "Zingers"}) + assert.NoError(t, err) + + dataToken, err := test.CreateVPPTokenData(time.Now().Add(24*time.Hour), "Donkey Kong", "Jungle") + require.NoError(t, err) + + dataToken2, err := test.CreateVPPTokenData(time.Now().Add(24*time.Hour), "Diddy Kong", "Mines") + require.NoError(t, err) + + tok1, err := ds.InsertVPPToken(ctx, dataToken) + assert.NoError(t, err) + + tok2, err := ds.InsertVPPToken(ctx, dataToken2) + assert.NoError(t, err) + + _, err = ds.UpdateVPPTokenTeams(ctx, tok1.ID, []uint{team1.ID}) + assert.NoError(t, err) + + _, err = ds.UpdateVPPTokenTeams(ctx, tok2.ID, []uint{team2.ID}) + assert.NoError(t, err) + + app1 := &fleet.VPPApp{ + Name: "app1", + VPPAppTeam: fleet.VPPAppTeam{ + VPPAppID: fleet.VPPAppID{ + AdamID: "1", + Platform: fleet.MacOSPlatform, + }, + }, + BundleIdentifier: "app1", + } + _, err = ds.InsertVPPAppWithTeam(ctx, app1, &team1.ID) + assert.NoError(t, err) + _, err = ds.InsertVPPAppWithTeam(ctx, app1, &team2.ID) + assert.NoError(t, err) + + app2 := &fleet.VPPApp{ + Name: "app2", + VPPAppTeam: fleet.VPPAppTeam{ + VPPAppID: fleet.VPPAppID{ + AdamID: "2", + Platform: fleet.MacOSPlatform, + }, + }, + BundleIdentifier: "app2", + } + vppApp2, err := ds.InsertVPPAppWithTeam(ctx, app2, &team1.ID) + _ = vppApp2 + assert.NoError(t, err) + + // team1: token 1, app1, app2 + // team2: token 2, app 1 + + apps, err := ds.GetAssignedVPPApps(ctx, &team1.ID) + assert.NoError(t, err) + assert.Len(t, apps, 2) + assert.Contains(t, apps, app1.VPPAppID) + assert.Contains(t, apps, app2.VPPAppID) + + apps, err = ds.GetAssignedVPPApps(ctx, &team2.ID) + assert.NoError(t, err) + assert.Len(t, apps, 1) + assert.Contains(t, apps, app1.VPPAppID) + + /// Try to move team 1 token to team 2 + + _, err = ds.UpdateVPPTokenTeams(ctx, tok1.ID, []uint{team2.ID}) + assert.Error(t, err) + + // team1: token 1, app1, app2 + // team2: token 2, app 1 + + apps, err = ds.GetAssignedVPPApps(ctx, &team1.ID) + assert.NoError(t, err) + assert.Len(t, apps, 2) + + apps, err = ds.GetAssignedVPPApps(ctx, &team2.ID) + assert.NoError(t, err) + assert.Len(t, apps, 1) + assert.Contains(t, apps, app1.VPPAppID) + + _, err = ds.UpdateVPPTokenTeams(ctx, tok1.ID, nil) + assert.NoError(t, err) + + // team1: no token, no apps + // team2: token 2, app 1 + + apps, err = ds.GetAssignedVPPApps(ctx, &team1.ID) + assert.NoError(t, err) + assert.Len(t, apps, 0) + + apps, err = ds.GetAssignedVPPApps(ctx, &team2.ID) + assert.NoError(t, err) + assert.Len(t, apps, 1) + assert.Contains(t, apps, app1.VPPAppID) + + // Move team 2 token to team 1 + + _, err = ds.UpdateVPPTokenTeams(ctx, tok2.ID, []uint{team1.ID}) + assert.NoError(t, err) + + // team1: token 2, app 1 + // team2: no token, no apps + + apps, err = ds.GetAssignedVPPApps(ctx, &team1.ID) + assert.NoError(t, err) + assert.Len(t, apps, 0) + + apps, err = ds.GetAssignedVPPApps(ctx, &team2.ID) + assert.NoError(t, err) + assert.Len(t, apps, 0) + + /// Can't assaign apps with no token + + _, err = ds.InsertVPPAppWithTeam(ctx, app1, &team2.ID) + assert.Error(t, err) } diff --git a/server/docs/patterns.md b/server/docs/patterns.md new file mode 100644 index 000000000000..49b97c6ac0ca --- /dev/null +++ b/server/docs/patterns.md @@ -0,0 +1,26 @@ +# Backend patterns + +The backend software patterns that we follow in Fleet. + +> NOTE: There are always exceptions to the rules, but we try to follow these patterns as much as possible unless a specific use case calls +> for something else. These should be discussed within the team and documented before merging. + +## MySQL + +Use high precision for all time fields. Precise timestamps make sure that we can accurately track when records were created and updated, +keep records in order with a reliable sort, and speed up testing by not having to wait for the time to +update. [MySQL reference](https://dev.mysql.com/doc/refman/8.4/en/date-and-time-type-syntax.html). [Backend sync where discussed](https://us-65885.app.gong.io/call?id=8041045095900447703). +Example: + +```sql +CREATE TABLE `sample` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `created_at` TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `updated_at` TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`) +); +``` + +Do not use [goqu](https://github.com/doug-martin/goqu); use MySQL queries directly. Searching for, understanding, and debugging direct MySQL +queries is easier. If needing to modify an existing `goqu` query, try to rewrite it in +MySQL. [Backend sync where discussed](https://us-65885.app.gong.io/call?id=8041045095900447703). diff --git a/server/fleet/app.go b/server/fleet/app.go index 3812c6d4502b..603ba2e0ab88 100644 --- a/server/fleet/app.go +++ b/server/fleet/app.go @@ -142,9 +142,9 @@ type MDM struct { // Deprecated: use AppleBussinessManager instead DeprecatedAppleBMDefaultTeam string `json:"apple_bm_default_team,omitempty"` - // AppleBussinessManager defines the associations between ABM tokens + // AppleBusinessManager defines the associations between ABM tokens // and the teams used to assign hosts when they're ingested from ABM. - AppleBussinessManager optjson.Slice[MDMAppleABMAssignmentInfo] `json:"apple_business_manager"` + AppleBusinessManager optjson.Slice[MDMAppleABMAssignmentInfo] `json:"apple_business_manager"` // AppleBMEnabledAndConfigured is set to true if Fleet has been // configured with the required Apple BM key pair or token. It can't be set @@ -635,12 +635,12 @@ func (c *AppConfig) Copy() *AppConfig { clone.MDM.WindowsSettings.CustomSettings = optjson.SetSlice(windowsSettings) } - if c.MDM.AppleBussinessManager.Set { - abm := make([]MDMAppleABMAssignmentInfo, len(c.MDM.AppleBussinessManager.Value)) - for i, s := range c.MDM.AppleBussinessManager.Value { + if c.MDM.AppleBusinessManager.Set { + abm := make([]MDMAppleABMAssignmentInfo, len(c.MDM.AppleBusinessManager.Value)) + for i, s := range c.MDM.AppleBusinessManager.Value { abm[i] = s } - clone.MDM.AppleBussinessManager = optjson.SetSlice(abm) + clone.MDM.AppleBusinessManager = optjson.SetSlice(abm) } @@ -878,9 +878,6 @@ func (c AppConfig) MarshalJSON() ([]byte, error) { if !c.MDM.MacOSSetup.EnableReleaseDeviceManually.Valid { c.MDM.MacOSSetup.EnableReleaseDeviceManually = optjson.SetBool(false) } - if c.MDM.AppleBussinessManager.Set { - c.MDM.DeprecatedAppleBMDefaultTeam = "" - } type aliasConfig AppConfig aa := aliasConfig(c) return json.Marshal(aa) diff --git a/server/fleet/apple_mdm.go b/server/fleet/apple_mdm.go index 29424190a4cb..9a3bb005f8d3 100644 --- a/server/fleet/apple_mdm.go +++ b/server/fleet/apple_mdm.go @@ -1,6 +1,7 @@ package fleet import ( + "bytes" "context" "crypto/md5" // nolint: gosec "encoding/hex" @@ -204,6 +205,13 @@ type MDMAppleConfigProfile struct { UploadedAt time.Time `db:"uploaded_at" json:"updated_at"` // NOTE: JSON field is still `updated_at` for historical reasons, would be an API breaking change } +// MDMProfilesUpdates flags updates that were done during batch processing of profiles. +type MDMProfilesUpdates struct { + AppleConfigProfile bool + WindowsConfigProfile bool + AppleDeclaration bool +} + // ConfigurationProfileLabel represents the many-to-many relationship between // profiles and labels. // @@ -309,6 +317,20 @@ func (p *MDMAppleProfilePayload) FailedToInstallOnHost() bool { return p.Status != nil && *p.Status == MDMDeliveryFailed && p.OperationType == MDMOperationTypeInstall } +func (p MDMAppleProfilePayload) Equal(other MDMAppleProfilePayload) bool { + statusEqual := p.Status == nil && other.Status == nil || p.Status != nil && other.Status != nil && *p.Status == *other.Status + return p.ProfileUUID == other.ProfileUUID && + p.ProfileIdentifier == other.ProfileIdentifier && + p.ProfileName == other.ProfileName && + p.HostUUID == other.HostUUID && + p.HostPlatform == other.HostPlatform && + bytes.Equal(p.Checksum, other.Checksum) && + statusEqual && + p.OperationType == other.OperationType && + p.Detail == other.Detail && + p.CommandUUID == other.CommandUUID +} + type MDMAppleBulkUpsertHostProfilePayload struct { ProfileUUID string ProfileIdentifier string @@ -660,6 +682,18 @@ type MDMAppleHostDeclaration struct { Checksum string `db:"checksum" json:"-"` } +func (p MDMAppleHostDeclaration) Equal(other MDMAppleHostDeclaration) bool { + statusEqual := p.Status == nil && other.Status == nil || p.Status != nil && other.Status != nil && *p.Status == *other.Status + return statusEqual && + p.HostUUID == other.HostUUID && + p.DeclarationUUID == other.DeclarationUUID && + p.Name == other.Name && + p.Identifier == other.Identifier && + p.OperationType == other.OperationType && + p.Detail == other.Detail && + p.Checksum == other.Checksum +} + func NewMDMAppleDeclaration(raw []byte, teamID *uint, name string, declType, ident string) *MDMAppleDeclaration { var decl MDMAppleDeclaration diff --git a/server/fleet/apple_mdm_test.go b/server/fleet/apple_mdm_test.go index 16799f6cc3be..f31796d4a4f0 100644 --- a/server/fleet/apple_mdm_test.go +++ b/server/fleet/apple_mdm_test.go @@ -7,18 +7,20 @@ import ( "crypto/x509" "encoding/json" "fmt" + "reflect" "strings" "testing" "time" "github.com/fleetdm/fleet/v4/server/mdm" "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" + "github.com/fleetdm/fleet/v4/server/mdm/scep/depot" "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/google/go-cmp/cmp" "github.com/google/uuid" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.mozilla.org/pkcs7" - - "github.com/fleetdm/fleet/v4/server/mdm/scep/depot" ) func TestMDMAppleConfigProfile(t *testing.T) { @@ -416,3 +418,199 @@ func TestMDMProfileIsWithinGracePeriod(t *testing.T) { }) } } + +func TestMDMAppleHostDeclarationEqual(t *testing.T) { + t.Parallel() + + // This test is intended to ensure that the Equal method on MDMAppleHostDeclaration is updated when new fields are added. + // The Equal method is used to identify whether database update is needed. + + items := [...]MDMAppleHostDeclaration{{}, {}} + + numberOfFields := 0 + for i := 0; i < len(items); i++ { + rValue := reflect.ValueOf(&items[i]).Elem() + numberOfFields = rValue.NumField() + for j := 0; j < numberOfFields; j++ { + field := rValue.Field(j) + switch field.Kind() { + case reflect.String: + valueToSet := fmt.Sprintf("test %d", i) + field.SetString(valueToSet) + case reflect.Int: + field.SetInt(int64(i)) + case reflect.Bool: + field.SetBool(i%2 == 0) + case reflect.Pointer: + field.Set(reflect.New(field.Type().Elem())) + default: + t.Fatalf("unhandled field type %s", field.Kind()) + } + } + } + + status0 := MDMDeliveryStatus("status") + status1 := MDMDeliveryStatus("status") + items[0].Status = &status0 + assert.False(t, items[0].Equal(items[1])) + + // Set known fields to be equal + fieldsInEqualMethod := 0 + items[1].HostUUID = items[0].HostUUID + fieldsInEqualMethod++ + items[1].DeclarationUUID = items[0].DeclarationUUID + fieldsInEqualMethod++ + items[1].Name = items[0].Name + fieldsInEqualMethod++ + items[1].Identifier = items[0].Identifier + fieldsInEqualMethod++ + items[1].OperationType = items[0].OperationType + fieldsInEqualMethod++ + items[1].Detail = items[0].Detail + fieldsInEqualMethod++ + items[1].Checksum = items[0].Checksum + fieldsInEqualMethod++ + items[1].Status = &status1 + fieldsInEqualMethod++ + assert.Equal(t, fieldsInEqualMethod, numberOfFields, "MDMAppleHostDeclaration.Equal needs to be updated for new/updated field(s)") + assert.True(t, items[0].Equal(items[1])) + + // Set pointers to nil + items[0].Status = nil + items[1].Status = nil + assert.True(t, items[0].Equal(items[1])) + +} + +func TestMDMAppleProfilePayloadEqual(t *testing.T) { + t.Parallel() + + // This test is intended to ensure that the Equal method on MDMAppleProfilePayload is updated when new fields are added. + // The Equal method is used to identify whether database update is needed. + + items := [...]MDMAppleProfilePayload{{}, {}} + + numberOfFields := 0 + for i := 0; i < len(items); i++ { + rValue := reflect.ValueOf(&items[i]).Elem() + numberOfFields = rValue.NumField() + for j := 0; j < numberOfFields; j++ { + field := rValue.Field(j) + switch field.Kind() { + case reflect.String: + valueToSet := fmt.Sprintf("test %d", i) + field.SetString(valueToSet) + case reflect.Int: + field.SetInt(int64(i)) + case reflect.Bool: + field.SetBool(i%2 == 0) + case reflect.Pointer: + field.Set(reflect.New(field.Type().Elem())) + case reflect.Slice: + switch field.Type().Elem().Kind() { + case reflect.Uint8: + valueToSet := []byte("test") + field.Set(reflect.ValueOf(valueToSet)) + default: + t.Fatalf("unhandled slice type %s", field.Type().Elem().Kind()) + } + default: + t.Fatalf("unhandled field type %s", field.Kind()) + } + } + } + + status0 := MDMDeliveryStatus("status") + status1 := MDMDeliveryStatus("status") + items[0].Status = &status0 + checksum0 := []byte("checksum") + checksum1 := []byte("checksum") + items[0].Checksum = checksum0 + assert.False(t, items[0].Equal(items[1])) + + // Set known fields to be equal + fieldsInEqualMethod := 0 + items[1].ProfileUUID = items[0].ProfileUUID + fieldsInEqualMethod++ + items[1].ProfileIdentifier = items[0].ProfileIdentifier + fieldsInEqualMethod++ + items[1].ProfileName = items[0].ProfileName + fieldsInEqualMethod++ + items[1].HostUUID = items[0].HostUUID + fieldsInEqualMethod++ + items[1].HostPlatform = items[0].HostPlatform + fieldsInEqualMethod++ + items[1].Checksum = checksum1 + fieldsInEqualMethod++ + items[1].Status = &status1 + fieldsInEqualMethod++ + items[1].OperationType = items[0].OperationType + fieldsInEqualMethod++ + items[1].Detail = items[0].Detail + fieldsInEqualMethod++ + items[1].CommandUUID = items[0].CommandUUID + fieldsInEqualMethod++ + assert.Equal(t, fieldsInEqualMethod, numberOfFields, "MDMAppleProfilePayload.Equal needs to be updated for new/updated field(s)") + assert.True(t, items[0].Equal(items[1])) + + // Set pointers and slices to nil + items[0].Status = nil + items[1].Status = nil + items[0].Checksum = nil + items[1].Checksum = nil + assert.True(t, items[0].Equal(items[1])) + +} + +func TestConfigurationProfileLabelEqual(t *testing.T) { + t.Parallel() + + // This test is intended to ensure that the cmp.Equal method on ConfigurationProfileLabel is updated when new fields are added. + // The cmp.Equal method is used to identify whether database update is needed. + + items := [...]ConfigurationProfileLabel{{}, {}} + + numberOfFields := 0 + for i := 0; i < len(items); i++ { + rValue := reflect.ValueOf(&items[i]).Elem() + numberOfFields = rValue.NumField() + for j := 0; j < numberOfFields; j++ { + field := rValue.Field(j) + switch field.Kind() { + case reflect.String: + valueToSet := fmt.Sprintf("test %d", i) + field.SetString(valueToSet) + case reflect.Int: + field.SetInt(int64(i)) + case reflect.Uint: + field.SetUint(uint64(i)) + case reflect.Bool: + field.SetBool(i%2 == 0) + case reflect.Pointer: + field.Set(reflect.New(field.Type().Elem())) + default: + t.Fatalf("unhandled field type %s", field.Kind()) + } + } + } + + assert.False(t, cmp.Equal(items[0], items[1])) + + // Set known fields to be equal + fieldsInEqualMethod := 0 + items[1].ProfileUUID = items[0].ProfileUUID + fieldsInEqualMethod++ + items[1].LabelName = items[0].LabelName + fieldsInEqualMethod++ + items[1].LabelID = items[0].LabelID + fieldsInEqualMethod++ + items[1].Broken = items[0].Broken + fieldsInEqualMethod++ + items[1].Exclude = items[0].Exclude + fieldsInEqualMethod++ + + assert.Equal(t, fieldsInEqualMethod, numberOfFields, + "Does cmp.Equal for ConfigurationProfileLabel needs to be updated for new/updated field(s)?") + assert.True(t, cmp.Equal(items[0], items[1])) + +} diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 1d721390b0c3..2689b1114c55 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -531,7 +531,7 @@ type Datastore interface { // InsertSoftwareInstallRequest tracks a new request to install the provided // software installer in the host. It returns the auto-generated installation // uuid. - InsertSoftwareInstallRequest(ctx context.Context, hostID uint, softwareTitleID uint, selfService bool) (string, error) + InsertSoftwareInstallRequest(ctx context.Context, hostID uint, softwareInstallerID uint, selfService bool) (string, error) /////////////////////////////////////////////////////////////////////////////// // SoftwareStore @@ -679,6 +679,8 @@ type Datastore interface { // and have a calendar event scheduled. GetTeamHostsPolicyMemberships(ctx context.Context, domain string, teamID uint, policyIDs []uint, hostID *uint) ([]HostPolicyMembershipData, error) + // GetPoliciesWithAssociatedInstaller returns team policies that have an associated installer. + GetPoliciesWithAssociatedInstaller(ctx context.Context, teamID uint, policyIDs []uint) ([]PolicySoftwareInstallerData, error) GetCalendarPolicies(ctx context.Context, teamID uint) ([]PolicyCalendarData, error) // Methods used for async processing of host policy query results. @@ -1161,7 +1163,9 @@ type Datastore interface { // remove for each affected host to pending for the provided criteria, which // may be either a list of hostIDs, teamIDs, profileUUIDs or hostUUIDs (only // one of those ID types can be provided). - BulkSetPendingMDMHostProfiles(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string) error + BulkSetPendingMDMHostProfiles(ctx context.Context, hostIDs, teamIDs []uint, + profileUUIDs, hostUUIDs []string) (updates MDMProfilesUpdates, + err error) // GetMDMAppleProfilesContents retrieves the XML contents of the // profiles requested. @@ -1284,7 +1288,7 @@ type Datastore interface { // ScreenDEPAssignProfileSerialsForCooldown returns the serials that are still in cooldown and the // ones that are ready to be assigned a profile. If `screenRetryJobs` is true, it will also skip // any serials that have a non-zero `retry_job_id`. - ScreenDEPAssignProfileSerialsForCooldown(ctx context.Context, serials []string) (skipSerials []string, serialsByOrgName map[string][]string, err error) + ScreenDEPAssignProfileSerialsForCooldown(ctx context.Context, serials []string) (skipSerialsByOrgName map[string][]string, serialsByOrgName map[string][]string, err error) // GetDEPAssignProfileExpiredCooldowns returns the serials of the hosts that have expired // cooldowns, grouped by team. GetDEPAssignProfileExpiredCooldowns(ctx context.Context) (map[uint][]string, error) @@ -1354,6 +1358,9 @@ type Datastore interface { ListVPPTokens(ctx context.Context) ([]*VPPTokenDB, error) GetVPPToken(ctx context.Context, tokenID uint) (*VPPTokenDB, error) GetVPPTokenByTeamID(ctx context.Context, teamID *uint) (*VPPTokenDB, error) + // UpdateVPPTokenTeams sets the teams associated with this token. + // Note that updating the token's associations removes all + // apps-team associations using this token UpdateVPPTokenTeams(ctx context.Context, id uint, teams []uint) (*VPPTokenDB, error) UpdateVPPToken(ctx context.Context, id uint, tok *VPPTokenData) (*VPPTokenDB, error) DeleteVPPToken(ctx context.Context, tokenID uint) error @@ -1513,7 +1520,8 @@ type Datastore interface { // BatchSetMDMProfiles sets the MDM Apple or Windows profiles for the given team or // no team in a single transaction. - BatchSetMDMProfiles(ctx context.Context, tmID *uint, macProfiles []*MDMAppleConfigProfile, winProfiles []*MDMWindowsConfigProfile, macDeclarations []*MDMAppleDeclaration) error + BatchSetMDMProfiles(ctx context.Context, tmID *uint, macProfiles []*MDMAppleConfigProfile, winProfiles []*MDMWindowsConfigProfile, + macDeclarations []*MDMAppleDeclaration) (updates MDMProfilesUpdates, err error) // NewMDMAppleDeclaration creates and returns a new MDM Apple declaration. NewMDMAppleDeclaration(ctx context.Context, declaration *MDMAppleDeclaration) (*MDMAppleDeclaration, error) @@ -1621,6 +1629,9 @@ type Datastore interface { // installer execution IDs that have not yet been run for a given host ListPendingSoftwareInstalls(ctx context.Context, hostID uint) ([]string, error) + // GetHostLastInstallData returns the data for the last installation of a package on a host. + GetHostLastInstallData(ctx context.Context, hostID, installerID uint) (*HostLastInstallData, error) + // MatchOrCreateSoftwareInstaller matches or creates a new software installer. MatchOrCreateSoftwareInstaller(ctx context.Context, payload *UploadSoftwareInstallerPayload) (uint, error) @@ -1660,7 +1671,7 @@ type Datastore interface { CleanupUnusedSoftwareInstallers(ctx context.Context, softwareInstallStore SoftwareInstallerStore, removeCreatedBefore time.Time) error // BatchSetSoftwareInstallers sets the software installers for the given team or no team. - BatchSetSoftwareInstallers(ctx context.Context, tmID *uint, installers []*UploadSoftwareInstallerPayload) error + BatchSetSoftwareInstallers(ctx context.Context, tmID *uint, installers []*UploadSoftwareInstallerPayload) ([]SoftwareInstaller, error) // HasSelfServiceSoftwareInstallers returns true if self-service software installers are available for the team or globally. HasSelfServiceSoftwareInstallers(ctx context.Context, platform string, teamID *uint) (bool, error) diff --git a/server/fleet/mdm.go b/server/fleet/mdm.go index 44b422f913a1..69df8e03237c 100644 --- a/server/fleet/mdm.go +++ b/server/fleet/mdm.go @@ -3,7 +3,6 @@ package fleet import ( "bytes" "context" - "encoding/base64" "encoding/json" "fmt" "net/url" @@ -791,48 +790,6 @@ type TeamTuple struct { Name string `json:"name"` } -// ExtractToken extracts the metadata from the token as stored in the database, -// and returns the raw token that can be used directly with Apple's VPP API. If -// while extracting the token it notices that the metadata has changed, it will -// update t and return true as second return value, indicating that it changed -// and should be saved. -func (t *VPPTokenDB) ExtractToken() (rawAppleToken string, didUpdateMetadata bool, err error) { - var vppTokenData VPPTokenData - if err := json.Unmarshal([]byte(t.Token), &vppTokenData); err != nil { - return "", false, fmt.Errorf("unmarshaling VPP token data: %w", err) - } - - vppTokenRawBytes, err := base64.StdEncoding.DecodeString(vppTokenData.Token) - if err != nil { - return "", false, fmt.Errorf("decoding raw vpp token data: %w", err) - } - - var vppTokenRaw VPPTokenRaw - if err := json.Unmarshal(vppTokenRawBytes, &vppTokenRaw); err != nil { - return "", false, fmt.Errorf("unmarshaling raw vpp token data: %w", err) - } - - exp, err := time.Parse("2006-01-02T15:04:05Z0700", vppTokenRaw.ExpDate) - if err != nil { - return "", false, fmt.Errorf("parsing vpp token expiration date: %w", err) - } - - if vppTokenData.Location != t.Location { - t.Location = vppTokenData.Location - didUpdateMetadata = true - } - if vppTokenRaw.OrgName != t.OrgName { - t.OrgName = vppTokenRaw.OrgName - didUpdateMetadata = true - } - if !exp.Equal(t.RenewDate) { - t.RenewDate = exp.UTC() - didUpdateMetadata = true - } - - return vppTokenRaw.Token, didUpdateMetadata, nil -} - type NullTeamType string const ( diff --git a/server/fleet/mdm_test.go b/server/fleet/mdm_test.go index 01bc6787753e..256b65be64ec 100644 --- a/server/fleet/mdm_test.go +++ b/server/fleet/mdm_test.go @@ -212,7 +212,7 @@ func TestDEPClient(t *testing.T) { // simulate using a new token, not yet saved in the DB, so we pass the // token directly in the context ctx = ctxabm.NewContext(ctx, &nanodep_client.OAuth1Tokens{AccessToken: c.token}) - orgName = "new_abm_token" + orgName = apple_mdm.UnsavedABMTokenOrgName } res, err := dep.AccountDetail(ctx, orgName) diff --git a/server/fleet/policies.go b/server/fleet/policies.go index 6ce5e380973a..a66ccc00a453 100644 --- a/server/fleet/policies.go +++ b/server/fleet/policies.go @@ -30,8 +30,44 @@ type PolicyPayload struct { // // Empty string targets all platforms. Platform string - // CalendarEventsEnabled indicates whether calendar events are enabled for the policy. Only applies to team policies. + // CalendarEventsEnabled indicates whether calendar events are enabled for the policy. + // + // Only applies to team policies. + CalendarEventsEnabled bool + // SoftwareInstallerID is the ID of the software installer that will be installed if the policy fails. + // + // Only applies to team policies. + SoftwareInstallerID *uint +} + +// NewTeamPolicyPayload holds data for team policy creation. +// +// If QueryID is not nil, then Name, Query and Description are ignored +// (such fields are fetched from the queries table). +type NewTeamPolicyPayload struct { + // QueryID allows creating a policy from an existing query. + // + // Using QueryID is the old way of creating policies. + // Use Query, Name and Description instead. + QueryID *uint + // Name is the name of the policy (ignored if QueryID != nil). + Name string + // Query is the policy query (ignored if QueryID != nil). + Query string + // Critical marks the policy as high impact. + Critical bool + // Description is the policy description text (ignored if QueryID != nil). + Description string + // Resolution indicates the steps needed to solve a failing policy. + Resolution string + // Platform is a comma-separated string to indicate the target platforms. + // + // Empty string targets all platforms. + Platform string + // CalendarEventsEnabled indicates whether calendar events are enabled for the policy. CalendarEventsEnabled bool + // SoftwareTitleID is the ID of the software title that will be installed if the policy fails. + SoftwareTitleID *uint } var ( @@ -109,8 +145,15 @@ type ModifyPolicyPayload struct { Platform *string `json:"platform"` // Critical marks the policy as high impact. Critical *bool `json:"critical" premium:"true"` - // CalendarEventsEnabled indicates whether calendar events are enabled for the policy. Only applies to team policies. + // CalendarEventsEnabled indicates whether calendar events are enabled for the policy. + // + // Only applies to team policies. CalendarEventsEnabled *bool `json:"calendar_events_enabled" premium:"true"` + // SoftwareTitleID is the ID of the software title that will be installed if the policy fails. + // Value 0 will unset the current installer from the policy. + // + // Only applies to team policies. + SoftwareTitleID *uint `json:"software_title_id" premium:"true"` } // Verify verifies the policy payload is valid. @@ -163,7 +206,8 @@ type PolicyData struct { // Empty string targets all platforms. Platform string `json:"platform" db:"platforms"` - CalendarEventsEnabled bool `json:"calendar_events_enabled" db:"calendar_events_enabled"` + CalendarEventsEnabled bool `json:"calendar_events_enabled" db:"calendar_events_enabled"` + SoftwareInstallerID *uint `json:"-" db:"software_installer_id"` UpdateCreateTimestamps } @@ -177,6 +221,14 @@ type Policy struct { // FailingHostCount is the number of hosts this policy fails on. FailingHostCount uint `json:"failing_host_count" db:"failing_host_count"` HostCountUpdatedAt *time.Time `json:"host_count_updated_at" db:"host_count_updated_at"` + + // InstallSoftware is used to trigger installation of a software title + // when this policy fails. + // + // Only applies to team policies. + // + // This field is populated from PolicyData.SoftwareInstallerID. + InstallSoftware *PolicySoftwareTitle `json:"install_software,omitempty"` } type PolicyCalendarData struct { @@ -184,6 +236,11 @@ type PolicyCalendarData struct { Name string `db:"name" json:"name"` } +type PolicySoftwareInstallerData struct { + ID uint `db:"id"` + InstallerID uint `db:"software_installer_id"` +} + // PolicyLite is a stripped down version of the policy. type PolicyLite struct { ID uint `db:"id"` @@ -232,8 +289,22 @@ type PolicySpec struct { // // Empty string targets all platforms. Platform string `json:"platform,omitempty"` - // CalendarEventsEnabled indicates whether calendar events are enabled for the policy. Only applies to team policies. + // CalendarEventsEnabled indicates whether calendar events are enabled for the policy. + // + // Only applies to team policies. CalendarEventsEnabled bool `json:"calendar_events_enabled"` + // SoftwareTitleID is the title ID of the installer associated with this policy. + // When editing a policy, if this is nil or 0 then the title ID is unset from the policy. + SoftwareTitleID *uint `json:"software_title_id"` +} + +// PolicySoftwareTitle contains software title data for policies. +type PolicySoftwareTitle struct { + // SoftwareTitleID is the ID of the title associated to the policy. + SoftwareTitleID uint `json:"software_title_id"` + // Name is the associated installer title name + // (not the package name, but the installed software title). + Name string `json:"name"` } // Verify verifies the policy data is valid. diff --git a/server/fleet/service.go b/server/fleet/service.go index b81366fcc1bd..ce3aa904a94e 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -640,9 +640,9 @@ type Service interface { // GetSoftwareInstallResults gets the results for a particular software install attempt. GetSoftwareInstallResults(ctx context.Context, installUUID string) (*HostSoftwareInstallerResult, error) - // BatchSetSoftwareInstallers replaces the software installers for a - // specified team - BatchSetSoftwareInstallers(ctx context.Context, tmName string, payloads []SoftwareInstallerPayload, dryRun bool) error + // BatchSetSoftwareInstallers replaces the software installers for a specified team. + // Returns the metadata of inserted software installers. + BatchSetSoftwareInstallers(ctx context.Context, tmName string, payloads []SoftwareInstallerPayload, dryRun bool) ([]SoftwareInstaller, error) // SelfServiceInstallSoftwareTitle installs a software title // initiated by the user @@ -672,7 +672,7 @@ type Service interface { // ///////////////////////////////////////////////////////////////////////////// // Team Policies - NewTeamPolicy(ctx context.Context, teamID uint, p PolicyPayload) (*Policy, error) + NewTeamPolicy(ctx context.Context, teamID uint, p NewTeamPolicyPayload) (*Policy, error) ListTeamPolicies(ctx context.Context, teamID uint, opts ListOptions, iopts ListOptions, mergeInherited bool) (teamPolicies, inheritedPolicies []*Policy, err error) DeleteTeamPolicies(ctx context.Context, teamID uint, ids []uint) ([]uint, error) ModifyTeamPolicy(ctx context.Context, teamID uint, id uint, p ModifyPolicyPayload) (*Policy, error) @@ -930,6 +930,9 @@ type Service interface { // CheckMDMAppleEnrollmentWithMinimumOSVersion checks if the minimum OS version is met for a MDM enrollment CheckMDMAppleEnrollmentWithMinimumOSVersion(ctx context.Context, m *MDMAppleMachineInfo) (*MDMAppleSoftwareUpdateRequired, error) + // GetOTAProfile gets the OTA (over-the-air) profile for a given team based on the enroll secret provided. + GetOTAProfile(ctx context.Context, enrollSecret string) ([]byte, error) + /////////////////////////////////////////////////////////////////////////////// // CronSchedulesService diff --git a/server/fleet/software.go b/server/fleet/software.go index d7492a2d981b..9045d7e37559 100644 --- a/server/fleet/software.go +++ b/server/fleet/software.go @@ -227,6 +227,7 @@ type SoftwareTitleListOptions struct { KnownExploit bool `query:"exploit,optional"` MinimumCVSS float64 `query:"min_cvss_score,optional"` MaximumCVSS float64 `query:"max_cvss_score,optional"` + PackagesOnly bool `query:"packages_only,optional"` } type HostSoftwareTitleListOptions struct { diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go index dbc3ea586e32..1e1566fc36e1 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -74,11 +74,13 @@ type SoftwareInstaller struct { // no team. TeamID *uint `json:"team_id" db:"team_id"` // TitleID is the id of the software title associated with the software installer. - TitleID *uint `json:"-" db:"title_id"` + TitleID *uint `json:"title_id" db:"title_id"` // Name is the name of the software package. Name string `json:"name" db:"filename"` // Version is the version of the software package. Version string `json:"version" db:"version"` + // Platform can be "darwin" (for pkgs), "windows" (for exes/msis) or "linux" (for debs). + Platform string `json:"platform" db:"platform"` // UploadedAt is the time the software package was uploaded. UploadedAt time.Time `json:"uploaded_at" db:"uploaded_at"` // InstallerID is the unique identifier for the software package metadata in Fleet. @@ -102,6 +104,8 @@ type SoftwareInstaller struct { // SelfService indicates that the software can be installed by the // end user without admin intervention SelfService bool `json:"self_service" db:"self_service"` + // URL is the source URL for this installer (set when uploading via batch/gitops). + URL string `json:"url" db:"url"` } // AuthzType implements authz.AuthzTyper. @@ -140,6 +144,14 @@ func (s SoftwareInstallerStatus) IsValid() bool { } } +// HostLastInstallData contains data for the last installation of a package on a host. +type HostLastInstallData struct { + // ExecutionID is the installation ID of the package on the host. + ExecutionID string `db:"execution_id"` + // Status is the status of the installation on the host. + Status *SoftwareInstallerStatus `db:"status"` +} + // HostSoftwareInstaller represents a software installer package that has been installed on a host. type HostSoftwareInstallerResult struct { // ID is the unique numerical ID of the result assigned by the datastore. @@ -183,6 +195,12 @@ type HostSoftwareInstallerResult struct { // HostDeletedAt indicates if the data is associated with a // deleted host HostDeletedAt *time.Time `json:"-" db:"host_deleted_at"` + // SoftwareInstallerUserID is the ID of the user that uploaded the software installer. + SoftwareInstallerUserID *uint `json:"-" db:"software_installer_user_id"` + // SoftwareInstallerUserID is the name of the user that uploaded the software installer. + SoftwareInstallerUserName string `json:"-" db:"software_installer_user_name"` + // SoftwareInstallerUserEmail is the email of the user that uploaded the software installer. + SoftwareInstallerUserEmail string `json:"-" db:"software_installer_user_email"` } const ( @@ -262,6 +280,8 @@ type UploadSoftwareInstallerPayload struct { Platform string BundleIdentifier string SelfService bool + UserID uint + URL string } // DownloadSoftwareInstallerPayload is the payload for downloading a software installer. @@ -333,6 +353,7 @@ type SoftwarePackageOrApp struct { SelfService *bool `json:"self_service,omitempty"` IconURL *string `json:"icon_url"` LastInstall *HostSoftwareInstall `json:"last_install"` + PackageURL *string `json:"package_url"` } type SoftwarePackageSpec struct { @@ -415,3 +436,5 @@ type SoftwareInstallerTokenMetadata struct { TitleID uint `json:"title_id"` TeamID uint `json:"team_id"` } + +const SoftwareInstallerURLMaxLength = 255 diff --git a/server/fleet/vpp.go b/server/fleet/vpp.go index a9fa8ae4519f..8c5dafa15a09 100644 --- a/server/fleet/vpp.go +++ b/server/fleet/vpp.go @@ -32,8 +32,11 @@ type VPPApp struct { Name string `db:"name" json:"name"` // LatestVersion is the latest version of this app. LatestVersion string `db:"latest_version" json:"latest_version"` - TeamID *uint `db:"-" json:"-"` - TitleID uint `db:"title_id" json:"-"` + // TeamID is used for authorization, it must be json serialized to be available + // to the rego script. We don't set it outside authorization anyway, so it + // won't render otherwise. + TeamID *uint `db:"-" json:"team_id,omitempty"` + TitleID uint `db:"title_id" json:"-"` CreatedAt time.Time `db:"created_at" json:"-"` UpdatedAt time.Time `db:"updated_at" json:"-"` diff --git a/server/fleet/windows_mdm.go b/server/fleet/windows_mdm.go index 448776500891..acc90b3a51ac 100644 --- a/server/fleet/windows_mdm.go +++ b/server/fleet/windows_mdm.go @@ -158,6 +158,18 @@ type MDMWindowsProfilePayload struct { Retries int `db:"retries"` } +func (p MDMWindowsProfilePayload) Equal(other MDMWindowsProfilePayload) bool { + statusEqual := p.Status == nil && other.Status == nil || p.Status != nil && other.Status != nil && *p.Status == *other.Status + return statusEqual && + p.ProfileUUID == other.ProfileUUID && + p.HostUUID == other.HostUUID && + p.ProfileName == other.ProfileName && + p.OperationType == other.OperationType && + p.Detail == other.Detail && + p.CommandUUID == other.CommandUUID && + p.Retries == other.Retries +} + type MDMWindowsBulkUpsertHostProfilePayload struct { ProfileUUID string ProfileName string diff --git a/server/mdm/apple/apple_mdm.go b/server/mdm/apple/apple_mdm.go index d85377bb209b..4925202507de 100644 --- a/server/mdm/apple/apple_mdm.go +++ b/server/mdm/apple/apple_mdm.go @@ -153,7 +153,7 @@ func (d *DEPService) createDefaultAutomaticProfile(ctx context.Context) error { // // On success, it returns the profile uuid and timestamp for the specific token // of interest to the caller (identified by its organization name). -func (d *DEPService) RegisterProfileWithAppleDEPServer(ctx context.Context, team *fleet.Team, setupAsst *fleet.MDMAppleSetupAssistant, abmTokeOrgName string) (string, time.Time, error) { +func (d *DEPService) RegisterProfileWithAppleDEPServer(ctx context.Context, team *fleet.Team, setupAsst *fleet.MDMAppleSetupAssistant, abmTokenOrgName string) (string, time.Time, error) { appCfg, err := d.ds.AppConfig(ctx) if err != nil { return "", time.Time{}, ctxerr.Wrap(ctx, err, "fetching app config") @@ -249,7 +249,7 @@ func (d *DEPService) RegisterProfileWithAppleDEPServer(ctx context.Context, team return "", time.Time{}, ctxerr.Wrap(ctx, err, "save default setup assistant profile UUID") } } - if orgName == abmTokeOrgName { + if orgName == abmTokenOrgName { requestedTokenProfileUUID = res.ProfileUUID } } @@ -403,7 +403,7 @@ func (d *DEPService) RunAssigner(ctx context.Context) error { } if cursor != "" && effectiveProfModTime.After(cursorModTime) { - d.logger.Log("msg", "clearing device syncer cursor", "org_name", token.OrganizationName, "team", team.Name) + d.logger.Log("msg", "clearing device syncer cursor", "org_name", token.OrganizationName) if err := d.depStorage.StoreCursor(ctx, token.OrganizationName, ""); err != nil { result = multierror.Append(result, err) continue @@ -624,7 +624,6 @@ func (d *DEPService) processDeviceResponse( } logger := kitlog.With(d.logger, "profile_uuid", profUUID) - level.Info(logger).Log("msg", "calling DEP client to assign profile", "profile_uuid", profUUID) skipSerials, assignSerials, err := d.ds.ScreenDEPAssignProfileSerialsForCooldown(ctx, serials) if err != nil { @@ -643,12 +642,14 @@ func (d *DEPService) processDeviceResponse( for orgName, serials := range assignSerials { apiResp, err := d.depClient.AssignProfile(ctx, orgName, profUUID, serials...) if err != nil { + // only log the error so the failure can be recorded + // below in UpdateHostDEPAssignProfileResponses and + // the proper cooldowns are applied level.Error(logger).Log( "msg", "assign profile", "devices", len(assignSerials), "err", err, ) - return fmt.Errorf("assign profile: %w", err) } logs := []interface{}{ @@ -1041,3 +1042,36 @@ func IOSiPadOSRefetch(ctx context.Context, ds fleet.Datastore, commander *MDMApp } return nil } + +func GenerateOTAEnrollmentProfileMobileconfig(orgName, fleetURL, enrollSecret string) ([]byte, error) { + path, err := url.JoinPath(fleetURL, "/api/v1/fleet/ota_enrollment") + if err != nil { + return nil, fmt.Errorf("creating path for ota enrollment url: %w", err) + } + + enrollURL, err := url.Parse(path) + if err != nil { + return nil, fmt.Errorf("parsing ota enrollment url: %w", err) + } + + q := enrollURL.Query() + q.Set("enroll_secret", enrollSecret) + enrollURL.RawQuery = q.Encode() + + var profileBuf bytes.Buffer + tmplArgs := struct { + Organization string + URL string + EnrollSecret string + }{ + Organization: orgName, + URL: enrollURL.String(), + } + + err = mobileconfig.OTAMobileConfigTemplate.Execute(&profileBuf, tmplArgs) + if err != nil { + return nil, fmt.Errorf("executing ota profile template: %w", err) + } + + return profileBuf.Bytes(), nil +} diff --git a/server/mdm/apple/apple_mdm_external_test.go b/server/mdm/apple/apple_mdm_external_test.go index 1867b9357771..30287b0a32c6 100644 --- a/server/mdm/apple/apple_mdm_external_test.go +++ b/server/mdm/apple/apple_mdm_external_test.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" @@ -21,11 +22,10 @@ import ( ) func TestDEPService_RunAssigner(t *testing.T) { - // FIXME - t.Skip() ctx := context.Background() ds := mysql.CreateMySQLDS(t) + const abmTokenOrgName = "test_org" depStorage, err := ds.NewMDMAppleDEPStorage() require.NoError(t, err) @@ -35,10 +35,10 @@ func TestDEPService_RunAssigner(t *testing.T) { t.Cleanup(srv.Close) t.Cleanup(func() { mysql.TruncateTables(t, ds) }) - err = depStorage.StoreConfig(ctx, "fleet", &nanodep_client.Config{BaseURL: srv.URL}) + err = depStorage.StoreConfig(ctx, abmTokenOrgName, &nanodep_client.Config{BaseURL: srv.URL}) require.NoError(t, err) - mysql.SetTestABMAssets(t, ds, "fleet") + mysql.SetTestABMAssets(t, ds, abmTokenOrgName) logger := log.NewNopLogger() return apple_mdm.NewDEPService(ds, depStorage, logger) @@ -54,7 +54,7 @@ func TestDEPService_RunAssigner(t *testing.T) { case "/session": _, _ = w.Write([]byte(`{"auth_session_token": "session123"}`)) case "/account": - _, _ = w.Write([]byte(`{"admin_id": "admin123", "org_name": "test_org"}`)) + _, _ = w.Write([]byte(fmt.Sprintf(`{"admin_id": "admin123", "org_name": "%s"}`, abmTokenOrgName))) case "/profile": err := encoder.Encode(godep.ProfileResponse{ProfileUUID: "profile123"}) require.NoError(t, err) @@ -78,7 +78,7 @@ func TestDEPService_RunAssigner(t *testing.T) { require.NotEmpty(t, defProf.Token) // a profile UUID was assigned for no-team - profUUID, modTime, err := ds.GetMDMAppleDefaultSetupAssistant(ctx, nil, "") + profUUID, modTime, err := ds.GetMDMAppleDefaultSetupAssistant(ctx, nil, abmTokenOrgName) require.NoError(t, err) require.Equal(t, "profile123", profUUID) require.False(t, modTime.Before(start)) @@ -87,6 +87,11 @@ func TestDEPService_RunAssigner(t *testing.T) { appCfg, err := ds.AppConfig(ctx) require.NoError(t, err) require.Empty(t, appCfg.MDM.DeprecatedAppleBMDefaultTeam) + abmTok, err := ds.GetABMTokenByOrgName(ctx, abmTokenOrgName) + require.NoError(t, err) + require.Nil(t, abmTok.MacOSDefaultTeamID) + require.Nil(t, abmTok.IPadOSDefaultTeamID) + require.Nil(t, abmTok.IOSDefaultTeamID) // no teams, so no team-specific custom setup assistants teams, err := ds.ListTeams(ctx, fleet.TeamFilter{User: test.UserAdmin}, fleet.ListOptions{}) @@ -120,7 +125,7 @@ func TestDEPService_RunAssigner(t *testing.T) { case "/session": _, _ = w.Write([]byte(`{"auth_session_token": "session123"}`)) case "/account": - _, _ = w.Write([]byte(`{"admin_id": "admin123", "org_name": "test_org"}`)) + _, _ = w.Write([]byte(fmt.Sprintf(`{"admin_id": "admin123", "org_name": "%s"}`, abmTokenOrgName))) case "/profile": err := encoder.Encode(godep.ProfileResponse{ProfileUUID: "profile123"}) require.NoError(t, err) @@ -158,7 +163,7 @@ func TestDEPService_RunAssigner(t *testing.T) { require.NotEmpty(t, defProf.Token) // a profile UUID was assigned to no-team - profUUID, modTime, err := ds.GetMDMAppleDefaultSetupAssistant(ctx, nil, "") + profUUID, modTime, err := ds.GetMDMAppleDefaultSetupAssistant(ctx, nil, abmTokenOrgName) require.NoError(t, err) require.Equal(t, "profile123", profUUID) require.False(t, modTime.Before(start)) @@ -192,7 +197,7 @@ func TestDEPService_RunAssigner(t *testing.T) { case "/session": _, _ = w.Write([]byte(`{"auth_session_token": "session123"}`)) case "/account": - _, _ = w.Write([]byte(`{"admin_id": "admin123", "org_name": "test_org"}`)) + _, _ = w.Write([]byte(fmt.Sprintf(`{"admin_id": "admin123", "org_name": "%s"}`, abmTokenOrgName))) case "/profile": reqBody, err := io.ReadAll(r.Body) require.NoError(t, err) @@ -236,12 +241,11 @@ func TestDEPService_RunAssigner(t *testing.T) { tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "test_team"}) require.NoError(t, err) - appCfg, err := ds.AppConfig(ctx) + // set that team as default assignment for new macOS devices + tok, err := ds.GetABMTokenByOrgName(ctx, abmTokenOrgName) require.NoError(t, err) - - // set that team as default assignment for new devices - appCfg.MDM.DeprecatedAppleBMDefaultTeam = tm.Name - err = ds.SaveAppConfig(ctx, appCfg) + tok.MacOSDefaultTeamID = &tm.ID + err = ds.SaveABMToken(ctx, tok) require.NoError(t, err) // create a custom setup assistant for that team @@ -264,7 +268,7 @@ func TestDEPService_RunAssigner(t *testing.T) { require.NotEmpty(t, defProf.Token) // a profile UUID was assigned to the team - profUUID, modTime, err := ds.GetMDMAppleDefaultSetupAssistant(ctx, &tm.ID, "") + profUUID, modTime, err := ds.GetMDMAppleDefaultSetupAssistant(ctx, &tm.ID, abmTokenOrgName) require.NoError(t, err) require.Equal(t, "profile123", profUUID) require.False(t, modTime.Before(start)) @@ -272,8 +276,11 @@ func TestDEPService_RunAssigner(t *testing.T) { // the team-specific custom profile was registered tmAsst, err = ds.GetMDMAppleSetupAssistant(ctx, tmAsst.TeamID) require.NoError(t, err) - //require.Equal(t, "profile456", tmAsst.ProfileUUID) require.False(t, tmAsst.UploadedAt.Before(start)) + profileUUID, modTime, err := ds.GetMDMAppleSetupAssistantProfileForABMToken(ctx, &tm.ID, abmTokenOrgName) + require.NoError(t, err) + require.Equal(t, "profile456", profileUUID) + require.True(t, tmAsst.UploadedAt.Equal(modTime)) // a couple hosts were created and assigned to the team (except the op_type ignored) hosts, err := ds.ListHosts(ctx, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{}) diff --git a/server/mdm/apple/apple_mdm_test.go b/server/mdm/apple/apple_mdm_test.go index b1f4075f504d..156736ecd4e5 100644 --- a/server/mdm/apple/apple_mdm_test.go +++ b/server/mdm/apple/apple_mdm_test.go @@ -21,8 +21,6 @@ import ( ) func TestDEPService(t *testing.T) { - // FIXME - t.Skip() t.Run("EnsureDefaultSetupAssistant", func(t *testing.T) { ds := new(mock.Store) ctx := context.Background() @@ -70,6 +68,9 @@ func TestDEPService(t *testing.T) { Token: p.Token, Type: p.Type, DEPProfile: p.DEPProfile, + UpdateCreateTimestamps: fleet.UpdateCreateTimestamps{ + UpdateTimestamp: fleet.UpdateTimestamp{UpdatedAt: time.Now()}, + }, } savedProfile = res return res, nil @@ -122,7 +123,7 @@ func TestDEPService(t *testing.T) { return 0, nil } - profUUID, modTime, err := depSvc.EnsureDefaultSetupAssistant(ctx, nil, "") + profUUID, modTime, err := depSvc.EnsureDefaultSetupAssistant(ctx, nil, "org1") require.NoError(t, err) require.Equal(t, "abcd", profUUID) require.NotZero(t, modTime) diff --git a/server/mdm/apple/commander.go b/server/mdm/apple/commander.go index 68bd90c82e14..6a098a5284ef 100644 --- a/server/mdm/apple/commander.go +++ b/server/mdm/apple/commander.go @@ -5,6 +5,8 @@ import ( "encoding/base64" "fmt" "net/http" + "sort" + "strings" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" @@ -383,14 +385,15 @@ func (svc *MDMAppleCommander) SendNotifications(ctx context.Context, hostUUIDs [ // Even if we didn't get an error, some of the APNs // responses might have failed, signal that to the caller. - var failed []string + failed := map[string]error{} for uuid, response := range apnsResponses { if response.Err != nil { - failed = append(failed, uuid) + failed[uuid] = response.Err } } + if len(failed) > 0 { - return &APNSDeliveryError{FailedUUIDs: failed, Err: err} + return &APNSDeliveryError{errorsByUUID: failed} } return nil @@ -399,14 +402,38 @@ func (svc *MDMAppleCommander) SendNotifications(ctx context.Context, hostUUIDs [ // APNSDeliveryError records an error and the associated host UUIDs in which it // occurred. type APNSDeliveryError struct { - FailedUUIDs []string - Err error + errorsByUUID map[string]error } func (e *APNSDeliveryError) Error() string { - return fmt.Sprintf("APNS delivery failed with: %s, for UUIDs: %v", e.Err, e.FailedUUIDs) + var uuids []string + for uuid := range e.errorsByUUID { + uuids = append(uuids, uuid) + } + + // sort UUIDs alphabetically for deterministic output + sort.Strings(uuids) + + var errStrings []string + for _, uuid := range uuids { + errStrings = append(errStrings, fmt.Sprintf("UUID: %s, Error: %v", uuid, e.errorsByUUID[uuid])) + } + + return fmt.Sprintf( + "APNS delivery failed with the following errors:\n%s", + strings.Join(errStrings, "\n"), + ) } -func (e *APNSDeliveryError) Unwrap() error { return e.Err } +func (e *APNSDeliveryError) FailedUUIDs() []string { + var uuids []string + for uuid := range e.errorsByUUID { + uuids = append(uuids, uuid) + } + + // sort UUIDs alphabetically for deterministic output + sort.Strings(uuids) + return uuids +} func (e *APNSDeliveryError) StatusCode() int { return http.StatusBadGateway } diff --git a/server/mdm/apple/commander_test.go b/server/mdm/apple/commander_test.go index 5978944b52d8..0d21c66ab5fb 100644 --- a/server/mdm/apple/commander_test.go +++ b/server/mdm/apple/commander_test.go @@ -3,7 +3,9 @@ package apple_mdm import ( "context" "crypto/tls" + "errors" "fmt" + "net/http" "os" "testing" @@ -200,3 +202,50 @@ func mobileconfigForTest(name, identifier string) []byte { </plist> `, name, identifier, uuid.New().String())) } + +func TestAPNSDeliveryError(t *testing.T) { + tests := []struct { + name string + errorsByUUID map[string]error + expectedError string + expectedFailedUUIDs []string + expectedStatusCode int + }{ + { + name: "single error", + errorsByUUID: map[string]error{ + "uuid1": errors.New("network error"), + }, + expectedError: `APNS delivery failed with the following errors: +UUID: uuid1, Error: network error`, + expectedFailedUUIDs: []string{"uuid1"}, + expectedStatusCode: http.StatusBadGateway, + }, + { + name: "multiple errors, sorted", + errorsByUUID: map[string]error{ + "uuid3": errors.New("timeout error"), + "uuid1": errors.New("network error"), + "uuid2": errors.New("certificate error"), + }, + expectedError: `APNS delivery failed with the following errors: +UUID: uuid1, Error: network error +UUID: uuid2, Error: certificate error +UUID: uuid3, Error: timeout error`, + expectedFailedUUIDs: []string{"uuid1", "uuid2", "uuid3"}, + expectedStatusCode: http.StatusBadGateway, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + apnsErr := &APNSDeliveryError{ + errorsByUUID: tt.errorsByUUID, + } + + require.Equal(t, tt.expectedError, apnsErr.Error()) + require.Equal(t, tt.expectedFailedUUIDs, apnsErr.FailedUUIDs()) + require.Equal(t, tt.expectedStatusCode, apnsErr.StatusCode()) + }) + } +} diff --git a/server/mdm/apple/mobileconfig/profiles.go b/server/mdm/apple/mobileconfig/profiles.go index a2dfdf44380b..b71d2db5ab98 100644 --- a/server/mdm/apple/mobileconfig/profiles.go +++ b/server/mdm/apple/mobileconfig/profiles.go @@ -1,6 +1,11 @@ package mobileconfig -import "text/template" +import ( + "encoding/xml" + "fmt" + "strings" + "text/template" +) var funcMap = map[string]any{ "xml": XMLEscapeString, @@ -113,3 +118,40 @@ var FleetCARootTemplate = template.Must(template.New("").Option("missingkey=erro </dict> </plist> `)) + +var OTAMobileConfigTemplate = template.Must(template.New("").Funcs(template.FuncMap{"xml": func(v string) (string, error) { + var escaped strings.Builder + if err := xml.EscapeText(&escaped, []byte(v)); err != nil { + return "", fmt.Errorf("XML escaping in OTA profile: %w", err) + } + return escaped.String(), nil +}}).Option("missingkey=error").Parse(`<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple Inc//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> + <dict> + <key>PayloadContent</key> + <dict> + <key>URL</key> + <string>{{ .URL }}</string> + <key>DeviceAttributes</key> + <array> + <string>UDID</string> + <string>VERSION</string> + <string>PRODUCT</string> + <string>SERIAL</string> + </array> + </dict> + <key>PayloadOrganization</key> + <string>{{ xml .Organization }}</string> + <key>PayloadDisplayName</key> + <string>{{ xml .Organization }} enrollment</string> + <key>PayloadVersion</key> + <integer>1</integer> + <key>PayloadUUID</key> + <string>fdb376e5-b5bb-4d8c-829e-e90865f990c9</string> + <key>PayloadIdentifier</key> + <string>com.fleetdm.fleet.mdm.apple.ota</string> + <key>PayloadType</key> + <string>Profile Service</string> + </dict> +</plist>`)) diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 128371de5079..99d55d12158c 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -396,7 +396,7 @@ type ListSoftwareTitlesFunc func(ctx context.Context, opt fleet.SoftwareTitleLis type SoftwareTitleByIDFunc func(ctx context.Context, id uint, teamID *uint, tmFilter fleet.TeamFilter) (*fleet.SoftwareTitle, error) -type InsertSoftwareInstallRequestFunc func(ctx context.Context, hostID uint, softwareTitleID uint, selfService bool) (string, error) +type InsertSoftwareInstallRequestFunc func(ctx context.Context, hostID uint, softwareInstallerID uint, selfService bool) (string, error) type ListSoftwareForVulnDetectionFunc func(ctx context.Context, filter fleet.VulnSoftwareFilter) ([]fleet.Software, error) @@ -494,6 +494,8 @@ type PolicyQueriesForHostFunc func(ctx context.Context, host *fleet.Host) (map[s type GetTeamHostsPolicyMembershipsFunc func(ctx context.Context, domain string, teamID uint, policyIDs []uint, hostID *uint) ([]fleet.HostPolicyMembershipData, error) +type GetPoliciesWithAssociatedInstallerFunc func(ctx context.Context, teamID uint, policyIDs []uint) ([]fleet.PolicySoftwareInstallerData, error) + type GetCalendarPoliciesFunc func(ctx context.Context, teamID uint) ([]fleet.PolicyCalendarData, error) type AsyncBatchInsertPolicyMembershipFunc func(ctx context.Context, batch []fleet.PolicyMembershipResult) error @@ -778,7 +780,7 @@ type ListMDMAppleProfilesToRemoveFunc func(ctx context.Context) ([]*fleet.MDMApp type BulkUpsertMDMAppleHostProfilesFunc func(ctx context.Context, payload []*fleet.MDMAppleBulkUpsertHostProfilePayload) error -type BulkSetPendingMDMHostProfilesFunc func(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string) error +type BulkSetPendingMDMHostProfilesFunc func(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string) (updates fleet.MDMProfilesUpdates, err error) type GetMDMAppleProfilesContentsFunc func(ctx context.Context, profileUUIDs []string) (map[string]mobileconfig.Mobileconfig, error) @@ -842,7 +844,7 @@ type DeleteHostDEPAssignmentsFunc func(ctx context.Context, serials []string) er type UpdateHostDEPAssignProfileResponsesFunc func(ctx context.Context, resp *godep.ProfileResponse) error -type ScreenDEPAssignProfileSerialsForCooldownFunc func(ctx context.Context, serials []string) (skipSerials []string, serialsByOrgName map[string][]string, err error) +type ScreenDEPAssignProfileSerialsForCooldownFunc func(ctx context.Context, serials []string) (skipSerialsByOrgName map[string][]string, serialsByOrgName map[string][]string, err error) type GetDEPAssignProfileExpiredCooldownsFunc func(ctx context.Context) (map[uint][]string, error) @@ -970,7 +972,7 @@ type NewMDMWindowsConfigProfileFunc func(ctx context.Context, cp fleet.MDMWindow type SetOrUpdateMDMWindowsConfigProfileFunc func(ctx context.Context, cp fleet.MDMWindowsConfigProfile) error -type BatchSetMDMProfilesFunc func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDeclarations []*fleet.MDMAppleDeclaration) error +type BatchSetMDMProfilesFunc func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDeclarations []*fleet.MDMAppleDeclaration) (updates fleet.MDMProfilesUpdates, err error) type NewMDMAppleDeclarationFunc func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) @@ -1024,6 +1026,8 @@ type GetSoftwareInstallDetailsFunc func(ctx context.Context, executionId string) type ListPendingSoftwareInstallsFunc func(ctx context.Context, hostID uint) ([]string, error) +type GetHostLastInstallDataFunc func(ctx context.Context, hostID uint, installerID uint) (*fleet.HostLastInstallData, error) + type MatchOrCreateSoftwareInstallerFunc func(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (uint, error) type GetSoftwareInstallerMetadataByIDFunc func(ctx context.Context, id uint) (*fleet.SoftwareInstaller, error) @@ -1046,7 +1050,7 @@ type GetSoftwareInstallResultsFunc func(ctx context.Context, resultsUUID string) type CleanupUnusedSoftwareInstallersFunc func(ctx context.Context, softwareInstallStore fleet.SoftwareInstallerStore, removeCreatedBefore time.Time) error -type BatchSetSoftwareInstallersFunc func(ctx context.Context, tmID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error +type BatchSetSoftwareInstallersFunc func(ctx context.Context, tmID *uint, installers []*fleet.UploadSoftwareInstallerPayload) ([]fleet.SoftwareInstaller, error) type HasSelfServiceSoftwareInstallersFunc func(ctx context.Context, platform string, teamID *uint) (bool, error) @@ -1776,6 +1780,9 @@ type DataStore struct { GetTeamHostsPolicyMembershipsFunc GetTeamHostsPolicyMembershipsFunc GetTeamHostsPolicyMembershipsFuncInvoked bool + GetPoliciesWithAssociatedInstallerFunc GetPoliciesWithAssociatedInstallerFunc + GetPoliciesWithAssociatedInstallerFuncInvoked bool + GetCalendarPoliciesFunc GetCalendarPoliciesFunc GetCalendarPoliciesFuncInvoked bool @@ -2571,6 +2578,9 @@ type DataStore struct { ListPendingSoftwareInstallsFunc ListPendingSoftwareInstallsFunc ListPendingSoftwareInstallsFuncInvoked bool + GetHostLastInstallDataFunc GetHostLastInstallDataFunc + GetHostLastInstallDataFuncInvoked bool + MatchOrCreateSoftwareInstallerFunc MatchOrCreateSoftwareInstallerFunc MatchOrCreateSoftwareInstallerFuncInvoked bool @@ -3950,11 +3960,11 @@ func (s *DataStore) SoftwareTitleByID(ctx context.Context, id uint, teamID *uint return s.SoftwareTitleByIDFunc(ctx, id, teamID, tmFilter) } -func (s *DataStore) InsertSoftwareInstallRequest(ctx context.Context, hostID uint, softwareTitleID uint, selfService bool) (string, error) { +func (s *DataStore) InsertSoftwareInstallRequest(ctx context.Context, hostID uint, softwareInstallerID uint, selfService bool) (string, error) { s.mu.Lock() s.InsertSoftwareInstallRequestFuncInvoked = true s.mu.Unlock() - return s.InsertSoftwareInstallRequestFunc(ctx, hostID, softwareTitleID, selfService) + return s.InsertSoftwareInstallRequestFunc(ctx, hostID, softwareInstallerID, selfService) } func (s *DataStore) ListSoftwareForVulnDetection(ctx context.Context, filter fleet.VulnSoftwareFilter) ([]fleet.Software, error) { @@ -4293,6 +4303,13 @@ func (s *DataStore) GetTeamHostsPolicyMemberships(ctx context.Context, domain st return s.GetTeamHostsPolicyMembershipsFunc(ctx, domain, teamID, policyIDs, hostID) } +func (s *DataStore) GetPoliciesWithAssociatedInstaller(ctx context.Context, teamID uint, policyIDs []uint) ([]fleet.PolicySoftwareInstallerData, error) { + s.mu.Lock() + s.GetPoliciesWithAssociatedInstallerFuncInvoked = true + s.mu.Unlock() + return s.GetPoliciesWithAssociatedInstallerFunc(ctx, teamID, policyIDs) +} + func (s *DataStore) GetCalendarPolicies(ctx context.Context, teamID uint) ([]fleet.PolicyCalendarData, error) { s.mu.Lock() s.GetCalendarPoliciesFuncInvoked = true @@ -5287,7 +5304,7 @@ func (s *DataStore) BulkUpsertMDMAppleHostProfiles(ctx context.Context, payload return s.BulkUpsertMDMAppleHostProfilesFunc(ctx, payload) } -func (s *DataStore) BulkSetPendingMDMHostProfiles(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string) error { +func (s *DataStore) BulkSetPendingMDMHostProfiles(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string) (updates fleet.MDMProfilesUpdates, err error) { s.mu.Lock() s.BulkSetPendingMDMHostProfilesFuncInvoked = true s.mu.Unlock() @@ -5511,7 +5528,7 @@ func (s *DataStore) UpdateHostDEPAssignProfileResponses(ctx context.Context, res return s.UpdateHostDEPAssignProfileResponsesFunc(ctx, resp) } -func (s *DataStore) ScreenDEPAssignProfileSerialsForCooldown(ctx context.Context, serials []string) (skipSerials []string, serialsByOrgName map[string][]string, err error) { +func (s *DataStore) ScreenDEPAssignProfileSerialsForCooldown(ctx context.Context, serials []string) (skipSerialsByOrgName map[string][]string, serialsByOrgName map[string][]string, err error) { s.mu.Lock() s.ScreenDEPAssignProfileSerialsForCooldownFuncInvoked = true s.mu.Unlock() @@ -5959,7 +5976,7 @@ func (s *DataStore) SetOrUpdateMDMWindowsConfigProfile(ctx context.Context, cp f return s.SetOrUpdateMDMWindowsConfigProfileFunc(ctx, cp) } -func (s *DataStore) BatchSetMDMProfiles(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDeclarations []*fleet.MDMAppleDeclaration) error { +func (s *DataStore) BatchSetMDMProfiles(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDeclarations []*fleet.MDMAppleDeclaration) (updates fleet.MDMProfilesUpdates, err error) { s.mu.Lock() s.BatchSetMDMProfilesFuncInvoked = true s.mu.Unlock() @@ -6148,6 +6165,13 @@ func (s *DataStore) ListPendingSoftwareInstalls(ctx context.Context, hostID uint return s.ListPendingSoftwareInstallsFunc(ctx, hostID) } +func (s *DataStore) GetHostLastInstallData(ctx context.Context, hostID uint, installerID uint) (*fleet.HostLastInstallData, error) { + s.mu.Lock() + s.GetHostLastInstallDataFuncInvoked = true + s.mu.Unlock() + return s.GetHostLastInstallDataFunc(ctx, hostID, installerID) +} + func (s *DataStore) MatchOrCreateSoftwareInstaller(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (uint, error) { s.mu.Lock() s.MatchOrCreateSoftwareInstallerFuncInvoked = true @@ -6225,7 +6249,7 @@ func (s *DataStore) CleanupUnusedSoftwareInstallers(ctx context.Context, softwar return s.CleanupUnusedSoftwareInstallersFunc(ctx, softwareInstallStore, removeCreatedBefore) } -func (s *DataStore) BatchSetSoftwareInstallers(ctx context.Context, tmID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { +func (s *DataStore) BatchSetSoftwareInstallers(ctx context.Context, tmID *uint, installers []*fleet.UploadSoftwareInstallerPayload) ([]fleet.SoftwareInstaller, error) { s.mu.Lock() s.BatchSetSoftwareInstallersFuncInvoked = true s.mu.Unlock() diff --git a/server/service/activities.go b/server/service/activities.go index cdab1837f1a2..861c87309520 100644 --- a/server/service/activities.go +++ b/server/service/activities.go @@ -85,7 +85,12 @@ func newActivity(ctx context.Context, user *fleet.User, activity fleet.ActivityD var userName *string var userEmail *string if user != nil { - userID = &user.ID + // To support creating activities with users that were deleted. This can happen + // for automatically installed software which uses the author of the upload as the author of + // the installation. + if user.ID != 0 { + userID = &user.ID + } userName = &user.Name userEmail = &user.Email } diff --git a/server/service/appconfig.go b/server/service/appconfig.go index 956de2b96b6b..f384dec0d5d2 100644 --- a/server/service/appconfig.go +++ b/server/service/appconfig.go @@ -414,12 +414,12 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle return nil, ctxerr.Wrap(ctx, err, "validating MDM config") } - abmAssignments, err := svc.validateABMAssignments(ctx, &appConfig.MDM, &oldAppConfig.MDM, invalid, license) + abmAssignments, err := svc.validateABMAssignments(ctx, &newAppConfig.MDM, &oldAppConfig.MDM, invalid, license) if err != nil { return nil, ctxerr.Wrap(ctx, err, "validating ABM token assignments") } - vppAssignments, err := svc.validateVPPAssignments(ctx, &appConfig.MDM, invalid, license) + vppAssignments, err := svc.validateVPPAssignments(ctx, &newAppConfig.MDM, invalid, license) if err != nil { return nil, ctxerr.Wrap(ctx, err, "validating VPP token assignments") } @@ -545,15 +545,16 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle } } - if appConfig.MDM.AppleBussinessManager.Set || appConfig.MDM.DeprecatedAppleBMDefaultTeam != "" { + if (appConfig.MDM.AppleBusinessManager.Set && appConfig.MDM.AppleBusinessManager.Valid) || appConfig.MDM.DeprecatedAppleBMDefaultTeam != "" { for _, tok := range abmAssignments { + fmt.Println(tok.EncryptedToken) if err := svc.ds.SaveABMToken(ctx, tok); err != nil { return nil, ctxerr.Wrap(ctx, err, "saving ABM token assignments") } } } - if appConfig.MDM.VolumePurchasingProgram.Set { + if appConfig.MDM.VolumePurchasingProgram.Set && appConfig.MDM.VolumePurchasingProgram.Valid { for tokenID, tokenTeams := range vppAssignments { if _, err := svc.ds.UpdateVPPTokenTeams(ctx, tokenID, tokenTeams); err != nil { return nil, ctxerr.Wrap(ctx, err, "saving ABM token assignments") @@ -835,7 +836,7 @@ func (svc *Service) validateMDM( len(mdm.WindowsSettings.CustomSettings.Value) > 0 && !fleet.MDMProfileSpecsMatch(mdm.WindowsSettings.CustomSettings.Value, oldMdm.WindowsSettings.CustomSettings.Value) { invalid.Append("windows_settings.custom_settings", - `Couldn’t edit windows_settings.custom_settings. Windows MDM isn’t turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.`) + `Couldn’t edit windows_settings.custom_settings. Windows MDM isn’t turned on. This can be enabled by setting "controls.windows_enabled_and_configured: true" in the default configuration. Visit https://fleetdm.com/guides/windows-mdm-setup and https://fleetdm.com/docs/configuration/yaml-files#controls to learn more about enabling MDM.`) } } checkCustomSettings("windows", mdm.WindowsSettings.CustomSettings.Value) @@ -970,7 +971,7 @@ func (svc *Service) validateABMAssignments( invalid *fleet.InvalidArgumentError, license *fleet.LicenseInfo, ) ([]*fleet.ABMToken, error) { - if mdm.DeprecatedAppleBMDefaultTeam != "" && mdm.AppleBussinessManager.Set && mdm.AppleBussinessManager.Valid { + if mdm.DeprecatedAppleBMDefaultTeam != "" && mdm.AppleBusinessManager.Set && mdm.AppleBusinessManager.Valid { invalid.Append("mdm.apple_bm_default_team", fleet.AppleABMDefaultTeamDeprecatedMessage) return nil, nil } @@ -1008,7 +1009,7 @@ func (svc *Service) validateABMAssignments( return []*fleet.ABMToken{tok}, nil } - if mdm.AppleBussinessManager.Set && mdm.AppleBussinessManager.Valid { + if mdm.AppleBusinessManager.Set && mdm.AppleBusinessManager.Valid { if !license.IsPremium() { invalid.Append("mdm.apple_business_manager", ErrMissingLicense.Error()) return nil, nil @@ -1040,7 +1041,7 @@ func (svc *Service) validateABMAssignments( } var tokensToSave []*fleet.ABMToken - for _, bm := range mdm.AppleBussinessManager.Value { + for _, bm := range mdm.AppleBusinessManager.Value { for _, tmName := range []string{bm.MacOSTeam, bm.IOSTeam, bm.IpadOSTeam} { if _, ok := teamsByName[norm.NFC.String(tmName)]; !ok { invalid.Appendf("mdm.apple_business_manager", "team %s doesn't exist", tmName) @@ -1101,10 +1102,10 @@ func (svc *Service) validateVPPAssignments( token.Teams = nil } - var tokensToSave map[uint][]uint + tokensToSave := make(map[uint][]uint, len(mdm.VolumePurchasingProgram.Value)) for _, vpp := range mdm.VolumePurchasingProgram.Value { for _, tmName := range vpp.Teams { - if _, ok := teamsByName[norm.NFC.String(tmName)]; !ok { + if _, ok := teamsByName[norm.NFC.String(tmName)]; !ok && tmName != fleet.TeamNameAllTeams { invalid.Appendf("mdm.volume_purchasing_program", "team %s doesn't exist", tmName) return nil, nil } diff --git a/server/service/appconfig_test.go b/server/service/appconfig_test.go index 67173e194244..0fb0d318d26f 100644 --- a/server/service/appconfig_test.go +++ b/server/service/appconfig_test.go @@ -827,8 +827,6 @@ func TestTransparencyURLDowngradeLicense(t *testing.T) { } func TestMDMAppleConfig(t *testing.T) { - // FIXME - t.Skip() ds := new(mock.Store) depStorage := new(nanodep_mock.Storage) @@ -860,11 +858,13 @@ func TestMDMAppleConfig(t *testing.T) { name: "nochange", licenseTier: "free", expectedMDM: fleet.MDM{ - MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)}, - MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, - IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, - IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, - WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, + AppleBusinessManager: optjson.Slice[fleet.MDMAppleABMAssignmentInfo]{Set: true, Value: []fleet.MDMAppleABMAssignmentInfo{}}, + MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)}, + MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + VolumePurchasingProgram: optjson.Slice[fleet.MDMAppleVolumePurchasingProgramInfo]{Set: true, Value: []fleet.MDMAppleVolumePurchasingProgramInfo{}}, + WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, WindowsSettings: fleet.WindowsSettings{ CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}}, }, @@ -891,11 +891,13 @@ func TestMDMAppleConfig(t *testing.T) { findTeam: true, newMDM: fleet.MDM{DeprecatedAppleBMDefaultTeam: "foobar"}, expectedMDM: fleet.MDM{ + AppleBusinessManager: optjson.Slice[fleet.MDMAppleABMAssignmentInfo]{Set: true, Value: []fleet.MDMAppleABMAssignmentInfo{}}, DeprecatedAppleBMDefaultTeam: "foobar", MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)}, MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + VolumePurchasingProgram: optjson.Slice[fleet.MDMAppleVolumePurchasingProgramInfo]{Set: true, Value: []fleet.MDMAppleVolumePurchasingProgramInfo{}}, WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, WindowsSettings: fleet.WindowsSettings{ CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}}, @@ -908,11 +910,13 @@ func TestMDMAppleConfig(t *testing.T) { oldMDM: fleet.MDM{DeprecatedAppleBMDefaultTeam: "bar"}, newMDM: fleet.MDM{DeprecatedAppleBMDefaultTeam: "foobar"}, expectedMDM: fleet.MDM{ + AppleBusinessManager: optjson.Slice[fleet.MDMAppleABMAssignmentInfo]{Set: true, Value: []fleet.MDMAppleABMAssignmentInfo{}}, DeprecatedAppleBMDefaultTeam: "foobar", MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)}, MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + VolumePurchasingProgram: optjson.Slice[fleet.MDMAppleVolumePurchasingProgramInfo]{Set: true, Value: []fleet.MDMAppleVolumePurchasingProgramInfo{}}, WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, WindowsSettings: fleet.WindowsSettings{ CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}}, @@ -931,12 +935,14 @@ func TestMDMAppleConfig(t *testing.T) { newMDM: fleet.MDM{EndUserAuthentication: fleet.MDMEndUserAuthentication{SSOProviderSettings: fleet.SSOProviderSettings{EntityID: "foo"}}}, oldMDM: fleet.MDM{EndUserAuthentication: fleet.MDMEndUserAuthentication{SSOProviderSettings: fleet.SSOProviderSettings{EntityID: "foo"}}}, expectedMDM: fleet.MDM{ - EndUserAuthentication: fleet.MDMEndUserAuthentication{SSOProviderSettings: fleet.SSOProviderSettings{EntityID: "foo"}}, - MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)}, - MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, - IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, - IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, - WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, + AppleBusinessManager: optjson.Slice[fleet.MDMAppleABMAssignmentInfo]{Set: true, Value: []fleet.MDMAppleABMAssignmentInfo{}}, + EndUserAuthentication: fleet.MDMEndUserAuthentication{SSOProviderSettings: fleet.SSOProviderSettings{EntityID: "foo"}}, + MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)}, + MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + VolumePurchasingProgram: optjson.Slice[fleet.MDMAppleVolumePurchasingProgramInfo]{Set: true, Value: []fleet.MDMAppleVolumePurchasingProgramInfo{}}, + WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, WindowsSettings: fleet.WindowsSettings{ CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}}, }, @@ -952,17 +958,19 @@ func TestMDMAppleConfig(t *testing.T) { IDPName: "onelogin", }}}, expectedMDM: fleet.MDM{ + AppleBusinessManager: optjson.Slice[fleet.MDMAppleABMAssignmentInfo]{Set: true, Value: []fleet.MDMAppleABMAssignmentInfo{}}, EndUserAuthentication: fleet.MDMEndUserAuthentication{SSOProviderSettings: fleet.SSOProviderSettings{ EntityID: "fleet", IssuerURI: "http://issuer.idp.com", MetadataURL: "http://isser.metadata.com", IDPName: "onelogin", }}, - MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)}, - MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, - IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, - IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, - WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, + MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)}, + MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + VolumePurchasingProgram: optjson.Slice[fleet.MDMAppleVolumePurchasingProgramInfo]{Set: true, Value: []fleet.MDMAppleVolumePurchasingProgramInfo{}}, + WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, WindowsSettings: fleet.WindowsSettings{ CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}}, }, @@ -1017,12 +1025,14 @@ func TestMDMAppleConfig(t *testing.T) { EnableDiskEncryption: optjson.SetBool(false), }, expectedMDM: fleet.MDM{ - EnableDiskEncryption: optjson.Bool{Set: true, Valid: true, Value: false}, - MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)}, - MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, - IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, - IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, - WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, + AppleBusinessManager: optjson.Slice[fleet.MDMAppleABMAssignmentInfo]{Set: true, Value: []fleet.MDMAppleABMAssignmentInfo{}}, + EnableDiskEncryption: optjson.Bool{Set: true, Valid: true, Value: false}, + MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)}, + MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + VolumePurchasingProgram: optjson.Slice[fleet.MDMAppleVolumePurchasingProgramInfo]{Set: true, Value: []fleet.MDMAppleVolumePurchasingProgramInfo{}}, + WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, WindowsSettings: fleet.WindowsSettings{ CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}}, }, @@ -1065,6 +1075,12 @@ func TestMDMAppleConfig(t *testing.T) { ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { return job, nil } + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return []*fleet.ABMToken{{ID: 1}}, nil + } + ds.SaveABMTokenFunc = func(ctx context.Context, token *fleet.ABMToken) error { + return nil + } depStorage.RetrieveConfigFunc = func(p0 context.Context, p1 string) (*nanodep_client.Config, error) { return &nanodep_client.Config{BaseURL: depSrv.URL}, nil } diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 4a02bd608e9f..3072cd710cd6 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -380,7 +380,7 @@ func (svc *Service) NewMDMAppleConfigProfile(ctx context.Context, teamID uint, r } return nil, ctxerr.Wrap(ctx, err) } - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{newCP.ProfileUUID}, nil); err != nil { + if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{newCP.ProfileUUID}, nil); err != nil { return nil, ctxerr.Wrap(ctx, err, "bulk set pending host profiles") } @@ -470,7 +470,7 @@ func (svc *Service) NewMDMAppleDeclaration(ctx context.Context, teamID uint, r i return nil, err } - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl.DeclarationUUID}, nil); err != nil { + if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl.DeclarationUUID}, nil); err != nil { return nil, ctxerr.Wrap(ctx, err, "bulk set pending host declarations") } @@ -773,7 +773,7 @@ func (svc *Service) DeleteMDMAppleConfigProfile(ctx context.Context, profileUUID return ctxerr.Wrap(ctx, err) } // cannot use the profile ID as it is now deleted - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{teamID}, nil, nil); err != nil { + if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{teamID}, nil, nil); err != nil { return ctxerr.Wrap(ctx, err, "bulk set pending host profiles") } @@ -853,7 +853,7 @@ func (svc *Service) DeleteMDMAppleDeclaration(ctx context.Context, declUUID stri return ctxerr.Wrap(ctx, err) } - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{teamID}, nil, nil); err != nil { + if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{teamID}, nil, nil); err != nil { return ctxerr.Wrap(ctx, err, "bulk set pending host profiles") } @@ -1570,47 +1570,17 @@ func (svc *Service) EnqueueMDMAppleCommandRemoveEnrollmentProfile(ctx context.Co return ctxerr.Wrap(ctx, err, "logging activity for mdm apple remove profile command") } - return svc.pollResultMDMAppleCommandRemoveEnrollmentProfile(ctx, cmdUUID, h.UUID, info.Platform) -} - -func (svc *Service) pollResultMDMAppleCommandRemoveEnrollmentProfile(ctx context.Context, cmdUUID string, deviceID string, platform string) error { - ctx, cancelFn := context.WithDeadline(ctx, time.Now().Add(5*time.Second)) - ticker := time.NewTicker(300 * time.Millisecond) - defer func() { - ticker.Stop() - cancelFn() - }() - - for { - select { - case <-ctx.Done(): - // time out after 5 seconds - return fleet.MDMAppleCommandTimeoutError{} - case <-ticker.C: - nanoEnroll, err := svc.ds.GetNanoMDMEnrollment(ctx, deviceID) - if err != nil { - level.Error(svc.logger).Log("err", "get nanomdm enrollment status", "details", err, "id", deviceID, "command_uuid", cmdUUID) - return err - } - if nanoEnroll != nil && nanoEnroll.Enabled { - // check again on the next tick - continue - } - // success, mdm enrollment is no longer enabled for the device - level.Info(svc.logger).Log("msg", "mdm disabled for device", "id", deviceID, "command_uuid", cmdUUID) - - mdmLifecycle := mdmlifecycle.New(svc.ds, svc.logger) - err = mdmLifecycle.Do(ctx, mdmlifecycle.HostOptions{ - Action: mdmlifecycle.HostActionTurnOff, - Platform: platform, - UUID: deviceID, - }) - if err != nil { - return err - } - return nil - } + mdmLifecycle := mdmlifecycle.New(svc.ds, svc.logger) + err = mdmLifecycle.Do(ctx, mdmlifecycle.HostOptions{ + Action: mdmlifecycle.HostActionTurnOff, + Platform: info.Platform, + UUID: h.UUID, + }) + if err != nil { + return ctxerr.Wrap(ctx, err, "running turn off action in mdm lifecycle") } + + return nil } type mdmAppleGetInstallerRequest struct { @@ -1978,7 +1948,7 @@ func (svc *Service) BatchSetMDMAppleProfiles(ctx context.Context, tmID *uint, tm } if !skipBulkPending { - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{bulkTeamID}, nil, nil); err != nil { + if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{bulkTeamID}, nil, nil); err != nil { return ctxerr.Wrap(ctx, err, "bulk set pending host profiles") } } @@ -3149,7 +3119,7 @@ func SendPushesToPendingDevices( if err := commander.SendNotifications(ctx, uuids); err != nil { var apnsErr *apple_mdm.APNSDeliveryError if errors.As(err, &apnsErr) { - level.Info(logger).Log("msg", "failed to send APNs notification to some hosts", "host_uuids", apnsErr.FailedUUIDs) + level.Info(logger).Log("msg", "failed to send APNs notification to some hosts", "error", apnsErr.Error()) return nil } @@ -4177,3 +4147,45 @@ func (svc *Service) RenewABMToken(ctx context.Context, token io.Reader, tokenID return nil, fleet.ErrMissingLicense } + +//////////////////////////////////////////////////////////////////////////////// +// GET /enrollment_profiles/ota +//////////////////////////////////////////////////////////////////////////////// + +type getOTAProfileRequest struct { + EnrollSecret string `query:"enroll_secret"` +} + +func getOTAProfileEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*getOTAProfileRequest) + profile, err := svc.GetOTAProfile(ctx, req.EnrollSecret) + if err != nil { + return &getMDMAppleConfigProfileResponse{Err: err}, err + } + + reader := bytes.NewReader(profile) + return &getMDMAppleConfigProfileResponse{fileReader: io.NopCloser(reader), fileLength: reader.Size(), fileName: "fleet-mdm-enrollment-profile"}, nil +} + +func (svc *Service) GetOTAProfile(ctx context.Context, enrollSecret string) ([]byte, error) { + // Skip authz as this endpoint is used by end users from their iPhones or iPads; authz is done + // by the enroll secret verification below + svc.authz.SkipAuthorization(ctx) + + cfg, err := svc.ds.AppConfig(ctx) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting app config to get org name") + } + + profBytes, err := apple_mdm.GenerateOTAEnrollmentProfileMobileconfig(cfg.OrgInfo.OrgName, cfg.ServerSettings.ServerURL, enrollSecret) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "generating ota mobileconfig file") + } + + signed, err := mdmcrypto.Sign(ctx, profBytes, svc.ds) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "signing profile") + } + + return signed, nil +} diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go index 718bfd183cee..a8b20b04ec25 100644 --- a/server/service/apple_mdm_test.go +++ b/server/service/apple_mdm_test.go @@ -599,8 +599,9 @@ func TestMDMAppleConfigProfileAuthz(t *testing.T) { ds.GetMDMAppleProfilesSummaryFunc = func(context.Context, *uint) (*fleet.MDMProfilesSummary, error) { return nil, nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } mockGetFuncWithTeamID := func(teamID uint) mock.GetMDMAppleConfigProfileFunc { return func(ctx context.Context, puid string) (*fleet.MDMAppleConfigProfile, error) { @@ -706,8 +707,9 @@ func TestNewMDMAppleConfigProfile(t *testing.T) { ds.NewActivityFunc = func(context.Context, *fleet.User, fleet.ActivityDetails, []byte, time.Time) error { return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } cp, err := svc.NewMDMAppleConfigProfile(ctx, 0, r, nil, false) @@ -1499,8 +1501,9 @@ func TestMDMBatchSetAppleProfiles(t *testing.T) { ) error { return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.ListMDMConfigProfilesFunc = func(ctx context.Context, tid *uint, opt fleet.ListOptions) ([]*fleet.MDMConfigProfilePayload, *fleet.PaginationMetadata, error) { return nil, nil, nil @@ -1815,8 +1818,9 @@ func TestMDMBatchSetAppleProfilesBoolArgs(t *testing.T) { ) error { return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, profileUUIDs, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, profileUUIDs, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.ListMDMConfigProfilesFunc = func(ctx context.Context, tid *uint, opt fleet.ListOptions) ([]*fleet.MDMConfigProfilePayload, *fleet.PaginationMetadata, error) { return nil, nil, nil diff --git a/server/service/client.go b/server/service/client.go index b811277e38f8..61524f636fb5 100644 --- a/server/service/client.go +++ b/server/service/client.go @@ -21,6 +21,7 @@ import ( "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mdm" + "github.com/fleetdm/fleet/v4/server/ptr" kithttp "github.com/go-kit/kit/transport/http" ) @@ -396,7 +397,9 @@ func (c *Client) ApplyGroup( logf func(format string, args ...interface{}), appconfig *fleet.EnrichedAppConfig, opts fleet.ApplyClientSpecOptions, -) (map[string]uint, error) { +) (map[string]uint, map[string][]fleet.SoftwareInstaller, error) { + teamSoftwareInstallers := make(map[string][]fleet.SoftwareInstaller) + logfn := func(format string, args ...interface{}) { if logf != nil { logf(format, args...) @@ -409,7 +412,7 @@ func (c *Client) ApplyGroup( logfn("[!] ignoring queries, dry run mode only supported for 'config' and 'team' specs\n") } else { if err := c.ApplyQueries(specs.Queries); err != nil { - return nil, fmt.Errorf("applying queries: %w", err) + return nil, nil, fmt.Errorf("applying queries: %w", err) } logfn("[+] applied %d queries\n", len(specs.Queries)) } @@ -420,42 +423,18 @@ func (c *Client) ApplyGroup( logfn("[!] ignoring labels, dry run mode only supported for 'config' and 'team' specs\n") } else { if err := c.ApplyLabels(specs.Labels); err != nil { - return nil, fmt.Errorf("applying labels: %w", err) + return nil, nil, fmt.Errorf("applying labels: %w", err) } logfn("[+] applied %d labels\n", len(specs.Labels)) } } - if len(specs.Policies) > 0 { - if opts.DryRun { - logfn("[!] ignoring policies, dry run mode only supported for 'config' and 'team' specs\n") - } else { - // Policy names must be unique, return error if duplicate policy names are found - if policyName := fleet.FirstDuplicatePolicySpecName(specs.Policies); policyName != "" { - return nil, fmt.Errorf( - "applying policies: policy names must be unique. Please correct policy %q and try again.", policyName, - ) - } - - // If set, override the team in all the policies. - if opts.TeamForPolicies != "" { - for _, policySpec := range specs.Policies { - policySpec.Team = opts.TeamForPolicies - } - } - if err := c.ApplyPolicies(specs.Policies); err != nil { - return nil, fmt.Errorf("applying policies: %w", err) - } - logfn("[+] applied %d policies\n", len(specs.Policies)) - } - } - if len(specs.Packs) > 0 { if opts.DryRun { logfn("[!] ignoring packs, dry run mode only supported for 'config' and 'team' specs\n") } else { if err := c.ApplyPacks(specs.Packs); err != nil { - return nil, fmt.Errorf("applying packs: %w", err) + return nil, nil, fmt.Errorf("applying packs: %w", err) } logfn("[+] applied %d packs\n", len(specs.Packs)) } @@ -475,7 +454,7 @@ func (c *Client) ApplyGroup( if (windowsCustomSettings != nil && macosCustomSettings != nil) || len(windowsCustomSettings)+len(macosCustomSettings) > 0 { fileContents, err := getProfilesContents(baseDir, macosCustomSettings, windowsCustomSettings, opts.ExpandEnvConfigProfiles) if err != nil { - return nil, err + return nil, nil, err } // Figure out if MDM should be enabled. assumeEnabled := false @@ -489,30 +468,30 @@ func (c *Client) ApplyGroup( } } if err := c.ApplyNoTeamProfiles(fileContents, opts.ApplySpecOptions, assumeEnabled); err != nil { - return nil, fmt.Errorf("applying custom settings: %w", err) + return nil, nil, fmt.Errorf("applying custom settings: %w", err) } } if macosSetup := extractAppCfgMacOSSetup(specs.AppConfig); macosSetup != nil { if macosSetup.BootstrapPackage.Value != "" { pkg, err := c.ValidateBootstrapPackageFromURL(macosSetup.BootstrapPackage.Value) if err != nil { - return nil, fmt.Errorf("applying fleet config: %w", err) + return nil, nil, fmt.Errorf("applying fleet config: %w", err) } if !opts.DryRun { if err := c.EnsureBootstrapPackage(pkg, uint(0)); err != nil { - return nil, fmt.Errorf("applying fleet config: %w", err) + return nil, nil, fmt.Errorf("applying fleet config: %w", err) } } } if macosSetup.MacOSSetupAssistant.Value != "" { content, err := c.validateMacOSSetupAssistant(resolveApplyRelativePath(baseDir, macosSetup.MacOSSetupAssistant.Value)) if err != nil { - return nil, fmt.Errorf("applying fleet config: %w", err) + return nil, nil, fmt.Errorf("applying fleet config: %w", err) } if !opts.DryRun { if err := c.uploadMacOSSetupAssistant(content, nil, macosSetup.MacOSSetupAssistant.Value); err != nil { - return nil, fmt.Errorf("applying fleet config: %w", err) + return nil, nil, fmt.Errorf("applying fleet config: %w", err) } } } @@ -523,7 +502,7 @@ func (c *Client) ApplyGroup( for i, f := range files { b, err := os.ReadFile(f) if err != nil { - return nil, fmt.Errorf("applying fleet config: %w", err) + return nil, nil, fmt.Errorf("applying fleet config: %w", err) } scriptPayloads[i] = fleet.ScriptPayload{ ScriptContents: b, @@ -531,11 +510,11 @@ func (c *Client) ApplyGroup( } } if err := c.ApplyNoTeamScripts(scriptPayloads, opts.ApplySpecOptions); err != nil { - return nil, fmt.Errorf("applying custom settings: %w", err) + return nil, nil, fmt.Errorf("applying custom settings: %w", err) } } if err := c.ApplyAppConfig(specs.AppConfig, opts.ApplySpecOptions); err != nil { - return nil, fmt.Errorf("applying fleet config: %w", err) + return nil, nil, fmt.Errorf("applying fleet config: %w", err) } if opts.DryRun { logfn("[+] would've applied fleet config\n") @@ -546,7 +525,7 @@ func (c *Client) ApplyGroup( if specs.EnrollSecret != nil { if err := c.ApplyEnrollSecretSpec(specs.EnrollSecret, opts.ApplySpecOptions); err != nil { - return nil, fmt.Errorf("applying enroll secrets: %w", err) + return nil, nil, fmt.Errorf("applying enroll secrets: %w", err) } if opts.DryRun { logfn("[+] would've applied enroll secrets\n") @@ -565,7 +544,7 @@ func (c *Client) ApplyGroup( for k, profileSpecs := range tmMDMSettings { fileContents, err := getProfilesContents(baseDir, profileSpecs.macos, profileSpecs.windows, opts.ExpandEnvConfigProfiles) if err != nil { - return nil, fmt.Errorf("Team %s: %w", k, err) // TODO: consider adding team name to improve error messages generally for other parts of the config because multiple team configs can be processed at once + return nil, nil, fmt.Errorf("Team %s: %w", k, err) // TODO: consider adding team name to improve error messages generally for other parts of the config because multiple team configs can be processed at once } tmFileContents[k] = fileContents } @@ -577,14 +556,14 @@ func (c *Client) ApplyGroup( if setup.BootstrapPackage.Value != "" { bp, err := c.ValidateBootstrapPackageFromURL(setup.BootstrapPackage.Value) if err != nil { - return nil, fmt.Errorf("applying teams: %w", err) + return nil, nil, fmt.Errorf("applying teams: %w", err) } tmBootstrapPackages[k] = bp } if setup.MacOSSetupAssistant.Value != "" { b, err := c.validateMacOSSetupAssistant(resolveApplyRelativePath(baseDir, setup.MacOSSetupAssistant.Value)) if err != nil { - return nil, fmt.Errorf("applying teams: %w", err) + return nil, nil, fmt.Errorf("applying teams: %w", err) } tmMacSetupAssistants[k] = b } @@ -598,7 +577,7 @@ func (c *Client) ApplyGroup( for i, f := range files { b, err := os.ReadFile(f) if err != nil { - return nil, fmt.Errorf("applying fleet config: %w", err) + return nil, nil, fmt.Errorf("applying fleet config: %w", err) } scriptPayloads[i] = fleet.ScriptPayload{ ScriptContents: b, @@ -609,11 +588,11 @@ func (c *Client) ApplyGroup( } tmSoftwarePackages := extractTmSpecsSoftwarePackages(specs.Teams) - tmSoftwarePackagesPayloads := make(map[string][]fleet.SoftwareInstallerPayload, len(tmScripts)) + tmSoftwarePackagesPayloads := make(map[string][]fleet.SoftwareInstallerPayload, len(tmSoftwarePackages)) for tmName, software := range tmSoftwarePackages { softwarePayloads, err := buildSoftwarePackagesPayload(baseDir, software) if err != nil { - return nil, fmt.Errorf("applying software installers for team %q: %w", tmName, err) + return nil, nil, fmt.Errorf("applying software installers for team %q: %w", tmName, err) } tmSoftwarePackagesPayloads[tmName] = softwarePayloads } @@ -638,7 +617,7 @@ func (c *Client) ApplyGroup( // In dry-run, the team names returned are the old team names (when team name is modified via gitops) teamIDsByName, err = c.ApplyTeams(specs.Teams, teamOpts) if err != nil { - return nil, fmt.Errorf("applying teams: %w", err) + return nil, nil, fmt.Errorf("applying teams: %w", err) } // When using GitOps, the team name could change, so we need to check for that @@ -665,7 +644,7 @@ func (c *Client) ApplyGroup( } else { logfn("[+] applying MDM profiles for team %s\n", tmName) if err := c.ApplyTeamProfiles(currentTeamName, profs, teamOpts); err != nil { - return nil, fmt.Errorf("applying custom settings for team %q: %w", tmName, err) + return nil, nil, fmt.Errorf("applying custom settings for team %q: %w", tmName, err) } } } @@ -674,12 +653,12 @@ func (c *Client) ApplyGroup( for tmName, tmID := range teamIDsByName { if bp, ok := tmBootstrapPackages[tmName]; ok { if err := c.EnsureBootstrapPackage(bp, tmID); err != nil { - return nil, fmt.Errorf("uploading bootstrap package for team %q: %w", tmName, err) + return nil, nil, fmt.Errorf("uploading bootstrap package for team %q: %w", tmName, err) } } if b, ok := tmMacSetupAssistants[tmName]; ok { if err := c.uploadMacOSSetupAssistant(b, &tmID, tmMacSetup[tmName].MacOSSetupAssistant.Value); err != nil { - return nil, fmt.Errorf("uploading macOS setup assistant for team %q: %w", tmName, err) + return nil, nil, fmt.Errorf("uploading macOS setup assistant for team %q: %w", tmName, err) } } } @@ -689,7 +668,7 @@ func (c *Client) ApplyGroup( // For non-dry run, currentTeamName and tmName are the same currentTeamName := getTeamName(tmName) if err := c.ApplyTeamScripts(currentTeamName, scripts, opts.ApplySpecOptions); err != nil { - return nil, fmt.Errorf("applying scripts for team %q: %w", tmName, err) + return nil, nil, fmt.Errorf("applying scripts for team %q: %w", tmName, err) } } } @@ -697,9 +676,12 @@ func (c *Client) ApplyGroup( for tmName, software := range tmSoftwarePackagesPayloads { // For non-dry run, currentTeamName and tmName are the same currentTeamName := getTeamName(tmName) - if err := c.ApplyTeamSoftwareInstallers(currentTeamName, software, opts.ApplySpecOptions); err != nil { - return nil, fmt.Errorf("applying software installers for team %q: %w", tmName, err) + logfn("[+] applying software installers for team %s\n", tmName) + installers, err := c.ApplyTeamSoftwareInstallers(currentTeamName, software, opts.ApplySpecOptions) + if err != nil { + return nil, nil, fmt.Errorf("applying software installers for team %q: %w", tmName, err) } + teamSoftwareInstallers[tmName] = installers } } if len(tmSoftwareAppsPayloads) > 0 { @@ -707,7 +689,7 @@ func (c *Client) ApplyGroup( // For non-dry run, currentTeamName and tmName are the same currentTeamName := getTeamName(tmName) if err := c.ApplyTeamAppStoreAppsAssociation(currentTeamName, apps, opts.ApplySpecOptions); err != nil { - return nil, fmt.Errorf("applying app store apps for team: %q: %w", tmName, err) + return nil, nil, fmt.Errorf("applying app store apps for team: %q: %w", tmName, err) } } } @@ -718,17 +700,42 @@ func (c *Client) ApplyGroup( } } + // Policies can reference software installers thus they are applied at this point. + if len(specs.Policies) > 0 { + // Policy names must be unique, return error if duplicate policy names are found + if policyName := fleet.FirstDuplicatePolicySpecName(specs.Policies); policyName != "" { + return nil, nil, fmt.Errorf( + "applying policies: policy names must be unique. Please correct policy %q and try again.", policyName, + ) + } + if opts.DryRun { + logfn("[!] ignoring policies, dry run mode only supported for 'config' and 'team' specs\n") + } else { + // If set, override the team in all the policies. + if opts.TeamForPolicies != "" { + for _, policySpec := range specs.Policies { + policySpec.Team = opts.TeamForPolicies + } + } + if err := c.ApplyPolicies(specs.Policies); err != nil { + return nil, nil, fmt.Errorf("applying policies: %w", err) + } + logfn("[+] applied %d policies\n", len(specs.Policies)) + } + } + if specs.UsersRoles != nil { if opts.DryRun { logfn("[!] ignoring user roles, dry run mode only supported for 'config' and 'team' specs\n") } else { if err := c.ApplyUsersRoleSecretSpec(specs.UsersRoles); err != nil { - return nil, fmt.Errorf("applying user roles: %w", err) + return nil, nil, fmt.Errorf("applying user roles: %w", err) } logfn("[+] applied user roles\n") } } - return teamIDsByName, nil + + return teamIDsByName, teamSoftwareInstallers, nil } func buildSoftwarePackagesPayload(baseDir string, specs []fleet.SoftwarePackageSpec) ([]fleet.SoftwareInstallerPayload, error) { @@ -1285,9 +1292,13 @@ func (c *Client) DoGitOps( team["webhook_settings"] = map[string]interface{}{} clearHostStatusWebhook := true if webhookSettings, ok := config.TeamSettings["webhook_settings"]; ok { - if hostStatusWebhook, ok := webhookSettings.(map[string]interface{})["host_status_webhook"]; ok { - clearHostStatusWebhook = false - team["webhook_settings"].(map[string]interface{})["host_status_webhook"] = hostStatusWebhook + if _, ok := webhookSettings.(map[string]interface{}); ok { + if hostStatusWebhook, ok := webhookSettings.(map[string]interface{})["host_status_webhook"]; ok { + clearHostStatusWebhook = false + team["webhook_settings"].(map[string]interface{})["host_status_webhook"] = hostStatusWebhook + } + } else if webhookSettings != nil { + return nil, fmt.Errorf("team_settings.webhook_settings config is not a map but a %T", webhookSettings) } } if clearHostStatusWebhook { @@ -1424,8 +1435,8 @@ func (c *Client) DoGitOps( group.TeamsDryRunAssumptions = teamDryRunAssumptions } - // Apply org settings, scripts, enroll secrets, and controls - teamIDsByName, err := c.ApplyGroup(ctx, &group, baseDir, logf, appConfig, fleet.ApplyClientSpecOptions{ + // Apply org settings, scripts, enroll secrets, team entities (software, scripts, etc.), and controls. + teamIDsByName, teamsSoftwareInstallers, err := c.ApplyGroup(ctx, &group, baseDir, logf, appConfig, fleet.ApplyClientSpecOptions{ ApplySpecOptions: fleet.ApplySpecOptions{ DryRun: dryRun, }, @@ -1434,6 +1445,7 @@ func (c *Client) DoGitOps( if err != nil { return nil, err } + var teamSoftwareInstallers []fleet.SoftwareInstaller if config.TeamName != nil { if len(teamIDsByName) != 1 { return nil, fmt.Errorf("expected 1 team spec to be applied, got %d", len(teamIDsByName)) @@ -1449,19 +1461,19 @@ func (c *Client) DoGitOps( for _, teamID = range teamIDsByName { config.TeamID = &teamID } + teamSoftwareInstallers = teamsSoftwareInstallers[*config.TeamName] } - err = c.doGitOpsPolicies(config, logFn, dryRun) - if err != nil { + if _, err = c.doGitOpsNoTeamSoftware(group, baseDir, appConfig, logFn, dryRun); err != nil { return nil, err } - err = c.doGitOpsQueries(config, logFn, dryRun) + err = c.doGitOpsPolicies(config, teamSoftwareInstallers, logFn, dryRun) if err != nil { return nil, err } - err = c.doGitOpsNoTeamSoftware(group, baseDir, appConfig, logFn, dryRun) + err = c.doGitOpsQueries(config, logFn, dryRun) if err != nil { return nil, err } @@ -1469,7 +1481,8 @@ func (c *Client) DoGitOps( return teamAssumptions, nil } -func (c *Client) doGitOpsNoTeamSoftware(specs spec.Group, baseDir string, appconfig *fleet.EnrichedAppConfig, logFn func(format string, args ...interface{}), dryRun bool) error { +func (c *Client) doGitOpsNoTeamSoftware(specs spec.Group, baseDir string, appconfig *fleet.EnrichedAppConfig, logFn func(format string, args ...interface{}), dryRun bool) ([]fleet.SoftwareInstaller, error) { + var softwareInstallers []fleet.SoftwareInstaller if len(specs.Teams) == 0 && appconfig != nil && appconfig.License.IsPremium() { packages := make([]fleet.SoftwarePackageSpec, 0, len(specs.Software)) for _, software := range specs.Software { @@ -1479,10 +1492,11 @@ func (c *Client) doGitOpsNoTeamSoftware(specs spec.Group, baseDir string, appcon } payload, err := buildSoftwarePackagesPayload(baseDir, packages) if err != nil { - return fmt.Errorf("applying software installers: %w", err) + return nil, fmt.Errorf("applying software installers: %w", err) } - if err := c.ApplyNoTeamSoftwareInstallers(payload, fleet.ApplySpecOptions{DryRun: dryRun}); err != nil { - return fmt.Errorf("applying software installers: %w", err) + softwareInstallers, err = c.ApplyNoTeamSoftwareInstallers(payload, fleet.ApplySpecOptions{DryRun: dryRun}) + if err != nil { + return nil, fmt.Errorf("applying software installers: %w", err) } if dryRun { @@ -1491,15 +1505,50 @@ func (c *Client) doGitOpsNoTeamSoftware(specs spec.Group, baseDir string, appcon logFn("[+] applied 'No Team' software installers\n") } } - return nil + return softwareInstallers, nil } -func (c *Client) doGitOpsPolicies(config *spec.GitOps, logFn func(format string, args ...interface{}), dryRun bool) error { +func (c *Client) doGitOpsPolicies(config *spec.GitOps, teamSoftwareInstallers []fleet.SoftwareInstaller, logFn func(format string, args ...interface{}), dryRun bool) error { + // Get software titles of packages for the team. + if config.TeamID != nil { + softwareTitleURLs := make(map[string]uint) + for _, softwareInstaller := range teamSoftwareInstallers { + if softwareInstaller.URL == "" { + // Should not happen because we previously applied packages via gitops, but to not panic we just log a warning. + logFn("[!] software installer without url: %s\n", softwareInstaller.Name) + continue + } + if softwareInstaller.TitleID == nil { + // Should not happen, but to not panic we just log a warning. + logFn("[!] software installer without title id: %s\n", softwareInstaller.Name) + continue + } + softwareTitleURLs[softwareInstaller.URL] = *softwareInstaller.TitleID + } + for i := range config.Policies { + config.Policies[i].SoftwareTitleID = ptr.Uint(0) // 0 unsets the installer + + if config.Policies[i].InstallSoftware == nil { + continue + } + softwareTitleID, ok := softwareTitleURLs[config.Policies[i].InstallSoftwareURL] + if !ok { + // Should not happen because software packages are uploaded first. + if !dryRun { + logFn("[!] software URL without software title id: %s\n", config.Policies[i].InstallSoftwareURL) + } + continue + } + config.Policies[i].SoftwareTitleID = &softwareTitleID + } + } + // Get the ids and names of current policies to figure out which ones to delete policies, err := c.GetPolicies(config.TeamID) if err != nil { return fmt.Errorf("error getting current policies: %w", err) } + if len(config.Policies) > 0 { numPolicies := len(config.Policies) logFn("[+] syncing %d policies\n", numPolicies) @@ -1512,7 +1561,12 @@ func (c *Client) doGitOpsPolicies(config *spec.GitOps, logFn func(format string, } totalApplied += end - i // Note: We are reusing the spec flow here for adding/updating policies, instead of creating a new flow for GitOps. - if err := c.ApplyPolicies(config.Policies[i:end]); err != nil { + policiesToApply := config.Policies[i:end] + policiesSpec := make([]*fleet.PolicySpec, len(policiesToApply)) + for i := range policiesToApply { + policiesSpec[i] = &policiesToApply[i].PolicySpec + } + if err := c.ApplyPolicies(policiesSpec); err != nil { return fmt.Errorf("error applying policies: %w", err) } logFn("[+] synced %d policies\n", totalApplied) diff --git a/server/service/client_software.go b/server/service/client_software.go index d08faee40409..acf402732e9a 100644 --- a/server/service/client_software.go +++ b/server/service/client_software.go @@ -28,11 +28,15 @@ func (c *Client) ListSoftwareTitles(query string) ([]fleet.SoftwareTitleListResu return responseBody.SoftwareTitles, nil } -func (c *Client) ApplyNoTeamSoftwareInstallers(softwareInstallers []fleet.SoftwareInstallerPayload, opts fleet.ApplySpecOptions) error { +func (c *Client) ApplyNoTeamSoftwareInstallers(softwareInstallers []fleet.SoftwareInstallerPayload, opts fleet.ApplySpecOptions) ([]fleet.SoftwareInstaller, error) { verb, path := "POST", "/api/latest/fleet/software/batch" query, err := url.ParseQuery(opts.RawQuery()) if err != nil { - return err + return nil, err + } + var resp batchSetSoftwareInstallersResponse + if err := c.authenticatedRequestWithQuery(map[string]interface{}{"software": softwareInstallers}, verb, path, &resp, query.Encode()); err != nil { + return nil, err } - return c.authenticatedRequestWithQuery(map[string]interface{}{"software": softwareInstallers}, verb, path, nil, query.Encode()) + return resp.Installers, nil } diff --git a/server/service/client_teams.go b/server/service/client_teams.go index e2edc217a02b..da9332067a53 100644 --- a/server/service/client_teams.go +++ b/server/service/client_teams.go @@ -93,14 +93,18 @@ func (c *Client) ApplyTeamScripts(tmName string, scripts []fleet.ScriptPayload, return c.authenticatedRequestWithQuery(map[string]interface{}{"scripts": scripts}, verb, path, nil, query.Encode()) } -func (c *Client) ApplyTeamSoftwareInstallers(tmName string, softwareInstallers []fleet.SoftwareInstallerPayload, opts fleet.ApplySpecOptions) error { +func (c *Client) ApplyTeamSoftwareInstallers(tmName string, softwareInstallers []fleet.SoftwareInstallerPayload, opts fleet.ApplySpecOptions) ([]fleet.SoftwareInstaller, error) { verb, path := "POST", "/api/latest/fleet/software/batch" query, err := url.ParseQuery(opts.RawQuery()) if err != nil { - return err + return nil, err } query.Add("team_name", tmName) - return c.authenticatedRequestWithQuery(map[string]interface{}{"software": softwareInstallers}, verb, path, nil, query.Encode()) + var resp batchSetSoftwareInstallersResponse + if err := c.authenticatedRequestWithQuery(map[string]interface{}{"software": softwareInstallers}, verb, path, &resp, query.Encode()); err != nil { + return nil, err + } + return resp.Installers, nil } func (c *Client) ApplyTeamAppStoreAppsAssociation(tmName string, vppBatchPayload []fleet.VPPBatchPayload, opts fleet.ApplySpecOptions) error { diff --git a/server/service/endpoint_utils.go b/server/service/endpoint_utils.go index acb3d23c7163..055eb2cba1a8 100644 --- a/server/service/endpoint_utils.go +++ b/server/service/endpoint_utils.go @@ -448,9 +448,12 @@ var pathReplacer = strings.NewReplacer( "}", "_", ) -func getNameFromPathAndVerb(verb, path string) string { - return strings.ToLower(verb) + "_" + - pathReplacer.Replace(strings.TrimPrefix(strings.TrimRight(path, "/"), "/api/_version_/fleet/")) +func getNameFromPathAndVerb(verb, path, startAt string) string { + prefix := strings.ToLower(verb) + "_" + if startAt != "" { + prefix += pathReplacer.Replace(startAt) + "_" + } + return prefix + pathReplacer.Replace(strings.TrimPrefix(strings.TrimRight(path, "/"), "/api/_version_/fleet/")) } func capabilitiesResponseFunc(capabilities fleet.CapabilityMap) kithttp.ServerOption { @@ -560,14 +563,14 @@ func (e *authEndpointer) handlePathHandler(path string, pathHandler func(path st } versionedPath := strings.Replace(path, "/_version_/", fmt.Sprintf("/{fleetversion:(?:%s)}/", strings.Join(versions, "|")), 1) - nameAndVerb := getNameFromPathAndVerb(verb, path) + nameAndVerb := getNameFromPathAndVerb(verb, path, e.startingAtVersion) if e.usePathPrefix { e.r.PathPrefix(versionedPath).Handler(pathHandler(versionedPath)).Name(nameAndVerb).Methods(verb) } else { e.r.Handle(versionedPath, pathHandler(versionedPath)).Name(nameAndVerb).Methods(verb) } for _, alias := range e.alternativePaths { - nameAndVerb := getNameFromPathAndVerb(verb, alias) + nameAndVerb := getNameFromPathAndVerb(verb, alias, e.startingAtVersion) versionedPath := strings.Replace(alias, "/_version_/", fmt.Sprintf("/{fleetversion:(?:%s)}/", strings.Join(versions, "|")), 1) if e.usePathPrefix { e.r.PathPrefix(versionedPath).Handler(pathHandler(versionedPath)).Name(nameAndVerb).Methods(verb) diff --git a/server/service/frontend.go b/server/service/frontend.go index f5d884ec1fe0..a2a6058b5459 100644 --- a/server/service/frontend.go +++ b/server/service/frontend.go @@ -1,9 +1,11 @@ package service import ( + "fmt" "html/template" "io" "net/http" + "net/url" assetfs "github.com/elazarl/go-bindata-assetfs" "github.com/fleetdm/fleet/v4/server/bindata" @@ -68,6 +70,69 @@ func ServeFrontend(urlPrefix string, sandbox bool, logger log.Logger) http.Handl }) } +func ServeEndUserEnrollOTA(urlPrefix string, logger log.Logger) http.Handler { + herr := func(w http.ResponseWriter, err string) { + logger.Log("err", err) + http.Error(w, err, http.StatusInternalServerError) + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeBrowserSecurityHeaders(w) + + fs := newBinaryFileSystem("/frontend") + file, err := fs.Open("templates/enroll-ota.html") + if err != nil { + herr(w, "load enroll ota template: "+err.Error()) + return + } + + data, err := io.ReadAll(file) + if err != nil { + herr(w, "read bindata file: "+err.Error()) + return + } + + t, err := template.New("enroll-ota").Parse(string(data)) + if err != nil { + herr(w, "create react template: "+err.Error()) + return + } + + enrollURL, err := generateEnrollOTAURL(urlPrefix, r.URL.Query().Get("enroll_secret")) + if err != nil { + herr(w, "generate enroll ota url: "+err.Error()) + return + } + if err := t.Execute(w, struct { + EnrollURL string + URLPrefix string + }{ + URLPrefix: urlPrefix, + EnrollURL: enrollURL, + }); err != nil { + herr(w, "execute react template: "+err.Error()) + return + } + }) +} + +func generateEnrollOTAURL(fleetURL string, enrollSecret string) (string, error) { + path, err := url.JoinPath(fleetURL, "/api/v1/fleet/enrollment_profiles/ota") + if err != nil { + return "", fmt.Errorf("creating path for end user ota enrollment url: %w", err) + } + + enrollURL, err := url.Parse(path) + if err != nil { + return "", fmt.Errorf("parsing end user ota enrollment url: %w", err) + } + + q := enrollURL.Query() + q.Set("enroll_secret", enrollSecret) + enrollURL.RawQuery = q.Encode() + return enrollURL.String(), nil +} + func ServeStaticAssets(path string) http.Handler { return http.StripPrefix(path, http.FileServer(newBinaryFileSystem("/assets"))) } diff --git a/server/service/frontend_test.go b/server/service/frontend_test.go index 32363d6dd833..2710b69e9db0 100644 --- a/server/service/frontend_test.go +++ b/server/service/frontend_test.go @@ -2,6 +2,7 @@ package service import ( "bytes" + "io" "net/http" "net/http/httptest" "os" @@ -40,3 +41,29 @@ func TestServeFrontend(t *testing.T) { require.NoError(t, err) require.Equal(t, http.StatusMethodNotAllowed, response.StatusCode) } + +func TestServeEndUserEnrollOTA(t *testing.T) { + if !hasBuildTag("full") { + t.Skip("This test requires running with -tags full") + } + logger := log.NewLogfmtLogger(os.Stdout) + h := ServeEndUserEnrollOTA("", logger) + ts := httptest.NewServer(h) + t.Cleanup(func() { + ts.Close() + }) + + // assert html is returned + response, err := http.DefaultClient.Get(ts.URL + "?enroll_secret=foo") + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.StatusCode) + require.Equal(t, response.Header.Get("Content-Type"), "text/html; charset=utf-8") + + // assert it contains the content we expect + defer response.Body.Close() + bodyBytes, err := io.ReadAll(response.Body) + require.NoError(t, err) + bodyString := string(bodyBytes) + require.Contains(t, bodyString, "Enroll your device to Fleet") + require.Contains(t, bodyString, "?enroll_secret=foo") +} diff --git a/server/service/global_policies.go b/server/service/global_policies.go index c7d03e969547..ed0ef22013f4 100644 --- a/server/service/global_policies.go +++ b/server/service/global_policies.go @@ -6,12 +6,12 @@ import ( "encoding/json" "errors" "fmt" - "github.com/fleetdm/fleet/v4/pkg/fleethttp" "io" "net/http" "strings" "time" + "github.com/fleetdm/fleet/v4/pkg/fleethttp" "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/contexts/license" @@ -155,6 +155,9 @@ func (svc Service) GetPolicyByIDQueries(ctx context.Context, policyID uint) (*fl if err != nil { return nil, err } + if err := svc.populatePolicyInstallSoftware(ctx, policy); err != nil { + return nil, ctxerr.Wrap(ctx, err, "populate install_software") + } return policy, nil } @@ -583,8 +586,10 @@ func autofillPoliciesEndpoint(ctx context.Context, request interface{}, svc flee } // Exposing external URL and timeout for testing purposes -var getHumanInterpretationFromOsquerySqlUrl = "https://fleetdm.com/api/v1/get-human-interpretation-from-osquery-sql" -var getHumanInterpretationFromOsquerySqlTimeout = 30 * time.Second +var ( + getHumanInterpretationFromOsquerySqlUrl = "https://fleetdm.com/api/v1/get-human-interpretation-from-osquery-sql" + getHumanInterpretationFromOsquerySqlTimeout = 30 * time.Second +) type AutofillError struct { Message string diff --git a/server/service/handler.go b/server/service/handler.go index c1cde37255e5..23b30a346195 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -887,6 +887,9 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC // Deprecated: GET /mdm/apple/setup/eula/:token is now deprecated, replaced by the platform agnostic /mdm/setup/eula/:token neAppleMDM.GET("/api/_version_/fleet/mdm/apple/setup/eula/{token}", getMDMEULAEndpoint, getMDMEULARequest{}) + // Get OTA profile + neAppleMDM.GET("/api/_version_/fleet/enrollment_profiles/ota", getOTAProfileEndpoint, getOTAProfileRequest{}) + // These endpoint are used by Microsoft devices during MDM device enrollment phase neWindowsMDM := ne.WithCustomMiddleware(mdmConfiguredMiddleware.VerifyWindowsMDM()) diff --git a/server/service/handler_test.go b/server/service/handler_test.go index df7ff9e04d4b..116b155ac99a 100644 --- a/server/service/handler_test.go +++ b/server/service/handler_test.go @@ -76,7 +76,6 @@ func TestAPIRoutesConflicts(t *testing.T) { } func TestAPIRoutesMetrics(t *testing.T) { - t.Skip() ds := new(mock.Store) svc, _ := newTestService(t, ds, nil, nil) @@ -108,7 +107,8 @@ func TestAPIRoutesMetrics(t *testing.T) { routeNames := make(map[string]bool) err = router.Walk(func(route *mux.Route, _ *mux.Router, _ []*mux.Route) error { if _, ok := routeNames[route.GetName()]; ok { - t.Errorf("duplicate route name: %s", route.GetName()) + path, _ := route.GetPathTemplate() + t.Errorf("duplicate route name: %s (%s)", route.GetName(), path) } routeNames[route.GetName()] = true return nil @@ -194,7 +194,7 @@ func TestAPIRoutesMetrics(t *testing.T) { "go_memstats_alloc_bytes_total": 1, "go_memstats_buck_hash_sys_bytes": 1, "go_memstats_frees_total": 1, - "go_memstats_gc_cpu_fraction": 1, + "go_memstats_gc_cpu_fraction": 0, // does not appear to be reported anymore "go_memstats_gc_sys_bytes": 1, "go_memstats_heap_alloc_bytes": 1, "go_memstats_heap_idle_bytes": 1, diff --git a/server/service/hosts.go b/server/service/hosts.go index 359799b050dd..a4db5b73dc0a 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -826,7 +826,7 @@ func (svc *Service) AddHostsToTeam(ctx context.Context, teamID *uint, hostIDs [] return err } if !skipBulkPending { - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, hostIDs, nil, nil, nil); err != nil { + if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, hostIDs, nil, nil, nil); err != nil { return ctxerr.Wrap(ctx, err, "bulk set pending host profiles") } } @@ -962,7 +962,7 @@ func (svc *Service) AddHostsToTeamByFilter(ctx context.Context, teamID *uint, fi if err := svc.ds.AddHostsToTeam(ctx, teamID, hostIDs); err != nil { return err } - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, hostIDs, nil, nil, nil); err != nil { + if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, hostIDs, nil, nil, nil); err != nil { return ctxerr.Wrap(ctx, err, "bulk set pending host profiles") } serials, err := svc.ds.ListMDMAppleDEPSerialsInHostIDs(ctx, hostIDs) diff --git a/server/service/hosts_test.go b/server/service/hosts_test.go index 1e9e663ed6c5..d44f9fcf4d31 100644 --- a/server/service/hosts_test.go +++ b/server/service/hosts_test.go @@ -610,8 +610,9 @@ func TestHostAuth(t *testing.T) { } return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.ListMDMAppleDEPSerialsInHostIDsFunc = func(ctx context.Context, hids []uint) ([]string, error) { return nil, nil @@ -889,8 +890,9 @@ func TestAddHostsToTeamByFilter(t *testing.T) { assert.Equal(t, expectedHostIDs, hostIDs) return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.ListMDMAppleDEPSerialsInHostIDsFunc = func(ctx context.Context, hids []uint) ([]string, error) { return nil, nil @@ -931,8 +933,9 @@ func TestAddHostsToTeamByFilterLabel(t *testing.T) { assert.Equal(t, expectedHostIDs, hostIDs) return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.ListMDMAppleDEPSerialsInHostIDsFunc = func(ctx context.Context, hids []uint) ([]string, error) { return nil, nil @@ -963,8 +966,9 @@ func TestAddHostsToTeamByFilterEmptyHosts(t *testing.T) { ds.AddHostsToTeamFunc = func(ctx context.Context, teamID *uint, hostIDs []uint) error { return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } emptyFilter := &map[string]interface{}{} diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index ef40dd7fb6ad..8b1f84820506 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -11595,6 +11595,9 @@ func (s *integrationTestSuite) TestListHostUpcomingActivities() { t := s.T() ctx := context.Background() + adminUser, err := s.ds.UserByEmail(ctx, "admin1@example.com") + require.NoError(t, err) + // there is already a datastore-layer test that verifies that correct values // are returned for users, saved scripts, etc. so this is more focused on // verifying that the service layer passes the proper options and the @@ -11639,6 +11642,7 @@ func (s *integrationTestSuite) TestListHostUpcomingActivities() { Title: "foo", Source: "apps", Version: "0.0.1", + UserID: adminUser.ID, }) require.NoError(t, err) s1Meta, err := s.ds.GetSoftwareInstallerMetadataByID(ctx, sw1) diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index cc1a7816407b..96bd651883f1 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -1120,6 +1120,20 @@ func (s *integrationEnterpriseTestSuite) TestTeamEndpoints() { tmResp.Team = nil s.DoJSON("POST", "/api/latest/fleet/teams", team2, http.StatusConflict, &tmResp) + // create a team with reserved team names; should be case-insensitive + teamReserved := &fleet.Team{ + Name: "no TeAm", + Description: "description", + Secrets: []*fleet.EnrollSecret{{Secret: "foobar"}}, + } + + r := s.Do("POST", "/api/latest/fleet/teams", teamReserved, http.StatusUnprocessableEntity) + require.Contains(t, extractServerErrorText(r.Body), `"No team" is a reserved team name`) + + teamReserved.Name = "AlL TeaMS" + r = s.Do("POST", "/api/latest/fleet/teams", teamReserved, http.StatusUnprocessableEntity) + require.Contains(t, extractServerErrorText(r.Body), `"All teams" is a reserved team name`) + // create a team with too many secrets team3 := &fleet.Team{ Name: name + "lots_of_secrets", @@ -1219,6 +1233,13 @@ func (s *integrationEnterpriseTestSuite) TestTeamEndpoints() { modifyExpiry.HostExpirySettings.HostExpiryWindow = 0 s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tm1ID), modifyExpiry, http.StatusUnprocessableEntity, &tmResp) + // try to rename to reserved names + r = s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tm1ID), fleet.TeamPayload{Name: ptr.String("no TEAM")}, http.StatusUnprocessableEntity) + require.Contains(t, extractServerErrorText(r.Body), `"No team" is a reserved team name`) + + r = s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tm1ID), fleet.TeamPayload{Name: ptr.String("ALL teAMs")}, http.StatusUnprocessableEntity) + require.Contains(t, extractServerErrorText(r.Body), `"All teams" is a reserved team name`) + // Modify team's calendar config modifyCalendar := fleet.TeamPayload{ Integrations: &fleet.TeamIntegrations{ @@ -10009,8 +10030,10 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() { // Add new software to host -- installed on host, but not by Fleet installedVersion := "1.0.1" - softwareAlreadyInstalled := fleet.Software{Name: "DummyApp.app", Version: installedVersion, Source: "apps", - BundleIdentifier: "com.example.dummy"} + softwareAlreadyInstalled := fleet.Software{ + Name: "DummyApp.app", Version: installedVersion, Source: "apps", + BundleIdentifier: "com.example.dummy", + } software = append(software, softwareAlreadyInstalled) _, err = s.ds.UpdateHostSoftware(ctx, host.ID, software) require.NoError(t, err) @@ -10034,7 +10057,6 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() { assert.Equal(t, installedVersion, getHostSw.Software[0].InstalledVersions[0].Version) assert.NotNil(t, getHostSw.Software[0].SoftwarePackage) assert.Nil(t, getHostSw.Software[0].Status) - } func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndDelete() { @@ -10573,6 +10595,12 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() { } s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusUnprocessableEntity, "team_name", tm.Name) + // software with a too big URL + softwareToInstall = []fleet.SoftwareInstallerPayload{ + {URL: "https://ftp.mozilla.org/" + strings.Repeat("a", 233)}, + } + s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusUnprocessableEntity, "team_name", tm.Name) + // create an HTTP server to host the software installer handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { file, err := os.Open(filepath.Join("testdata", "software-installers", "ruby.deb")) @@ -10590,7 +10618,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() { softwareToInstall = []fleet.SoftwareInstallerPayload{ {URL: srv.URL}, } - s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusNoContent, "team_name", tm.Name) + s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusOK, "team_name", tm.Name) // TODO(roberto): test with a variety of response codes @@ -10599,6 +10627,9 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() { s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &titlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(tm.ID))) require.Equal(t, 1, titlesResp.Count) require.Len(t, titlesResp.SoftwareTitles, 1) + // Check that the URL is set to software installers uploaded via batch. + require.NotNil(t, titlesResp.SoftwareTitles[0].SoftwarePackage.PackageURL) + require.Equal(t, srv.URL, *titlesResp.SoftwareTitles[0].SoftwarePackage.PackageURL) // check that platform is set when the installer is created mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { @@ -10611,14 +10642,14 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() { }) // same payload doesn't modify anything - s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusNoContent, "team_name", tm.Name) + s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusOK, "team_name", tm.Name) newTitlesResp := listSoftwareTitlesResponse{} s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &newTitlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(tm.ID))) require.Equal(t, titlesResp, newTitlesResp) // setting self-service to true updates the software title metadata softwareToInstall[0].SelfService = true - s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusNoContent, "team_name", tm.Name) + s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusOK, "team_name", tm.Name) newTitlesResp = listSoftwareTitlesResponse{} s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &newTitlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(tm.ID))) titlesResp.SoftwareTitles[0].SoftwarePackage.SelfService = ptr.Bool(true) @@ -10626,7 +10657,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() { // empty payload cleans the software items softwareToInstall = []fleet.SoftwareInstallerPayload{} - s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusNoContent, "team_name", tm.Name) + s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusOK, "team_name", tm.Name) titlesResp = listSoftwareTitlesResponse{} s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &titlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(tm.ID))) require.Equal(t, 0, titlesResp.Count) @@ -10638,7 +10669,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() { softwareToInstall = []fleet.SoftwareInstallerPayload{ {URL: srv.URL}, } - s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusNoContent) + s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusOK) // check the application status on team 0 titlesResp = listSoftwareTitlesResponse{} @@ -10647,14 +10678,14 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() { require.Len(t, titlesResp.SoftwareTitles, 1) // same payload doesn't modify anything - s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusNoContent) + s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusOK) newTitlesResp = listSoftwareTitlesResponse{} s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &newTitlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(0))) require.Equal(t, titlesResp, newTitlesResp) // setting self-service to true updates the software title metadata softwareToInstall[0].SelfService = true - s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusNoContent) + s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusOK) newTitlesResp = listSoftwareTitlesResponse{} s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &newTitlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(0))) titlesResp.SoftwareTitles[0].SoftwarePackage.SelfService = ptr.Bool(true) @@ -10662,13 +10693,130 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() { // empty payload cleans the software items softwareToInstall = []fleet.SoftwareInstallerPayload{} - s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusNoContent) + s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusOK) titlesResp = listSoftwareTitlesResponse{} s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &titlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(0))) require.Equal(t, 0, titlesResp.Count) require.Len(t, titlesResp.SoftwareTitles, 0) } +func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallersWithPoliciesAssociated() { + ctx := context.Background() + t := s.T() + + team1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + team2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team2"}) + require.NoError(t, err) + policy1Team1, err := s.ds.NewTeamPolicy( + ctx, team1.ID, nil, fleet.PolicyPayload{ + Name: "team1Policy1", + Query: "SELECT 1;", + }, + ) + require.NoError(t, err) + policy2Team2, err := s.ds.NewTeamPolicy( + ctx, team2.ID, nil, fleet.PolicyPayload{ + Name: "team2Policy2", + Query: "SELECT 2;", + }, + ) + require.NoError(t, err) + + // create an HTTP server to host software installers + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var fileName string + switch r.URL.Path { + case "/ruby.deb", "/dummy_installer.pkg": + fileName = strings.TrimPrefix(r.URL.Path, "/") + default: + w.WriteHeader(http.StatusNotFound) + return + } + file, err := os.Open(filepath.Join("testdata", "software-installers", fileName)) + require.NoError(t, err) + defer file.Close() + w.Header().Set("Content-Type", "application/vnd.debian.binary-package") + _, err = io.Copy(w, file) + require.NoError(t, err) + }) + + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + + // team1 has ruby.deb + softwareToInstall := []fleet.SoftwareInstallerPayload{ + { + URL: srv.URL + "/ruby.deb", + }, + } + s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusOK, "team_name", team1.Name) + // team2 has dummy_installer.pkg and ruby.deb. + softwareToInstall = []fleet.SoftwareInstallerPayload{ + { + URL: srv.URL + "/dummy_installer.pkg", + }, + { + URL: srv.URL + "/ruby.deb", + }, + } + s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusOK, "team_name", team2.Name) + + // Associate ruby.deb to policy1Team1. + resp := listSoftwareTitlesResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{}, + http.StatusOK, &resp, + "query", "ruby", + "team_id", fmt.Sprintf("%d", team1.ID), + ) + require.Len(t, resp.SoftwareTitles, 1) + require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage) + rubyDebTitleID := resp.SoftwareTitles[0].ID + mtplr := modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: &rubyDebTitleID, + }, + }, http.StatusOK, &mtplr) + + // Associate ruby.deb in team2 to policy2Team2. + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team2.ID, policy2Team2.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: &rubyDebTitleID, + }, + }, http.StatusOK, &mtplr) + + // Get rid of all installers in team1. + softwareToInstall = []fleet.SoftwareInstallerPayload{} + s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusOK, "team_name", team1.Name) + + // policy1Team1 should not be associated to any installer. + policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID) + require.NoError(t, err) + require.Nil(t, policy1Team1.SoftwareInstallerID) + // team1 should be empty. + titlesResp := listSoftwareTitlesResponse{} + s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &titlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(team1.ID))) + require.Equal(t, 0, titlesResp.Count) + + // team2 should be untouched. + titlesResp = listSoftwareTitlesResponse{} + s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &titlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(team2.ID))) + require.Equal(t, 2, titlesResp.Count) + require.Len(t, titlesResp.SoftwareTitles, 2) + require.NotNil(t, titlesResp.SoftwareTitles[0].SoftwarePackage.PackageURL) + require.Equal(t, srv.URL+"/dummy_installer.pkg", *titlesResp.SoftwareTitles[0].SoftwarePackage.PackageURL) + require.NotNil(t, titlesResp.SoftwareTitles[1].SoftwarePackage.PackageURL) + require.Equal(t, srv.URL+"/ruby.deb", *titlesResp.SoftwareTitles[1].SoftwarePackage.PackageURL) + + // policy2Team2 should still be associated to ruby.deb of team2. + policy2Team2, err = s.ds.Policy(ctx, policy2Team2.ID) + require.NoError(t, err) + require.NotNil(t, policy2Team2.SoftwareInstallerID) +} + func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerNewInstallRequestPlatformValidation() { t := s.T() @@ -10973,7 +11121,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerHostRequests() { Results: map[string]json.RawMessage{ hostDetailQueryPrefix + "software_linux": json.RawMessage(fmt.Sprintf( `[{"name": "%s", "version": "1.0", "type": "Package (deb)", - "source": "deb_packages", "last_opened_at": "", + "source": "deb_packages", "last_opened_at": "", "installed_path": "/bin/ruby"}]`, payload.Title)), }, Statuses: map[string]interface{}{ @@ -11116,7 +11264,7 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() { host := createOrbitEnrolledHost(t, "linux", "", s.ds) - // create a software installer and some host install requests + // Create software installers and corresponding host install requests. payload := &fleet.UploadSoftwareInstallerPayload{ InstallScript: "install script", PreInstallQuery: "pre install query", @@ -11126,6 +11274,24 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() { } s.uploadSoftwareInstaller(payload, http.StatusOK, "") titleID := getSoftwareTitleID(t, s.ds, payload.Title, "deb_packages") + payload2 := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "install script 2", + PreInstallQuery: "pre install query 2", + PostInstallScript: "post install script 2", + Filename: "vim.deb", + Title: "vim", + } + s.uploadSoftwareInstaller(payload2, http.StatusOK, "") + titleID2 := getSoftwareTitleID(t, s.ds, payload2.Title, "deb_packages") + payload3 := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "install script 3", + PreInstallQuery: "pre install query 3", + PostInstallScript: "post install script 3", + Filename: "emacs.deb", + Title: "emacs", + } + s.uploadSoftwareInstaller(payload3, http.StatusOK, "") + titleID3 := getSoftwareTitleID(t, s.ds, payload3.Title, "deb_packages") latestInstallUUID := func() string { var id string @@ -11137,9 +11303,10 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() { // create some install requests for the host installUUIDs := make([]string, 3) + titleIDs := []uint{titleID, titleID2, titleID3} for i := 0; i < len(installUUIDs); i++ { resp := installSoftwareResponse{} - s.DoJSON("POST", fmt.Sprintf("/api/v1/fleet/hosts/%d/software/install/%d", host.ID, titleID), nil, http.StatusAccepted, &resp) + s.DoJSON("POST", fmt.Sprintf("/api/v1/fleet/hosts/%d/software/install/%d", host.ID, titleIDs[i]), nil, http.StatusAccepted, &resp) installUUIDs[i] = latestInstallUUID() } @@ -11202,7 +11369,14 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() { Status: fleet.SoftwareInstallerFailed, PreInstallQueryOutput: ptr.String(fleet.SoftwareInstallerQueryFailCopy), }) - wantAct.InstallUUID = installUUIDs[1] + wantAct = fleet.ActivityTypeInstalledSoftware{ + HostID: host.ID, + HostDisplayName: host.DisplayName(), + SoftwareTitle: payload2.Title, + SoftwarePackage: payload2.Filename, + InstallUUID: installUUIDs[1], + Status: string(fleet.SoftwareInstallerFailed), + } s.lastActivityOfTypeMatches(wantAct.ActivityName(), string(jsonMustMarshal(t, wantAct)), 0) s.Do("POST", "/api/fleet/orbit/software_install/result", @@ -11224,8 +11398,14 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() { Output: ptr.String(fmt.Sprintf(fleet.SoftwareInstallerInstallSuccessCopy, "success")), PostInstallScriptOutput: ptr.String(fmt.Sprintf(fleet.SoftwareInstallerPostInstallSuccessCopy, "ok")), }) - wantAct.InstallUUID = installUUIDs[2] - wantAct.Status = string(fleet.SoftwareInstallerInstalled) + wantAct = fleet.ActivityTypeInstalledSoftware{ + HostID: host.ID, + HostDisplayName: host.DisplayName(), + SoftwareTitle: payload3.Title, + SoftwarePackage: payload3.Filename, + InstallUUID: installUUIDs[2], + Status: string(fleet.SoftwareInstallerInstalled), + } lastActID := s.lastActivityOfTypeMatches(wantAct.ActivityName(), string(jsonMustMarshal(t, wantAct)), 0) // non-existing installation uuid @@ -11343,7 +11523,11 @@ func (s *integrationEnterpriseTestSuite) TestHostScriptSoftDelete() { require.EqualValues(t, 0, *scriptRes.ExitCode) } -func (s *integrationEnterpriseTestSuite) uploadSoftwareInstaller(payload *fleet.UploadSoftwareInstallerPayload, expectedStatus int, expectedError string) { +func (s *integrationEnterpriseTestSuite) uploadSoftwareInstaller( + payload *fleet.UploadSoftwareInstallerPayload, + expectedStatus int, + expectedError string, +) { t := s.T() t.Helper() openFile := func(name string) *os.File { @@ -11388,6 +11572,8 @@ func (s *integrationEnterpriseTestSuite) uploadSoftwareInstaller(payload *fleet. } r := s.DoRawWithHeaders("POST", "/api/latest/fleet/software/package", b.Bytes(), expectedStatus, headers) + defer r.Body.Close() + if expectedError != "" { errMsg := extractServerErrorText(r.Body) require.Contains(t, errMsg, expectedError) @@ -11711,6 +11897,21 @@ func (s *integrationEnterpriseTestSuite) TestPKGNewSoftwareTitleFlow() { ) } +func (s *integrationEnterpriseTestSuite) TestPKGNoVersion() { + t := s.T() + ctx := context.Background() + + team, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "team1"}) + require.NoError(t, err) + + payload := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "some installer script", + Filename: "no_version.pkg", + TeamID: &team.ID, + } + s.uploadSoftwareInstaller(payload, http.StatusBadRequest, "Couldn't add. Fleet couldn't read the version from no_version.pkg.") +} + // 1. host reports software // 2. reconciler runs, creates title // 3. installer is uploaded, matches existing software title @@ -12677,6 +12878,8 @@ func (s *integrationEnterpriseTestSuite) TestVPPAppsWithoutMDM() { // Create host orbitHost := createOrbitEnrolledHost(t, "darwin", "nonmdm", s.ds) + test.CreateInsertGlobalVPPToken(t, s.ds) + // Create team and add host to team var newTeamResp teamResponse s.DoJSON("POST", "/api/latest/fleet/teams", &createTeamRequest{TeamPayload: fleet.TeamPayload{Name: ptr.String("Team 1")}}, http.StatusOK, &newTeamResp) @@ -12700,3 +12903,621 @@ func (s *integrationEnterpriseTestSuite) TestVPPAppsWithoutMDM() { r := s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", orbitHost.ID, app.TitleID), &installSoftwareRequest{}, http.StatusUnprocessableEntity) require.Contains(t, extractServerErrorText(r.Body), "Couldn't install. MDM is turned off. Please make sure that MDM is turned on to install App Store apps.") } + +func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers() { + t := s.T() + ctx := context.Background() + + team1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "team1"}) + require.NoError(t, err) + team2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "team2"}) + require.NoError(t, err) + + test.CreateInsertGlobalVPPToken(t, s.ds) + + newHost := func(name string, teamID *uint, platform string) *fleet.Host { + h, err := s.ds.NewHost(ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now().Add(-1 * time.Minute), + OsqueryHostID: ptr.String(t.Name() + name), + NodeKey: ptr.String(t.Name() + name), + UUID: uuid.New().String(), + Hostname: fmt.Sprintf("%s.%s.local", name, t.Name()), + Platform: platform, + TeamID: teamID, + }) + require.NoError(t, err) + return h + } + newFleetdHost := func(name string, teamID *uint, platform string) *fleet.Host { + h := newHost(name, teamID, platform) + orbitKey := setOrbitEnrollment(t, h, s.ds) + h.OrbitNodeKey = &orbitKey + return h + } + + host0NoTeam := newFleetdHost("host1NoTeam", nil, "darwin") + host1Team1 := newFleetdHost("host1Team1", &team1.ID, "darwin") + host2Team1 := newFleetdHost("host2Team1", &team1.ID, "ubuntu") + host3Team2 := newFleetdHost("host3Team2", &team2.ID, "windows") + hostVanillaOsquery5Team1 := newHost("hostVanillaOsquery5Team2", &team1.ID, "darwin") + + // Upload dummy_installer.pkg to team1. + pkgPayload := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "some pkg install script", + Filename: "dummy_installer.pkg", + TeamID: &team1.ID, + } + s.uploadSoftwareInstaller(pkgPayload, http.StatusOK, "") + // Get software title ID of the uploaded installer. + resp := listSoftwareTitlesResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{}, + http.StatusOK, &resp, + "query", "DummyApp.app", + "team_id", fmt.Sprintf("%d", team1.ID), + ) + require.Len(t, resp.SoftwareTitles, 1) + require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage) + dummyInstallerPkgTitleID := resp.SoftwareTitles[0].ID + var dummyInstallerPkg struct { + ID uint `db:"id"` + UserID *uint `db:"user_id"` + UserName string `db:"user_name"` + UserEmail string `db:"user_email"` + } + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, + &dummyInstallerPkg, + `SELECT id, user_id, user_name, user_email FROM software_installers WHERE global_or_team_id = ? AND filename = ?`, + team1.ID, "dummy_installer.pkg", + ) + }) + dummyInstallerPkgInstallerID := dummyInstallerPkg.ID + require.NotZero(t, dummyInstallerPkgInstallerID) + require.NotNil(t, dummyInstallerPkg.UserID) + globalAdmin, err := s.ds.UserByEmail(ctx, "admin1@example.com") + require.NoError(t, err) + require.Equal(t, globalAdmin.ID, *dummyInstallerPkg.UserID) + require.Equal(t, "Test Name admin1@example.com", dummyInstallerPkg.UserName) + require.Equal(t, "admin1@example.com", dummyInstallerPkg.UserEmail) + + // Upload ruby.deb to team1 by a user who will be deleted. + u2 := &fleet.User{ + Name: "admin team1", + Email: "admin_team1@example.com", + GlobalRole: nil, + Teams: []fleet.UserTeam{ + { + Team: *team1, + Role: fleet.RoleAdmin, + }, + }, + } + require.NoError(t, u2.SetPassword(test.GoodPassword, 10, 10)) + adminTeam1, err := s.ds.NewUser(context.Background(), u2) + require.NoError(t, err) + rubyPayload := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "some deb install script", + Filename: "ruby.deb", + TeamID: &team1.ID, + } + sessionKey := uuid.New().String() + adminTeam1Session, err := s.ds.NewSession(ctx, adminTeam1.ID, sessionKey) + require.NoError(t, err) + adminToken := s.token + t.Cleanup(func() { + s.token = adminToken + }) + s.token = adminTeam1Session.Key + s.uploadSoftwareInstaller(rubyPayload, http.StatusOK, "") + s.token = adminToken + err = s.ds.DeleteUser(ctx, adminTeam1.ID) + require.NoError(t, err) + // Get software title ID of the uploaded installer. + resp = listSoftwareTitlesResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{}, + http.StatusOK, &resp, + "query", "ruby", + "team_id", fmt.Sprintf("%d", team1.ID), + ) + require.Len(t, resp.SoftwareTitles, 1) + require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage) + rubyDebTitleID := resp.SoftwareTitles[0].ID + var rubyDeb struct { + ID uint `db:"id"` + UserID *uint `db:"user_id"` + UserName string `db:"user_name"` + UserEmail string `db:"user_email"` + } + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, + &rubyDeb, + `SELECT id, user_id, user_name, user_email FROM software_installers WHERE global_or_team_id = ? AND filename = ?`, + team1.ID, "ruby.deb", + ) + }) + rubyDebInstallerID := rubyDeb.ID + require.NotZero(t, rubyDebInstallerID) + require.Nil(t, rubyDeb.UserID) + require.Equal(t, "admin team1", rubyDeb.UserName) + require.Equal(t, "admin_team1@example.com", rubyDeb.UserEmail) + + // Upload fleet-osquery.msi to team2. + fleetOsqueryPayload := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "some msi install script", + Filename: "fleet-osquery.msi", + TeamID: &team2.ID, + // Set as Self-service to check that the generated host_software_installs + // is generated with self_service=false and the activity has the correct + // author (the admin that uploaded the installer). + SelfService: true, + } + s.uploadSoftwareInstaller(fleetOsqueryPayload, http.StatusOK, "") + // Get software title ID of the uploaded installer. + resp = listSoftwareTitlesResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{}, + http.StatusOK, &resp, + "query", "Fleet osquery", + "team_id", fmt.Sprintf("%d", team2.ID), + ) + require.Len(t, resp.SoftwareTitles, 1) + require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage) + fleetOsqueryMSITitleID := resp.SoftwareTitles[0].ID + var fleetOsqueryMSIInstallerID uint + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, + &fleetOsqueryMSIInstallerID, + `SELECT id FROM software_installers WHERE global_or_team_id = ? AND filename = ?`, + team2.ID, "fleet-osquery.msi", + ) + }) + require.NotZero(t, fleetOsqueryMSIInstallerID) + + // Create a VPP app to test that policies cannot be assigned to them. + _, err = s.ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{ + Name: "App123 " + t.Name(), + BundleIdentifier: "bid_" + t.Name(), + VPPAppTeam: fleet.VPPAppTeam{ + VPPAppID: fleet.VPPAppID{ + AdamID: "adam_" + t.Name(), + Platform: fleet.MacOSPlatform, + }, + }, + }, &team1.ID) + require.NoError(t, err) + // Get software title ID of the uploaded VPP app. + resp = listSoftwareTitlesResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{}, + http.StatusOK, &resp, + "query", "App123", + "team_id", fmt.Sprintf("%d", team1.ID), + ) + require.Len(t, resp.SoftwareTitles, 1) + require.NotNil(t, resp.SoftwareTitles[0].AppStoreApp) + vppAppTitleID := resp.SoftwareTitles[0].ID + + // Populate software for host1Team1 (to have a software title + // that doesn't have an associated installer) + software := []fleet.Software{ + {Name: "Foobar.app", Version: "0.0.1", Source: "apps"}, + } + _, err = s.ds.UpdateHostSoftware(ctx, host1Team1.ID, software) + require.NoError(t, err) + require.NoError(t, s.ds.SyncHostsSoftware(ctx, time.Now())) + require.NoError(t, s.ds.ReconcileSoftwareTitles(ctx)) + require.NoError(t, s.ds.SyncHostsSoftwareTitles(ctx, time.Now())) + // Get software title ID of the software. + resp = listSoftwareTitlesResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{}, + http.StatusOK, &resp, + "query", "Foobar.app", + "team_id", fmt.Sprintf("%d", team1.ID), + ) + require.Len(t, resp.SoftwareTitles, 1) + require.Nil(t, resp.SoftwareTitles[0].SoftwarePackage) + foobarAppTitleID := resp.SoftwareTitles[0].ID + + // policy0AllTeams is a global policy that runs on all devices. + policy0AllTeams, err := s.ds.NewGlobalPolicy(ctx, nil, fleet.PolicyPayload{ + Name: "policy0AllTeams", + Query: "SELECT 1;", + Platform: "darwin", + }) + require.NoError(t, err) + // policy1Team1 runs on macOS devices. + policy1Team1, err := s.ds.NewTeamPolicy(ctx, team1.ID, nil, fleet.PolicyPayload{ + Name: "policy1Team1", + Query: "SELECT 1;", + Platform: "darwin", + }) + require.NoError(t, err) + // policy2Team1 runs on macOS and Linux devices. + policy2Team1, err := s.ds.NewTeamPolicy(ctx, team1.ID, nil, fleet.PolicyPayload{ + Name: "policy2Team1", + Query: "SELECT 2;", + Platform: "linux,darwin", + }) + require.NoError(t, err) + // policy3Team1 runs on all devices in team1 (will have no associated installers). + policy3Team1, err := s.ds.NewTeamPolicy(ctx, team1.ID, nil, fleet.PolicyPayload{ + Name: "policy3Team1", + Query: "SELECT 3;", + }) + require.NoError(t, err) + // policy4Team2 runs on Windows devices. + policy4Team2, err := s.ds.NewTeamPolicy(ctx, team2.ID, nil, fleet.PolicyPayload{ + Name: "policy4Team2", + Query: "SELECT 4;", + Platform: "windows", + }) + require.NoError(t, err) + + // Attempt to associate to an unknown software title. + mtplr := modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: ptr.Uint(999_999), + }, + }, http.StatusBadRequest, &mtplr) + // Attempt to associate to a software title without associated installer. + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: ptr.Uint(foobarAppTitleID), + }, + }, http.StatusBadRequest, &mtplr) + // Attempt to associate vppApp to policy1Team1 which should fail because we only allow associating software installers. + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: &vppAppTitleID, + }, + }, http.StatusBadRequest, &mtplr) + // Associate dummy_installer.pkg to policy1Team1. + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: &dummyInstallerPkgTitleID, + }, + }, http.StatusOK, &mtplr) + // Change name only (to test not setting a software_title_id). + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), + json.RawMessage(`{"name": "policy1Team1_Renamed"}`), http.StatusOK, &mtplr, + ) + policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID) + require.NoError(t, err) + require.NotNil(t, policy1Team1.SoftwareInstallerID) + require.Equal(t, dummyInstallerPkgInstallerID, *policy1Team1.SoftwareInstallerID) + require.Equal(t, "policy1Team1_Renamed", *&policy1Team1.Name) + // Explicit set to 0 to disable. + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: ptr.Uint(0), + }, + }, http.StatusOK, &mtplr) + policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID) + require.NoError(t, err) + require.Nil(t, policy1Team1.SoftwareInstallerID) + // Back to associating dummy_installer.pkg to policy1Team1. + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: &dummyInstallerPkgTitleID, + }, + }, http.StatusOK, &mtplr) + policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID) + require.NoError(t, err) + require.NotNil(t, policy1Team1.SoftwareInstallerID) + require.Equal(t, dummyInstallerPkgInstallerID, *policy1Team1.SoftwareInstallerID) + + // Associate ruby.deb to policy2Team1. + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy2Team1.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: &rubyDebTitleID, + }, + }, http.StatusOK, &mtplr) + + host1LastInstall, err := s.ds.GetHostLastInstallData(ctx, host1Team1.ID, dummyInstallerPkgInstallerID) + require.NoError(t, err) + require.Nil(t, host1LastInstall) + + // We use DoJSONWithoutAuth for distributed/write because we want the requests to not have the + // current user's "Authorization: Bearer <API_TOKEN>" header. + + // host1Team1 fails all policies on the first report. + // Failing policy1Team1 means an install request must be generated. + // Failing policy2Team1 should not trigger a install request because it has a .deb attached to it (does not apply to macOS hosts). + // Failing policy3Team1 should do nothing because it doesn't have any installers associated to it. + distributedResp := submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host1Team1, + map[uint]*bool{ + policy1Team1.ID: ptr.Bool(false), + policy2Team1.ID: ptr.Bool(false), + policy3Team1.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + + host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host1Team1.ID, dummyInstallerPkgInstallerID) + require.NoError(t, err) + require.NotNil(t, host1LastInstall) + require.NotEmpty(t, host1LastInstall.ExecutionID) + require.NotNil(t, host1LastInstall.Status) + require.Equal(t, fleet.SoftwareInstallerPending, *host1LastInstall.Status) + prevExecutionID := host1LastInstall.ExecutionID + + // Request a manual installation on the host for the same installer, which should fail. + var installResp installSoftwareResponse + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", + host1Team1.ID, dummyInstallerPkgTitleID), nil, http.StatusBadRequest, &installResp) + + // Submit same results as before, which should not trigger a installation because the policy is already failing. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host1Team1, + map[uint]*bool{ + policy1Team1.ID: ptr.Bool(false), + policy2Team1.ID: ptr.Bool(false), + policy3Team1.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + + host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host1Team1.ID, dummyInstallerPkgInstallerID) + require.NoError(t, err) + require.NotNil(t, host1LastInstall) + require.Equal(t, prevExecutionID, host1LastInstall.ExecutionID) + require.NotNil(t, host1LastInstall.Status) + require.Equal(t, fleet.SoftwareInstallerPending, *host1LastInstall.Status) + + // Submit same results but policy1Team1 now passes, + // and then submit again but policy1Team1 fails. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host1Team1, + map[uint]*bool{ + policy1Team1.ID: ptr.Bool(true), + policy2Team1.ID: ptr.Bool(false), + policy3Team1.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host1Team1, + map[uint]*bool{ + policy1Team1.ID: ptr.Bool(false), + policy2Team1.ID: ptr.Bool(false), + policy3Team1.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + + // Another installation should not be triggered because the last installation is pending. + host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host1Team1.ID, dummyInstallerPkgInstallerID) + require.NoError(t, err) + require.NotNil(t, host1LastInstall) + require.Equal(t, prevExecutionID, host1LastInstall.ExecutionID) + require.NotNil(t, host1LastInstall.Status) + require.Equal(t, fleet.SoftwareInstallerPending, *host1LastInstall.Status) + + // host2Team1 is failing policy2Team1 and policy3Team1 policies. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host2Team1, + map[uint]*bool{ + policy2Team1.ID: ptr.Bool(false), + policy3Team1.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + + host2LastInstall, err := s.ds.GetHostLastInstallData(ctx, host2Team1.ID, rubyDebInstallerID) + require.NoError(t, err) + require.NotNil(t, host2LastInstall) + require.NotEmpty(t, host2LastInstall.ExecutionID) + require.NotNil(t, host2LastInstall.Status) + require.Equal(t, fleet.SoftwareInstallerPending, *host2LastInstall.Status) + + // Associate fleet-osquery.msi to policy4Team2. + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team2.ID, policy4Team2.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: &fleetOsqueryMSITitleID, + }, + }, http.StatusOK, &mtplr) + + // host3Team2 reports a failing result for policy4Team2, which should trigger an installation. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host3Team2, + map[uint]*bool{ + policy4Team2.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + + host3LastInstall, err := s.ds.GetHostLastInstallData(ctx, host3Team2.ID, fleetOsqueryMSIInstallerID) + require.NoError(t, err) + require.NotNil(t, host3LastInstall) + require.NotEmpty(t, host3LastInstall.ExecutionID) + require.NotNil(t, host3LastInstall.Status) + require.Equal(t, fleet.SoftwareInstallerPending, *host3LastInstall.Status) + host3LastInstallDetails, err := s.ds.GetSoftwareInstallDetails(ctx, host3LastInstall.ExecutionID) + require.NoError(t, err) + // Even if fleet-osquery.msi was uploaded as Self-service, it was installed by Fleet, so + // host3LastInstallDetails.SelfService should be false. + require.False(t, host3LastInstallDetails.SelfService) + + // + // The following increase coverage of policies result processing in distributed/write. + // + + // host3Team2 reports a passing result for policy0AllTeams which is a global policy. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host3Team2, + map[uint]*bool{ + policy0AllTeams.ID: ptr.Bool(true), + }, + ), http.StatusOK, &distributedResp) + + // host0NoTeam reports a failing result for policy0AllTeams which is a global policy. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host0NoTeam, + map[uint]*bool{ + policy0AllTeams.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + + // host3Team2 reports a failing result for policy0AllTeams which is a global policy. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host3Team2, + map[uint]*bool{ + policy0AllTeams.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + + // Unassociate policy4Team2 from installer. + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team2.ID, policy4Team2.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: ptr.Uint(0), + }, + }, http.StatusOK, &mtplr) + + // host3Team2 reports a failing result for policy4Team2. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host3Team2, + map[uint]*bool{ + policy4Team2.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + + // Upcoming activities for host1Team1 should show the automatic installation of dummy_installer.pkg. + // Check the author should be the admin that uploaded the installer. + var listUpcomingAct listHostUpcomingActivitiesResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", host1Team1.ID), nil, http.StatusOK, &listUpcomingAct) + require.Len(t, listUpcomingAct.Activities, 1) + require.NotNil(t, listUpcomingAct.Activities[0].ActorID) + require.Equal(t, globalAdmin.ID, *listUpcomingAct.Activities[0].ActorID) + require.Equal(t, globalAdmin.Name, *listUpcomingAct.Activities[0].ActorFullName) + require.Equal(t, globalAdmin.Email, *listUpcomingAct.Activities[0].ActorEmail) + + // + // Finally have orbit install the packages and check activities. + // + + // host1Team1 posts the installation result for dummy_installer.pkg. + s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(fmt.Sprintf(`{ + "orbit_node_key": %q, + "install_uuid": %q, + "pre_install_condition_output": "ok", + "install_script_exit_code": 0, + "install_script_output": "ok" + }`, *host1Team1.OrbitNodeKey, host1LastInstall.ExecutionID)), http.StatusNoContent) + s.lastActivityMatches(fleet.ActivityTypeInstalledSoftware{}.ActivityName(), fmt.Sprintf(`{ + "host_id": %d, + "host_display_name": "%s", + "software_title": "%s", + "software_package": "%s", + "self_service": false, + "install_uuid": "%s", + "status": "installed" + }`, host1Team1.ID, host1Team1.DisplayName(), "DummyApp.app", "dummy_installer.pkg", host1LastInstall.ExecutionID), 0) + + // host2Team1 posts the installation result for ruby.deb. + s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(fmt.Sprintf(`{ + "orbit_node_key": %q, + "install_uuid": %q, + "pre_install_condition_output": "ok", + "install_script_exit_code": 1, + "install_script_output": "failed" + }`, *host2Team1.OrbitNodeKey, host2LastInstall.ExecutionID)), http.StatusNoContent) + activityID := s.lastActivityMatches(fleet.ActivityTypeInstalledSoftware{}.ActivityName(), fmt.Sprintf(`{ + "host_id": %d, + "host_display_name": "%s", + "software_title": "%s", + "software_package": "%s", + "self_service": false, + "install_uuid": "%s", + "status": "failed" + }`, host2Team1.ID, host2Team1.DisplayName(), "ruby", "ruby.deb", host2LastInstall.ExecutionID), 0) + + // Check that the activity item generated for ruby.deb installation has a null user, + // but has name and email set. + var actor struct { + UserID *uint `db:"user_id"` + UserName *string `db:"user_name"` + UserEmail string `db:"user_email"` + } + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, + &actor, + `SELECT user_id, user_name, user_email FROM activities WHERE id = ?`, + activityID, + ) + }) + require.Nil(t, actor.UserID) + require.NotNil(t, actor.UserName) + require.Equal(t, "admin team1", *actor.UserName) + require.Equal(t, "admin_team1@example.com", actor.UserEmail) + + // host3Team2 posts the installation result for fleet-osquery.msi. + s.Do("POST", "/api/fleet/orbit/software_install/result", json.RawMessage(fmt.Sprintf(`{ + "orbit_node_key": %q, + "install_uuid": %q, + "pre_install_condition_output": "ok", + "install_script_exit_code": 1, + "install_script_output": "failed" + }`, *host3Team2.OrbitNodeKey, host3LastInstall.ExecutionID)), http.StatusNoContent) + activityID = s.lastActivityMatches(fleet.ActivityTypeInstalledSoftware{}.ActivityName(), fmt.Sprintf(`{ + "host_id": %d, + "host_display_name": "%s", + "software_title": "%s", + "software_package": "%s", + "self_service": false, + "install_uuid": "%s", + "status": "failed" + }`, host3Team2.ID, host3Team2.DisplayName(), "Fleet osquery", "fleet-osquery.msi", host3LastInstall.ExecutionID), 0) + + // Check that the activity item generated for fleet-osquery.msi installation has the admin user set as author. + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, + &actor, + `SELECT user_id, user_name, user_email FROM activities WHERE id = ?`, + activityID, + ) + }) + require.NotNil(t, actor.UserID) + require.Equal(t, globalAdmin.ID, *actor.UserID) + require.NotNil(t, actor.UserName) + require.Equal(t, "Test Name admin1@example.com", *actor.UserName) + require.Equal(t, "admin1@example.com", actor.UserEmail) + + // hostVanillaOsquery5Team1 sends policy results with failed policies with associated installers. + // Fleet should not queue an install for vanilla osquery hosts. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + hostVanillaOsquery5Team1, + map[uint]*bool{ + policy1Team1.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + hostVanillaOsquery5Team1LastInstall, err := s.ds.GetHostLastInstallData(ctx, hostVanillaOsquery5Team1.ID, dummyInstallerPkgInstallerID) + require.NoError(t, err) + require.Nil(t, hostVanillaOsquery5Team1LastInstall) +} diff --git a/server/service/integration_mdm_dep_test.go b/server/service/integration_mdm_dep_test.go index b741e1412d52..fca30a229e75 100644 --- a/server/service/integration_mdm_dep_test.go +++ b/server/service/integration_mdm_dep_test.go @@ -38,8 +38,6 @@ type profileAssignmentReq struct { func (s *integrationMDMTestSuite) TestDEPEnrollReleaseDeviceGlobal() { t := s.T() - // FIXME - t.Skip() ctx := context.Background() globalDevice := godep.Device{SerialNumber: uuid.New().String(), Model: "MacBook Pro", OS: "osx", OpType: "added"} @@ -96,6 +94,7 @@ func (s *integrationMDMTestSuite) TestDEPEnrollReleaseDeviceGlobal() { // enable FileVault s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage([]byte(`{"mdm":{"macos_settings":{"enable_disk_encryption":true}}}`)), http.StatusOK) + s.enableABM("fleet_ade_test") for _, enableReleaseManually := range []bool{false, true} { t.Run(fmt.Sprintf("enableReleaseManually=%t", enableReleaseManually), func(t *testing.T) { s.runDEPEnrollReleaseDeviceTest(t, globalDevice, enableReleaseManually, nil, "I1") @@ -105,8 +104,6 @@ func (s *integrationMDMTestSuite) TestDEPEnrollReleaseDeviceGlobal() { func (s *integrationMDMTestSuite) TestDEPEnrollReleaseDeviceTeam() { t := s.T() - // FIXME - t.Skip() ctx := context.Background() teamDevice := godep.Device{SerialNumber: uuid.New().String(), Model: "MacBook Pro", OS: "osx", OpType: "added"} @@ -141,9 +138,15 @@ func (s *integrationMDMTestSuite) TestDEPEnrollReleaseDeviceTeam() { // setup IdP so that AccountConfiguration profile is sent after DEP enrollment var acResp appConfigResponse + s.enableABM("fleet_ade_test") s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(fmt.Sprintf(`{ "mdm": { - "apple_bm_default_team": %q, + "apple_business_manager": [{ + "organization_name": %q, + "macos_team": %q, + "ios_team": %q, + "ipados_team": %q + }], "end_user_authentication": { "entity_id": "https://localhost:8080", "issuer_uri": "http://localhost:8080/simplesaml/saml2/idp/SSOService.php", @@ -154,7 +157,7 @@ func (s *integrationMDMTestSuite) TestDEPEnrollReleaseDeviceTeam() { "enable_end_user_authentication": true } } - }`, tm.Name)), http.StatusOK, &acResp) + }`, "fleet_ade_test", tm.Name, tm.Name, tm.Name)), http.StatusOK, &acResp) require.NotEmpty(t, acResp.MDM.EndUserAuthentication) // TODO(mna): how/where to pass an enroll_reference so that @@ -195,7 +198,7 @@ func (s *integrationMDMTestSuite) runDEPEnrollReleaseDeviceTest(t *testing.T, de return map[string]*push.Response{}, nil } - s.mockDEPResponse(defaultOrgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.mockDEPResponse("fleet_ade_test", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { encoder := json.NewEncoder(w) switch r.URL.Path { case "/session": @@ -382,8 +385,6 @@ func (s *integrationMDMTestSuite) expectAndScheduleReleaseDeviceJob(t *testing.T func (s *integrationMDMTestSuite) TestDEPProfileAssignment() { t := s.T() - // FIXME - t.Skip() ctx := context.Background() devices := []godep.Device{ @@ -543,7 +544,9 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignment() { expectAssignProfileResponseFailed := "" // set to device serial when testing the failed profile assignment flow expectAssignProfileResponseNotAccessible := "" // set to device serial when testing the not accessible profile assignment flow - s.mockDEPResponse(defaultOrgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + + s.enableABM(t.Name()) + s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) encoder := json.NewEncoder(w) switch r.URL.Path { @@ -925,6 +928,7 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignment() { addHostsToTeamRequest{TeamID: &dummyTeam.ID, HostIDs: []uint{eHost.ID}}, http.StatusOK) checkPendingMacOSSetupAssistantJob("hosts_transferred", &dummyTeam.ID, []string{eHost.HardwareSerial}, 0) + s.runWorker() // expect no assign profile request during cooldown profileAssignmentReqs = []profileAssignmentReq{} s.runIntegrationsSchedule() @@ -991,7 +995,7 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignment() { checkPendingMacOSSetupAssistantJob("hosts_cooldown", nil, []string{eHost.HardwareSerial}, jobID) checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", true) - // run the inregration schedule and expect success + // run the integration schedule and expect success expectAssignProfileResponseFailed = "" profileAssignmentReqs = []profileAssignmentReq{} s.runIntegrationsSchedule() @@ -1130,10 +1134,8 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignment() { func (s *integrationMDMTestSuite) TestDeprecatedDefaultAppleBMTeam() { t := s.T() - // FIXME - t.Skip() - s.enableABM() + s.enableABM(t.Name()) tm, err := s.ds.NewTeam(context.Background(), &fleet.Team{ Name: t.Name(), @@ -1143,6 +1145,16 @@ func (s *integrationMDMTestSuite) TestDeprecatedDefaultAppleBMTeam() { var acResp appConfigResponse + defer func() { + acResp = appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { + "apple_bm_default_team": "" + } + }`), http.StatusOK, &acResp) + require.Empty(t, acResp.MDM.DeprecatedAppleBMDefaultTeam) + }() + // try to set an invalid team name s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { diff --git a/server/service/integration_mdm_lifecycle_test.go b/server/service/integration_mdm_lifecycle_test.go index 82f11ab679da..b487cd1db3ec 100644 --- a/server/service/integration_mdm_lifecycle_test.go +++ b/server/service/integration_mdm_lifecycle_test.go @@ -226,10 +226,9 @@ func (s *integrationMDMTestSuite) TestTurnOnLifecycleEventsApple() { }) t.Run("automatic enrollment", func(t *testing.T) { - // FIXME - t.Skip() device := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, "") - s.mockDEPResponse(defaultOrgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.enableABM(t.Name()) + s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) encoder := json.NewEncoder(w) switch r.URL.Path { @@ -423,8 +422,6 @@ func (s *integrationMDMTestSuite) TestTurnOnLifecycleEventsWindows() { }) t.Run("automatic enrollment", func(t *testing.T) { - // FIXME - t.Skip() if strings.Contains(tt.Name, "wipe") { t.Skip("wipe tests are not supported for windows automatic enrollment until we fix #TODO") } @@ -593,14 +590,9 @@ func (s *integrationMDMTestSuite) setupLifecycleSettings() { // Host is renewing SCEP certificates func (s *integrationMDMTestSuite) TestLifecycleSCEPCertExpiration() { t := s.T() - // FIXME - t.Skip() ctx := context.Background() // ensure there's a token for automatic enrollments - s.mockDEPResponse(defaultOrgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"auth_session_token": "xyz"}`)) - })) + s.enableABM(t.Name()) s.runDEPSchedule() // add a device that's manually enrolled diff --git a/server/service/integration_mdm_profiles_test.go b/server/service/integration_mdm_profiles_test.go index 597f6e4f3dae..c60d0580e29d 100644 --- a/server/service/integration_mdm_profiles_test.go +++ b/server/service/integration_mdm_profiles_test.go @@ -10,6 +10,7 @@ import ( "fmt" "io" "net/http" + "net/url" "sort" "strconv" "strings" @@ -948,8 +949,9 @@ func (s *integrationMDMTestSuite) TestWindowsProfileRetries() { func (s *integrationMDMTestSuite) TestPuppetMatchPreassignProfiles() { ctx := context.Background() t := s.T() - // FIXME - t.Skip() + + // before we switch to a gitops token, ensure ABM is setup + s.enableABM(t.Name()) // Use a gitops user for all Puppet actions u := &fleet.User{ @@ -984,7 +986,7 @@ func (s *integrationMDMTestSuite) TestPuppetMatchPreassignProfiles() { // create a setup assistant for no team, for this we need to: // 1. mock the ABM API, as it gets called to set the profile // 2. run the DEP schedule, as this registers the default profile - s.mockDEPResponse(defaultOrgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"auth_session_token": "xyz"}`)) })) @@ -3781,17 +3783,18 @@ func (s *integrationMDMTestSuite) TestBatchSetMDMProfiles() { // apply an empty set to no-team s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: nil}, http.StatusNoContent) - s.lastActivityOfTypeMatches( + // Nothing changed, so no activity items + s.lastActivityOfTypeDoesNotMatch( fleet.ActivityTypeEditedMacosProfile{}.ActivityName(), `{"team_id": null, "team_name": null}`, 0, ) - s.lastActivityOfTypeMatches( + s.lastActivityOfTypeDoesNotMatch( fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(), `{"team_id": null, "team_name": null}`, 0, ) - s.lastActivityOfTypeMatches( + s.lastActivityOfTypeDoesNotMatch( fleet.ActivityTypeEditedDeclarationProfile{}.ActivityName(), `{"team_id": null, "team_name": null}`, 0, @@ -4061,12 +4064,13 @@ func (s *integrationMDMTestSuite) TestBatchSetMDMProfilesBackwardsCompat() { // apply an empty set to no-team s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": nil}, http.StatusNoContent) - s.lastActivityOfTypeMatches( + // Nothing changed, so no activity + s.lastActivityOfTypeDoesNotMatch( fleet.ActivityTypeEditedMacosProfile{}.ActivityName(), `{"team_id": null, "team_name": null}`, 0, ) - s.lastActivityOfTypeMatches( + s.lastActivityOfTypeDoesNotMatch( fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(), `{"team_id": null, "team_name": null}`, 0, @@ -4812,3 +4816,36 @@ func (s *integrationMDMTestSuite) TestHostMDMProfilesExcludeLabels() { }, }) } + +func (s *integrationMDMTestSuite) TestOTAProfile() { + t := s.T() + ctx := context.Background() + + // Getting profile for non-existent secret it's ok + s.Do("GET", "/api/latest/fleet/enrollment_profiles/ota", getOTAProfileRequest{}, http.StatusOK, "enroll_secret", "not-real") + + // Create an enroll secret; has some special characters that should be escaped in the profile + globalEnrollSec := "global_enroll+_/sec" + escSec := url.QueryEscape(globalEnrollSec) + s.Do("POST", "/api/latest/fleet/spec/enroll_secret", applyEnrollSecretSpecRequest{ + Spec: &fleet.EnrollSecretSpec{ + Secrets: []*fleet.EnrollSecret{{Secret: globalEnrollSec}}, + }, + }, http.StatusOK) + + cfg, err := s.ds.AppConfig(ctx) + require.NoError(t, err) + + // Get profile with that enroll secret + resp := s.Do("GET", "/api/latest/fleet/enrollment_profiles/ota", getOTAProfileRequest{}, http.StatusOK, "enroll_secret", globalEnrollSec) + require.NotZero(t, resp.ContentLength) + require.Contains(t, resp.Header.Get("Content-Disposition"), `attachment;filename="fleet-mdm-enrollment-profile.mobileconfig"`) + require.Contains(t, resp.Header.Get("Content-Type"), "application/x-apple-aspen-config") + require.Contains(t, resp.Header.Get("X-Content-Type-Options"), "nosniff") + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, resp.ContentLength, int64(len(b))) + require.Contains(t, string(b), "com.fleetdm.fleet.mdm.apple.ota") + require.Contains(t, string(b), fmt.Sprintf("%s/api/v1/fleet/ota_enrollment?enroll_secret=%s", cfg.ServerSettings.ServerURL, escSec)) + require.Contains(t, string(b), cfg.OrgInfo.OrgName) +} diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index ef662a058939..63ab882da18f 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -85,11 +85,9 @@ type integrationMDMTestSuite struct { fleetDMNextCSRStatus atomic.Value pushProvider *mock.APNSPushProvider depStorage nanodep_storage.AllDEPStorage - depSchedule *schedule.Schedule profileSchedule *schedule.Schedule integrationsSchedule *schedule.Schedule onProfileJobDone func() // function called when profileSchedule.Trigger() job completed - onDEPScheduleDone func() // function called when depSchedule.Trigger() job completed onIntegrationsScheduleDone func() // function called when integrationsSchedule.Trigger() job completed mdmStorage *mysql.NanoMDMStorage worker *worker.Worker @@ -177,7 +175,6 @@ func (s *integrationMDMTestSuite) SetupSuite() { return err }) - var depSchedule *schedule.Schedule var integrationsSchedule *schedule.Schedule var profileSchedule *schedule.Schedule cronLog := kitlog.NewJSONLogger(os.Stdout) @@ -215,26 +212,6 @@ func (s *integrationMDMTestSuite) SetupSuite() { SoftwareInstallStore: softwareInstallerStore, BootstrapPackageStore: bootstrapPackageStore, StartCronSchedules: []TestNewScheduleFunc{ - func(ctx context.Context, ds fleet.Datastore) fleet.NewCronScheduleFunc { - return func() (fleet.CronSchedule, error) { - const name = string(fleet.CronAppleMDMDEPProfileAssigner) - logger := cronLog - fleetSyncer := apple_mdm.NewDEPService(ds, depStorage, logger) - depSchedule = schedule.New( - ctx, name, s.T().Name(), 1*time.Hour, ds, ds, - schedule.WithLogger(logger), - schedule.WithJob("dep_syncer", func(ctx context.Context) error { - if s.onDEPScheduleDone != nil { - defer s.onDEPScheduleDone() - } - err := fleetSyncer.RunAssigner(ctx) - require.NoError(s.T(), err) - return err - }), - ) - return depSchedule, nil - } - }, func(ctx context.Context, ds fleet.Datastore) fleet.NewCronScheduleFunc { return func() (fleet.CronSchedule, error) { const name = string(fleet.CronMDMAppleProfileManager) @@ -323,7 +300,6 @@ func (s *integrationMDMTestSuite) SetupSuite() { s.fleetCfg = fleetCfg s.pushProvider = pushProvider s.depStorage = depStorage - s.depSchedule = depSchedule s.integrationsSchedule = integrationsSchedule s.profileSchedule = profileSchedule s.mdmStorage = mdmStorage @@ -579,7 +555,6 @@ func (s *integrationMDMTestSuite) SetupSuite() { // enable MDM flows s.appleCoreCertsSetup() - s.enableABM() s.T().Cleanup(fleetdmSrv.Close) s.T().Cleanup(s.appleVPPConfigSrv.Close) @@ -674,6 +649,14 @@ func (s *integrationMDMTestSuite) TearDownTest() { _, err := tx.ExecContext(ctx, "DELETE FROM host_mdm;") return err }) + mysql.ExecAdhocSQL(t, s.ds, func(tx sqlx.ExtContext) error { + _, err := tx.ExecContext(ctx, "DELETE FROM abm_tokens;") + return err + }) + mysql.ExecAdhocSQL(t, s.ds, func(tx sqlx.ExtContext) error { + _, err := tx.ExecContext(ctx, "DELETE FROM vpp_tokens;") + return err + }) } func (s *integrationMDMTestSuite) mockDEPResponse(orgName string, handler http.Handler) { @@ -805,8 +788,6 @@ func (s *integrationMDMTestSuite) TestGetBootstrapToken() { }) } -const defaultOrgName = "fleet" - func (s *integrationMDMTestSuite) TestAppleGetAppleMDM() { t := s.T() @@ -818,22 +799,36 @@ func (s *integrationMDMTestSuite) TestAppleGetAppleMDM() { require.Equal(t, "Fleet", mdmResp.CommonName) require.NotZero(t, mdmResp.RenewDate) - s.mockDEPResponse(defaultOrgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - switch r.URL.Path { - case "/session": - _, _ = w.Write([]byte(`{"auth_session_token": "xyz"}`)) - case "/account": - _, _ = w.Write([]byte(`{"admin_id": "abc", "org_name": "fleet"}`)) - } - })) - var getAppleBMResp getAppleBMResponse - s.DoJSON("GET", "/api/latest/fleet/abm", nil, http.StatusOK, &getAppleBMResp) - require.NoError(t, getAppleBMResp.Err) - require.Equal(t, "abc", getAppleBMResp.AppleID) - require.Equal(t, "fleet", getAppleBMResp.OrgName) - require.Equal(t, s.server.URL+"/mdm/apple/mdm", getAppleBMResp.MDMServerURL) - require.Empty(t, getAppleBMResp.DefaultTeam) + // set up multiple ABM tokens with different org names + defaultOrgName := "fleet_test" + s.enableABM(defaultOrgName) + tmOrgName := t.Name() + s.enableABM(tmOrgName) + + var tokensResp listABMTokensResponse + s.DoJSON("GET", "/api/latest/fleet/abm_tokens", nil, http.StatusOK, &tokensResp) + + // for t.Name() + tok := s.getABMTokenByName(defaultOrgName, tokensResp.Tokens) + require.NotNil(t, tok) + require.False(t, tok.TermsExpired) + require.Equal(t, "abc", tok.AppleID) + require.Equal(t, defaultOrgName, tok.OrganizationName) + require.Equal(t, s.server.URL+"/mdm/apple/mdm", tok.MDMServerURL) + require.Equal(t, fleet.TeamNameNoTeam, tok.MacOSTeam.Name) + require.Equal(t, fleet.TeamNameNoTeam, tok.IOSTeam.Name) + require.Equal(t, fleet.TeamNameNoTeam, tok.IPadOSTeam.Name) + + // for tmOrgName + tok = s.getABMTokenByName(tmOrgName, tokensResp.Tokens) + require.NotNil(t, tok) + require.False(t, tok.TermsExpired) + require.Equal(t, "abc", tok.AppleID) + require.Equal(t, tmOrgName, tok.OrganizationName) + require.Equal(t, s.server.URL+"/mdm/apple/mdm", tok.MDMServerURL) + require.Equal(t, fleet.TeamNameNoTeam, tok.MacOSTeam.Name) + require.Equal(t, fleet.TeamNameNoTeam, tok.IOSTeam.Name) + require.Equal(t, fleet.TeamNameNoTeam, tok.IPadOSTeam.Name) // create a new team tm, err := s.ds.NewTeam(context.Background(), &fleet.Team{ @@ -841,29 +836,58 @@ func (s *integrationMDMTestSuite) TestAppleGetAppleMDM() { Description: "desc", }) require.NoError(t, err) - // set the default bm assignment to that team + // set the default bm assignment for that token to that team acResp := appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(fmt.Sprintf(`{ "mdm": { - "apple_bm_default_team": %q + "apple_business_manager": [{ + "organization_name": %q, + "macos_team": %q, + "ios_team": %q, + "ipados_team": %q + }] + } + }`, tmOrgName, tm.Name, tm.Name, tm.Name)), http.StatusOK, &acResp) + t.Cleanup(func() { + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { + "apple_business_manager": [] + } + }`), http.StatusOK, &acResp) + }) + + // try again, this time we get team assignments in the response + tokensResp = listABMTokensResponse{} + s.DoJSON("GET", "/api/latest/fleet/abm_tokens", nil, http.StatusOK, &tokensResp) + + tok = s.getABMTokenByName(tmOrgName, tokensResp.Tokens) + require.NotNil(t, tok) + require.False(t, tok.TermsExpired) + require.Equal(t, "abc", tok.AppleID) + require.Equal(t, tmOrgName, tok.OrganizationName) + require.Equal(t, s.server.URL+"/mdm/apple/mdm", tok.MDMServerURL) + require.Equal(t, tm.Name, tok.MacOSTeam.Name) + require.Equal(t, tm.Name, tok.IOSTeam.Name) + require.Equal(t, tm.Name, tok.IPadOSTeam.Name) +} + +func (s *integrationMDMTestSuite) getABMTokenByName(orgName string, tokens []*fleet.ABMToken) *fleet.ABMToken { + for _, tok := range tokens { + if tok.OrganizationName == orgName { + return tok } - }`, tm.Name)), http.StatusOK, &acResp) + } - // try again, this time we get a default team in the response - getAppleBMResp = getAppleBMResponse{} - s.DoJSON("GET", "/api/latest/fleet/abm", nil, http.StatusOK, &getAppleBMResp) - require.NoError(t, getAppleBMResp.Err) - require.Equal(t, "abc", getAppleBMResp.AppleID) - require.Equal(t, "fleet", getAppleBMResp.OrgName) - require.Equal(t, s.server.URL+"/mdm/apple/mdm", getAppleBMResp.MDMServerURL) + return nil } func (s *integrationMDMTestSuite) TestABMExpiredToken() { t := s.T() - // FIXME - t.Skip() + + s.enableABM(t.Name()) + var returnType string - s.mockDEPResponse(defaultOrgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch returnType { case "not_signed": w.WriteHeader(http.StatusForbidden) @@ -882,27 +906,43 @@ func (s *integrationMDMTestSuite) TestABMExpiredToken() { config := s.getConfig() require.False(t, config.MDM.AppleBMTermsExpired) + ctx := context.Background() + fleetSyncer := apple_mdm.NewDEPService(s.ds, s.depStorage, s.logger) + // not signed error flips the AppleBMTermsExpired flag returnType = "not_signed" - res := s.DoRaw("GET", "/api/latest/fleet/abm", nil, http.StatusBadRequest) - errMsg := extractServerErrorText(res.Body) - require.Contains(t, errMsg, "DEP auth error: 403 Forbidden") - + err := fleetSyncer.RunAssigner(ctx) + require.ErrorContains(t, err, "T_C_NOT_SIGNED") + var tokensResp listABMTokensResponse + s.DoJSON("GET", "/api/latest/fleet/abm_tokens", nil, http.StatusOK, &tokensResp) + tok := s.getABMTokenByName(t.Name(), tokensResp.Tokens) + require.NotNil(t, tok) + require.True(t, tok.TermsExpired) config = s.getConfig() require.True(t, config.MDM.AppleBMTermsExpired) // a successful call clears it returnType = "success" - s.DoRaw("GET", "/api/latest/fleet/abm", nil, http.StatusOK) + err = fleetSyncer.RunAssigner(ctx) + require.NoError(t, err) + tokensResp = listABMTokensResponse{} + s.DoJSON("GET", "/api/latest/fleet/abm_tokens", nil, http.StatusOK, &tokensResp) + tok = s.getABMTokenByName(t.Name(), tokensResp.Tokens) + require.NotNil(t, tok) + require.False(t, tok.TermsExpired) config = s.getConfig() require.False(t, config.MDM.AppleBMTermsExpired) - // an unauthorized call returns 400 but does not flip the terms expired flag + // an unauthorized call does not flip the terms expired flag returnType = "unauthorized" - res = s.DoRaw("GET", "/api/latest/fleet/abm", nil, http.StatusBadRequest) - errMsg = extractServerErrorText(res.Body) - require.Contains(t, errMsg, "Apple Business Manager certificate or server token is invalid") + err = fleetSyncer.RunAssigner(ctx) + require.ErrorContains(t, err, "DEP auth error") + tokensResp = listABMTokensResponse{} + s.DoJSON("GET", "/api/latest/fleet/abm_tokens", nil, http.StatusOK, &tokensResp) + tok = s.getABMTokenByName(t.Name(), tokensResp.Tokens) + require.NotNil(t, tok) + require.False(t, tok.TermsExpired) config = s.getConfig() require.False(t, config.MDM.AppleBMTermsExpired) @@ -1378,8 +1418,8 @@ func (s *integrationMDMTestSuite) TestMDMAppleUnenroll() { // 3 profiles added + 1 profile with fleetd configuration + 1 root CA config require.Len(t, *hostResp.Host.MDM.Profiles, 5) - // try to unenroll the host, fails since the host doesn't respond - s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/mdm", h.ID), nil, http.StatusGatewayTimeout) + // returns success, but this is effectively a no-op because the host isn't enrolled yet. + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/mdm", h.ID), nil, http.StatusOK) // we're going to modify this mock, make sure we restore its default originalPushMock := s.pushProvider.PushFunc @@ -2632,13 +2672,28 @@ func (s *integrationMDMTestSuite) TestFleetdConfiguration() { require.NoError(t, err) s.assertConfigProfilesByIdentifier(&tm.ID, mobileconfig.FleetdConfigPayloadIdentifier, false) + // upload an ABM token + s.enableABM(t.Name()) + // set the default bm assignment to that team acResp := appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(fmt.Sprintf(`{ "mdm": { - "apple_bm_default_team": %q + "apple_business_manager": [{ + "organization_name": %q, + "macos_team": %q, + "ios_team": %q, + "ipados_team": %q + }] } - }`, tm.Name)), http.StatusOK, &acResp) + }`, t.Name(), tm.Name, tm.Name, tm.Name)), http.StatusOK, &acResp) + t.Cleanup(func() { + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { + "apple_business_manager": [] + } + }`), http.StatusOK, &acResp) + }) // the team doesn't have any enroll secrets yet, a profile is created using the global enroll secret s.awaitTriggerProfileSchedule(t) @@ -3039,8 +3094,10 @@ func (s *integrationMDMTestSuite) TestBootstrapPackage() { func (s *integrationMDMTestSuite) TestBootstrapPackageStatus() { t := s.T() - // TODO: fixme - t.Skip() + + abmOrgName := "abm_org" + s.enableABM(abmOrgName) + pkg, err := os.ReadFile(filepath.Join("testdata", "bootstrap-packages", "signed.pkg")) require.NoError(t, err) @@ -3153,9 +3210,8 @@ func (s *integrationMDMTestSuite) TestBootstrapPackageStatus() { }) require.NoError(t, err) - ch := make(chan bool) mockRespDevices := noTeamDevices - s.mockDEPResponse(defaultOrgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.mockDEPResponse(abmOrgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) encoder := json.NewEncoder(w) switch r.URL.Path { @@ -3176,7 +3232,6 @@ func (s *integrationMDMTestSuite) TestBootstrapPackageStatus() { err := encoder.Encode(godep.DeviceResponse{Devices: depResp}) require.NoError(t, err) case "/profile/devices": - ch <- true _, _ = w.Write([]byte(`{}`)) default: _, _ = w.Write([]byte(`{}`)) @@ -3184,9 +3239,7 @@ func (s *integrationMDMTestSuite) TestBootstrapPackageStatus() { })) // trigger a dep sync - _, err = s.depSchedule.Trigger() - require.NoError(t, err) - <-ch + s.runDEPSchedule() var summaryResp getMDMAppleBootstrapPackageSummaryResponse s.DoJSON("GET", "/api/latest/fleet/bootstrap/summary", nil, http.StatusOK, &summaryResp) @@ -3200,15 +3253,25 @@ func (s *integrationMDMTestSuite) TestBootstrapPackageStatus() { acResp := appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(fmt.Sprintf(`{ "mdm": { - "apple_bm_default_team": %q + "apple_business_manager": [{ + "organization_name": %q, + "macos_team": %q, + "ios_team": %q, + "ipados_team": %q + }] } - }`, team.Name)), http.StatusOK, &acResp) + }`, abmOrgName, team.Name, team.Name, team.Name)), http.StatusOK, &acResp) + t.Cleanup(func() { + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { + "apple_business_manager": [] + } + }`), http.StatusOK, &acResp) + }) // trigger a dep sync mockRespDevices = teamDevices - _, err = s.depSchedule.Trigger() - require.NoError(t, err) - <-ch + s.runDEPSchedule() summaryResp = getMDMAppleBootstrapPackageSummaryResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/bootstrap/summary?team_id=%d", team.ID), nil, http.StatusOK, &summaryResp) @@ -3423,8 +3486,6 @@ func (s *integrationMDMTestSuite) TestEULA() { func (s *integrationMDMTestSuite) TestMigrateMDMDeviceWebhook() { t := s.T() - // FIXME - t.Skip() h := createHostAndDeviceToken(t, s.ds, "good-token") @@ -3495,8 +3556,10 @@ func (s *integrationMDMTestSuite) TestMigrateMDMDeviceWebhook() { s.Do("POST", fmt.Sprintf("/api/v1/fleet/device/%s/migrate_mdm", "good-token"), nil, http.StatusBadRequest) require.False(t, webhookCalled) + s.enableABM(t.Name()) + // simulate that the device is assigned to Fleet in ABM - s.mockDEPResponse(defaultOrgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) switch r.URL.Path { case "/session": @@ -3601,8 +3664,6 @@ func (s *integrationMDMTestSuite) TestMigrateMDMDeviceWebhook() { func (s *integrationMDMTestSuite) TestMigrateMDMDeviceWebhookErrors() { t := s.T() - // FIXME - t.Skip() h := createHostAndDeviceToken(t, s.ds, "good-token") @@ -3636,8 +3697,9 @@ func (s *integrationMDMTestSuite) TestMigrateMDMDeviceWebhookErrors() { s.Do("POST", fmt.Sprintf("/api/v1/fleet/device/%s/migrate_mdm", "good-token"), nil, http.StatusBadRequest) require.False(t, webhookCalled) + s.enableABM(t.Name()) // simulate that the device is assigned to Fleet in ABM - s.mockDEPResponse(defaultOrgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) switch r.URL.Path { case "/session": @@ -3686,7 +3748,7 @@ func (s *integrationMDMTestSuite) TestMigrateMDMDeviceWebhookErrors() { func (s *integrationMDMTestSuite) TestMDMMacOSSetup() { t := s.T() - s.mockDEPResponse(defaultOrgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) encoder := json.NewEncoder(w) switch r.URL.Path { @@ -4426,7 +4488,9 @@ func (s *integrationMDMTestSuite) assertWindowsConfigProfilesByName(teamID *uint } var cfgProfs []*fleet.MDMWindowsConfigProfile mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { - return sqlx.SelectContext(context.Background(), q, &cfgProfs, `SELECT * FROM mdm_windows_configuration_profiles WHERE team_id = ?`, teamID) + return sqlx.SelectContext(context.Background(), q, &cfgProfs, + `SELECT profile_uuid, team_id, name, syncml, created_at, uploaded_at FROM mdm_windows_configuration_profiles WHERE team_id = ?`, + teamID) }) label := "exist" @@ -4752,14 +4816,13 @@ func (s *integrationMDMTestSuite) setTokenForTest(t *testing.T, email, password func (s *integrationMDMTestSuite) TestSSO() { t := s.T() - // FIXME - t.Skip() mdmDevice := mdmtest.NewTestMDMClientAppleDirect(mdmtest.AppleEnrollInfo{ SCEPChallenge: s.scepChallenge, }, "MacBookPro16,1") + s.enableABM(t.Name()) var lastSubmittedProfile *godep.Profile - s.mockDEPResponse(defaultOrgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) switch r.URL.Path { case "/session": @@ -5289,8 +5352,6 @@ func (s *integrationMDMTestSuite) verifyEnrollmentProfile(rawProfile []byte, enr func (s *integrationMDMTestSuite) TestMDMMigration() { t := s.T() - // FIXME - t.Skip() ctx := context.Background() // enable migration @@ -5299,6 +5360,8 @@ func (s *integrationMDMTestSuite) TestMDMMigration() { "mdm": { "macos_migration": { "enable": true, "mode": "voluntary", "webhook_url": "https://example.com" } } }`), http.StatusOK, &acResp) + s.enableABM(t.Name()) + checkMigrationResponses := func(host *fleet.Host, token string) { getDesktopResp := fleetDesktopResponse{} res := s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/desktop", nil, http.StatusOK) @@ -5321,7 +5384,7 @@ func (s *integrationMDMTestSuite) TestMDMMigration() { // simulate that the device is assigned to Fleet in ABM profileAssignmentStatusResponse := fleet.DEPAssignProfileResponseSuccess - s.mockDEPResponse(defaultOrgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) switch r.URL.Path { case "/session": @@ -7901,11 +7964,10 @@ func (s *integrationMDMTestSuite) runWorker() { } func (s *integrationMDMTestSuite) runDEPSchedule() { - ch := make(chan bool) - s.onDEPScheduleDone = func() { close(ch) } - _, err := s.depSchedule.Trigger() + ctx := context.Background() + fleetSyncer := apple_mdm.NewDEPService(s.ds, s.depStorage, s.logger) + err := fleetSyncer.RunAssigner(ctx) require.NoError(s.T(), err) - <-ch } func (s *integrationMDMTestSuite) runIntegrationsSchedule() { @@ -8638,20 +8700,38 @@ func (s *integrationMDMTestSuite) TestLockUnlockWipeMacOS() { s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", host.ID), nil, http.StatusNoContent) } -func (s *integrationMDMTestSuite) TestZCustomConfigurationWebURL() { +func (s *integrationMDMTestSuite) TestCustomConfigurationWebURL() { t := s.T() - // FIXME - t.Skip() acResp := appConfigResponse{} s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) + s.enableABM(t.Name()) var lastSubmittedProfile *godep.Profile - s.mockDEPResponse(defaultOrgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) encoder := json.NewEncoder(w) switch r.URL.Path { + case "/server/devices", "/devices/sync": + encoder := json.NewEncoder(w) + err := encoder.Encode(godep.DeviceResponse{ + Devices: []godep.Device{ + { + SerialNumber: "FAKE-1", + Model: "Mac Mini", + OS: "osx", + OpType: "added", + }, + { + SerialNumber: "FAKE-2", + Model: "Mac Mini", + OS: "osx", + OpType: "added", + }, + }, + }) + require.NoError(t, err) case "/profile": lastSubmittedProfile = &godep.Profile{} rawProfile, err := io.ReadAll(r.Body) @@ -8670,6 +8750,9 @@ func (s *integrationMDMTestSuite) TestZCustomConfigurationWebURL() { } })) + // run once to ingest the devices + s.runDEPSchedule() + // disable first to make sure we start in the desired state acResp = appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ @@ -8698,6 +8781,7 @@ func (s *integrationMDMTestSuite) TestZCustomConfigurationWebURL() { // assign the DEP profile and assert that contains the right values for the URL s.runWorker() + require.NotNil(t, lastSubmittedProfile) require.Contains(t, lastSubmittedProfile.ConfigurationWebURL, acResp.ServerSettings.ServerURL+"/mdm/sso") // trying to set a custom configuration_web_url fails because end user authentication is enabled @@ -8727,6 +8811,7 @@ func (s *integrationMDMTestSuite) TestZCustomConfigurationWebURL() { // assign the DEP profile and assert that contains the right values for the URL s.runWorker() + require.NotNil(t, lastSubmittedProfile) require.Contains(t, lastSubmittedProfile.ConfigurationWebURL, acResp.ServerSettings.ServerURL+"/api/mdm/apple/enroll?token=") // setting a custom configuration_web_url succeeds because user authentication is disabled @@ -8739,6 +8824,7 @@ func (s *integrationMDMTestSuite) TestZCustomConfigurationWebURL() { // assign the DEP profile and assert that contains the right values for the URL s.runWorker() + require.NotNil(t, lastSubmittedProfile) require.Contains(t, lastSubmittedProfile.ConfigurationWebURL, "https://foo.example.com") // try to enable end user auth again, it fails because configuration_web_url is set @@ -8775,8 +8861,13 @@ func (s *integrationMDMTestSuite) TestZCustomConfigurationWebURL() { require.Len(t, applyResp.TeamIDsByName, 1) teamID := applyResp.TeamIDsByName[t.Name()] + // transfer a host to the team to ensure all ABM calls are made + h, err := s.ds.HostByIdentifier(context.Background(), "FAKE-1") + require.NoError(t, err) + s.DoJSON("POST", "/api/latest/fleet/hosts/transfer", addHostsToTeamRequest{TeamID: &teamID, HostIDs: []uint{h.ID}}, http.StatusOK, &addHostsToTeamResponse{}) + // re-set the global state to configure MDM SSO - err := s.ds.DeleteMDMAppleSetupAssistant(context.Background(), nil) + err = s.ds.DeleteMDMAppleSetupAssistant(context.Background(), nil) require.NoError(t, err) acResp = appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ @@ -9148,12 +9239,9 @@ func (s *integrationMDMTestSuite) TestRemoveFailedProfiles() { func (s *integrationMDMTestSuite) TestABMAssetManagement() { t := s.T() - // FIXME - t.Skip() ctx := context.Background() - // ensure enable ABM again for other tests - t.Cleanup(s.enableABM) + s.enableABM(t.Name()) // Validate error when server private key not set testSetEmptyPrivateKey = true @@ -9169,21 +9257,21 @@ func (s *integrationMDMTestSuite) TestABMAssetManagement() { require.Nil(t, abmResp.Err) require.NotEmpty(t, abmResp.PublicKey) + var tokensResp listABMTokensResponse + s.DoJSON("GET", "/api/latest/fleet/abm_tokens", nil, http.StatusOK, &tokensResp) + tok := s.getABMTokenByName(t.Name(), tokensResp.Tokens) + // disable ABM - s.Do("DELETE", "/api/latest/fleet/abm_tokens", nil, http.StatusNoContent) - assets, err := s.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{ - fleet.MDMAssetABMCert, - fleet.MDMAssetABMKey, - fleet.MDMAssetABMTokenDeprecated, - }) + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/abm_tokens/%d", tok.ID), nil, http.StatusNoContent) + tok, err := s.ds.GetABMTokenByOrgName(ctx, t.Name()) var nfe fleet.NotFoundError require.ErrorAs(t, err, &nfe) - require.Nil(t, assets) + require.Nil(t, tok) - // try to upload a token without a keypair - s.uploadABMToken([]byte("foo"), http.StatusBadRequest, "Please generate a keypair first.") + // try to upload an invalid token + s.uploadABMToken([]byte("foo"), http.StatusBadRequest, "Please provide a valid token from Apple Business Manager") - // enable ABM again, creates a new keypair because the previous one was deleted + // enable ABM again var newABMResp generateABMKeyPairResponse s.DoJSON("GET", "/api/latest/fleet/mdm/apple/abm_public_key", nil, http.StatusOK, &newABMResp) require.Nil(t, newABMResp.Err) @@ -9191,9 +9279,8 @@ func (s *integrationMDMTestSuite) TestABMAssetManagement() { block, _ := pem.Decode(newABMResp.PublicKey) require.NotNil(t, block) require.Equal(t, "CERTIFICATE", block.Type) - require.NotEqual(t, abmResp.PublicKey, newABMResp.PublicKey) - // as long as the certs are not deleted, we should return the same values to support renewing the token + // we should always return the same values to support renewing the token var renewABMResp generateABMKeyPairResponse s.DoJSON("GET", "/api/latest/fleet/mdm/apple/abm_public_key", nil, http.StatusOK, &renewABMResp) require.Nil(t, renewABMResp.Err) @@ -9201,10 +9288,10 @@ func (s *integrationMDMTestSuite) TestABMAssetManagement() { require.Equal(t, renewABMResp.PublicKey, newABMResp.PublicKey) // simulate a renew flow - s.enableABM() + s.enableABM(t.Name()) } -func (s *integrationMDMTestSuite) enableABM() { +func (s *integrationMDMTestSuite) enableABM(orgName string) { t := s.T() var abmResp generateABMKeyPairResponse s.DoJSON("GET", "/api/latest/fleet/mdm/apple/abm_public_key", nil, http.StatusOK, &abmResp) @@ -9247,7 +9334,7 @@ func (s *integrationMDMTestSuite) enableABM() { case "/session": _, _ = w.Write([]byte(`{"auth_session_token": "xyz"}`)) case "/account": - _, _ = w.Write([]byte(fmt.Sprintf(`{"admin_id": "abc", "org_name": %q}`, defaultOrgName))) + _, _ = w.Write([]byte(fmt.Sprintf(`{"admin_id": "abc", "org_name": %q}`, orgName))) } })) @@ -9270,9 +9357,26 @@ func (s *integrationMDMTestSuite) enableABM() { require.Len(t, assets, 2) require.Equal(t, abmResp.PublicKey, assets[fleet.MDMAssetABMCert].Value) - tok, err := s.ds.GetABMTokenByOrgName(ctx, defaultOrgName) + tok, err := s.ds.GetABMTokenByOrgName(ctx, orgName) + require.NoError(t, err) + require.Equal(t, orgName, tok.OrganizationName) + + // do a dummy call so the nanodep client updates the org name in + // nano_dep_names, and leave the mock set with a dummy response + s.mockDEPResponse(orgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + switch r.URL.Path { + case "/session": + _, _ = w.Write([]byte(`{"auth_session_token": "xyz"}`)) + case "/account": + _, _ = w.Write([]byte(fmt.Sprintf(`{"admin_id": "abc", "org_name": %q}`, orgName))) + default: + _, _ = w.Write([]byte(`{}`)) + } + })) + depClient := apple_mdm.NewDEPClient(s.depStorage, s.ds, s.logger) + _, err = depClient.AccountDetail(ctx, orgName) require.NoError(t, err) - require.Equal(t, defaultOrgName, tok.OrganizationName) } func (s *integrationMDMTestSuite) appleCoreCertsSetup() { @@ -9370,8 +9474,6 @@ func (s *integrationMDMTestSuite) uploadABMToken(encryptedToken []byte, expected func (s *integrationMDMTestSuite) TestSilentMigrationGotchas() { t := s.T() ctx := context.Background() - // FIXME - t.Skip() host := createOrbitEnrolledHost(t, "darwin", t.Name(), s.ds) // set the host as enrolled in a third-party MDM @@ -9385,7 +9487,8 @@ func (s *integrationMDMTestSuite) TestSilentMigrationGotchas() { require.False(t, *hostResp.Host.MDM.ConnectedToFleet) // simulate that the device is assigned to Fleet in ABM - s.mockDEPResponse(defaultOrgName, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.enableABM(t.Name()) + s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) switch r.URL.Path { case "/session": @@ -9686,8 +9789,8 @@ func (s *integrationMDMTestSuite) TestBatchAssociateAppStoreApps() { }) require.NoError(t, err) - // No vpp token set, no association - s.Do("POST", batchURL, batchAssociateAppStoreAppsRequest{}, http.StatusNoContent, "team_name", tmGood.Name) + // No vpp token set, no association. + s.Do("POST", batchURL, batchAssociateAppStoreAppsRequest{}, http.StatusUnprocessableEntity, "team_name", tmGood.Name) // No vpp token set, try association // FIXME @@ -9733,6 +9836,26 @@ func (s *integrationMDMTestSuite) TestBatchAssociateAppStoreApps() { // Remove all vpp associations from team with no members s.Do("POST", batchURL, batchAssociateAppStoreAppsRequest{}, http.StatusNoContent, "team_name", tmGood.Name) + // Incorrect type check + incorrectTypes := struct { + Apps []struct { + AppStoreID int `json:"app_store_id"` + SelfService bool `json:"self_service"` + } `json:"app_store_apps"` + }{ + Apps: []struct { + AppStoreID int `json:"app_store_id"` + SelfService bool `json:"self_service"` + }{ + { + AppStoreID: 1, + }, + }, + } + badTypeReq := s.Do("POST", batchURL, incorrectTypes, http.StatusBadRequest, "team_name", tmGood.Name) + badTypeBody := extractServerErrorText(badTypeReq.Body) + assert.Contains(t, badTypeBody, "must be a string") + // Associating an app we don't own s.Do("POST", batchURL, batchAssociateAppStoreAppsRequest{Apps: []fleet.VPPBatchPayload{{AppStoreID: "fake-app"}}}, http.StatusUnprocessableEntity, "team_name", tmGood.Name) @@ -10116,8 +10239,6 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() { func (s *integrationMDMTestSuite) TestVPPApps() { t := s.T() - // FIXME - t.Skip() // Invalid token t.Setenv("FLEET_DEV_VPP_URL", s.appleVPPConfigSrv.URL+"?invalidToken") s.uploadDataViaForm("/api/latest/fleet/vpp_tokens", "token", "token.vpptoken", []byte("foobar"), http.StatusUnprocessableEntity, "Invalid token. Please provide a valid content token from Apple Business Manager.", nil) @@ -10412,26 +10533,51 @@ func (s *integrationMDMTestSuite) TestVPPApps() { s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", orbitHost.ID, macOSTitleID), &installSoftwareRequest{}, http.StatusBadRequest, &installResp) + // Disable all teams token + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", validToken.Token.ID), patchVPPTokensTeamsRequest{}, http.StatusOK, &resPatchVPP) + // Spoof an expired VPP token and attempt to install VPP app - tokenJSONBad := fmt.Sprintf(`{"expDate":"%s","token":"%s","orgName":"%s"}`, "2020-06-24T15:50:50+0000", "badtoken", "Evil Fleet") + tokenJSONBad := fmt.Sprintf(`{"expDate":"%s","token":"%s","orgName":"%s"}`, "2099-06-24T15:50:50+0000", "badtoken", "Evil Fleet") s.appleVPPConfigSrvConfig.Location = "Spooky Haunted House" var vppRes uploadVPPTokenResponse s.uploadDataViaForm("/api/latest/fleet/vpp_tokens", "token", "token.vpptoken", []byte(base64.StdEncoding.EncodeToString([]byte(tokenJSONBad))), http.StatusAccepted, "", &vppRes) - s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", vppRes.Token.ID), patchVPPTokensTeamsRequest{TeamIDs: []uint{team.ID, 99}}, http.StatusBadRequest, &resPatchVPP) + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", vppRes.Token.ID), patchVPPTokensTeamsRequest{TeamIDs: []uint{team.ID, 99}}, http.StatusUnprocessableEntity, &resPatchVPP) s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", vppRes.Token.ID), patchVPPTokensTeamsRequest{TeamIDs: []uint{team.ID}}, http.StatusOK, &resPatchVPP) - r := s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", mdmHost.ID, errTitleID), &installSoftwareRequest{}, http.StatusUnprocessableEntity) - require.Contains(t, extractServerErrorText(r.Body), "VPP token expired") + // mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + // _, err := q.ExecContext(context.Background(), "UPDATE vpp_tokens SET renew_at = ? WHERE organization_name = ?", time.Now().Add(-1*time.Hour), "badtoken") + // return err + // }) + + // r := s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", mdmHost.ID, errTitleID), &installSoftwareRequest{}, http.StatusUnprocessableEntity) + // require.Contains(t, extractServerErrorText(r.Body), "VPP token expired") // Disable the token s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", vppRes.Token.ID), patchVPPTokensTeamsRequest{}, http.StatusOK, &resPatchVPP) + // Enable all teams token + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/vpp_tokens/%d/teams", validToken.Token.ID), patchVPPTokensTeamsRequest{TeamIDs: []uint{}}, http.StatusOK, &resPatchVPP) + // Attempt to install non-existent app - r = s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", mdmHost.ID, 99999), &installSoftwareRequest{}, http.StatusBadRequest) + r := s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", mdmHost.ID, 99999), &installSoftwareRequest{}, http.StatusBadRequest) require.Contains(t, extractServerErrorText(r.Body), "Couldn't install software. Software title is not available for install. Please add software package or App Store app to install.") + // Add app 1 as self-service + addAppResp = addAppStoreAppResponse{} + s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", + &addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: errApp.AdamID, Platform: errApp.Platform, SelfService: true}, + http.StatusOK, &addAppResp) + + // Add remaining apps without self-service + for _, app := range expectedApps { + addAppResp = addAppStoreAppResponse{} + s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", + &addAppStoreAppRequest{TeamID: &team.ID, AppStoreID: app.AdamID, Platform: app.Platform, SelfService: app.AdamID == macOSApp.AdamID}, + http.StatusOK, &addAppResp) + } + // Trigger install to the host installResp = installSoftwareResponse{} s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", mdmHost.ID, errTitleID), &installSoftwareRequest{}, http.StatusAccepted, &installResp) @@ -10588,7 +10734,7 @@ func (s *integrationMDMTestSuite) TestVPPApps() { Results: map[string]json.RawMessage{ hostDetailQueryPrefix + "software_macos": json.RawMessage(fmt.Sprintf( `[{"name": "%s", "version": "%s", "type": "Application (macOS)", - "bundle_identifier": "%s", "source": "apps", "last_opened_at": "", + "bundle_identifier": "%s", "source": "apps", "last_opened_at": "", "installed_path": "/Applications/a.app"}]`, addedApp.Name, addedApp.LatestVersion, addedApp.BundleIdentifier)), }, Statuses: map[string]interface{}{ diff --git a/server/service/mdm.go b/server/service/mdm.go index 1b9e28c457ce..294d503d81ef 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -30,7 +30,6 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mdm" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" - "github.com/fleetdm/fleet/v4/server/mdm/apple/vpp" "github.com/fleetdm/fleet/v4/server/mdm/assets" nanomdm "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" "github.com/fleetdm/fleet/v4/server/ptr" @@ -567,13 +566,14 @@ func (svc *Service) enqueueAppleMDMCommand(ctx context.Context, rawXMLCmd []byte var apnsErr *apple_mdm.APNSDeliveryError var mysqlErr *mysql.MySQLError if errors.As(err, &apnsErr) { - if len(apnsErr.FailedUUIDs) < len(deviceIDs) { + failedUUIDs := apnsErr.FailedUUIDs() + if len(failedUUIDs) < len(deviceIDs) { // some hosts properly received the command, so return success, with the list // of failed uuids. return &fleet.CommandEnqueueResult{ CommandUUID: cmd.CommandUUID, RequestType: cmd.Command.RequestType, - FailedUUIDs: apnsErr.FailedUUIDs, + FailedUUIDs: failedUUIDs, }, nil } // push failed for all hosts @@ -1167,7 +1167,7 @@ func (svc *Service) DeleteMDMWindowsConfigProfile(ctx context.Context, profileUU } // cannot use the profile ID as it is now deleted - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{teamID}, nil, nil); err != nil { + if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{teamID}, nil, nil); err != nil { return ctxerr.Wrap(ctx, err, "bulk set pending host profiles") } @@ -1412,7 +1412,7 @@ func (svc *Service) NewMDMWindowsConfigProfile(ctx context.Context, teamID uint, return nil, ctxerr.Wrap(ctx, err) } - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{newCP.ProfileUUID}, nil); err != nil { + if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{newCP.ProfileUUID}, nil); err != nil { return nil, ctxerr.Wrap(ctx, err, "bulk set pending host profiles") } @@ -1600,7 +1600,8 @@ func (svc *Service) BatchSetMDMProfiles( return nil } - if err := svc.ds.BatchSetMDMProfiles(ctx, tmID, appleProfiles, windowsProfiles, appleDecls); err != nil { + var profUpdates fleet.MDMProfilesUpdates + if profUpdates, err = svc.ds.BatchSetMDMProfiles(ctx, tmID, appleProfiles, windowsProfiles, appleDecls); err != nil { return ctxerr.Wrap(ctx, err, "setting config profiles") } @@ -1609,7 +1610,8 @@ func (svc *Service) BatchSetMDMProfiles( for _, p := range windowsProfiles { winProfUUIDs = append(winProfUUIDs, p.ProfileUUID) } - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, winProfUUIDs, nil); err != nil { + winUpdates, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, winProfUUIDs, nil) + if err != nil { return ctxerr.Wrap(ctx, err, "bulk set pending windows host profiles") } @@ -1618,33 +1620,42 @@ func (svc *Service) BatchSetMDMProfiles( for _, p := range appleProfiles { appleProfUUIDs = append(appleProfUUIDs, p.ProfileUUID) } - if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, appleProfUUIDs, nil); err != nil { + appleUpdates, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, appleProfUUIDs, nil) + if err != nil { return ctxerr.Wrap(ctx, err, "bulk set pending apple host profiles") } + updates := fleet.MDMProfilesUpdates{ + AppleConfigProfile: profUpdates.AppleConfigProfile || winUpdates.AppleConfigProfile || appleUpdates.AppleConfigProfile, + WindowsConfigProfile: profUpdates.WindowsConfigProfile || winUpdates.WindowsConfigProfile || appleUpdates.WindowsConfigProfile, + AppleDeclaration: profUpdates.AppleDeclaration || winUpdates.AppleDeclaration || appleUpdates.AppleDeclaration, + } - // TODO(roberto): should we generate activities only of any profiles were - // changed? this is the existing behavior for macOS profiles so I'm - // leaving it as-is for now. - if err := svc.NewActivity( - ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeEditedMacosProfile{ - TeamID: tmID, - TeamName: tmName, - }); err != nil { - return ctxerr.Wrap(ctx, err, "logging activity for edited macos profile") + if updates.AppleConfigProfile { + if err := svc.NewActivity( + ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeEditedMacosProfile{ + TeamID: tmID, + TeamName: tmName, + }); err != nil { + return ctxerr.Wrap(ctx, err, "logging activity for edited macos profile") + } } - if err := svc.NewActivity( - ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeEditedWindowsProfile{ - TeamID: tmID, - TeamName: tmName, - }); err != nil { - return ctxerr.Wrap(ctx, err, "logging activity for edited windows profile") + if updates.WindowsConfigProfile { + if err := svc.NewActivity( + ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeEditedWindowsProfile{ + TeamID: tmID, + TeamName: tmName, + }); err != nil { + return ctxerr.Wrap(ctx, err, "logging activity for edited windows profile") + } } - if err := svc.NewActivity( - ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeEditedDeclarationProfile{ - TeamID: tmID, - TeamName: tmName, - }); err != nil { - return ctxerr.Wrap(ctx, err, "logging activity for edited macos declarations") + if updates.AppleDeclaration { + if err := svc.NewActivity( + ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeEditedDeclarationProfile{ + TeamID: tmID, + TeamName: tmName, + }); err != nil { + return ctxerr.Wrap(ctx, err, "logging activity for edited macos declarations") + } } return nil @@ -2531,335 +2542,3 @@ func (svc *Service) DeleteMDMAppleAPNSCert(ctx context.Context) error { return svc.ds.SaveAppConfig(ctx, appCfg) } - -//////////////////////////////////////////////////////////////////////////////// -// POST /api/_version_/vpp_tokens -//////////////////////////////////////////////////////////////////////////////// - -type uploadVPPTokenRequest struct { - File *multipart.FileHeader -} - -func (uploadVPPTokenRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) { - decoded := uploadVPPTokenRequest{} - - err := r.ParseMultipartForm(512 * units.MiB) - if err != nil { - return nil, &fleet.BadRequestError{ - Message: "failed to parse multipart form", - InternalErr: err, - } - } - - if r.MultipartForm.File["token"] == nil || len(r.MultipartForm.File["token"]) == 0 { - return nil, &fleet.BadRequestError{ - Message: "token multipart field is required", - InternalErr: err, - } - } - - decoded.File = r.MultipartForm.File["token"][0] - - return &decoded, nil -} - -type uploadVPPTokenResponse struct { - Err error `json:"error,omitempty"` - Token *fleet.VPPTokenDB `json:"token,omitempty"` -} - -func (r uploadVPPTokenResponse) Status() int { return http.StatusAccepted } - -func (r uploadVPPTokenResponse) error() error { - return r.Err -} - -func uploadVPPTokenEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { - req := request.(*uploadVPPTokenRequest) - file, err := req.File.Open() - if err != nil { - return uploadVPPTokenResponse{Err: err}, nil - } - defer file.Close() - - tok, err := svc.UploadVPPToken(ctx, file) - if err != nil { - return uploadVPPTokenResponse{Err: err}, nil - } - - return uploadVPPTokenResponse{Token: tok}, nil -} - -func (svc *Service) UploadVPPToken(ctx context.Context, token io.ReadSeeker) (*fleet.VPPTokenDB, error) { - if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionWrite); err != nil { - return nil, err - } - - privateKey := svc.config.Server.PrivateKey - if testSetEmptyPrivateKey { - privateKey = "" - } - - if len(privateKey) == 0 { - return nil, ctxerr.New(ctx, "Couldn't upload content token. Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key") - } - - if token == nil { - return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("token", "Invalid token. Please provide a valid content token from Apple Business Manager.")) - } - - tokenBytes, err := io.ReadAll(token) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "reading VPP token") - } - - locName, err := vpp.GetConfig(string(tokenBytes)) - if err != nil { - var vppErr *vpp.ErrorResponse - if errors.As(err, &vppErr) { - // Per https://developer.apple.com/documentation/devicemanagement/app_and_book_management/app_and_book_management_legacy/interpreting_error_codes - if vppErr.ErrorNumber == 9622 { - return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("token", "Invalid token. Please provide a valid content token from Apple Business Manager.")) - } - } - return nil, ctxerr.Wrap(ctx, err, "validating VPP token with Apple") - } - - data := fleet.VPPTokenData{ - Token: string(tokenBytes), - Location: locName, - } - - tok, err := svc.ds.InsertVPPToken(ctx, &data) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "writing VPP token to db") - } - - if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityEnabledVPP{ - Location: locName, - }); err != nil { - return nil, ctxerr.Wrap(ctx, err, "create activity for upload VPP token") - } - - return tok, nil -} - -//////////////////////////////////////////////////// -// PATCH /api/_version_/fleet/vpp_tokens/%d/renew // -//////////////////////////////////////////////////// - -type patchVPPTokenRenewRequest struct { - ID uint `url:"id"` - File *multipart.FileHeader -} - -func (patchVPPTokenRenewRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) { - decoded := patchVPPTokenRenewRequest{} - - err := r.ParseMultipartForm(512 * units.MiB) - if err != nil { - return nil, &fleet.BadRequestError{ - Message: "failed to parse multipart form", - InternalErr: err, - } - } - - if r.MultipartForm.File["token"] == nil || len(r.MultipartForm.File["token"]) == 0 { - return nil, &fleet.BadRequestError{ - Message: "token multipart field is required", - InternalErr: err, - } - } - - decoded.File = r.MultipartForm.File["token"][0] - - return &decoded, nil -} - -type patchVPPTokenRenewResponse struct { - Err error `json:"error,omitempty"` - Token *fleet.VPPTokenDB `json:"token,omitempty"` -} - -func (r patchVPPTokenRenewResponse) Status() int { return http.StatusAccepted } - -func (r patchVPPTokenRenewResponse) error() error { - return r.Err -} - -func patchVPPTokenRenewEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { - req := request.(*patchVPPTokenRenewRequest) - file, err := req.File.Open() - if err != nil { - return patchVPPTokenRenewResponse{Err: err}, nil - } - defer file.Close() - - tok, err := svc.UpdateVPPToken(ctx, req.ID, file) - if err != nil { - return patchVPPTokenRenewResponse{Err: err}, nil - } - - return patchVPPTokenRenewResponse{Token: tok}, nil -} - -func (svc *Service) UpdateVPPToken(ctx context.Context, tokenID uint, token io.ReadSeeker) (*fleet.VPPTokenDB, error) { - if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionWrite); err != nil { - return nil, err - } - - privateKey := svc.config.Server.PrivateKey - if testSetEmptyPrivateKey { - privateKey = "" - } - - if len(privateKey) == 0 { - return nil, ctxerr.New(ctx, "Couldn't upload content token. Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key") - } - - if token == nil { - return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("token", "Invalid token. Please provide a valid content token from Apple Business Manager.")) - } - - tokenBytes, err := io.ReadAll(token) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "reading VPP token") - } - - locName, err := vpp.GetConfig(string(tokenBytes)) - if err != nil { - var vppErr *vpp.ErrorResponse - if errors.As(err, &vppErr) { - // Per https://developer.apple.com/documentation/devicemanagement/app_and_book_management/app_and_book_management_legacy/interpreting_error_codes - if vppErr.ErrorNumber == 9622 { - return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("token", "Invalid token. Please provide a valid content token from Apple Business Manager.")) - } - } - return nil, ctxerr.Wrap(ctx, err, "validating VPP token with Apple") - } - - data := fleet.VPPTokenData{ - Token: string(tokenBytes), - Location: locName, - } - - tok, err := svc.ds.UpdateVPPToken(ctx, tokenID, &data) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "updating vpp token") - } - - return tok, nil -} - -//////////////////////////////////////////////////// -// PATCH /api/_version_/fleet/vpp_tokens/%d/teams // -//////////////////////////////////////////////////// - -type patchVPPTokensTeamsRequest struct { - ID uint `url:"id"` - TeamIDs []uint `json:"teams"` -} - -type patchVPPTokensTeamsResponse struct { - Token *fleet.VPPTokenDB `json:"token,omitempty"` - Err error `json:"error,omitempty"` -} - -func (r patchVPPTokensTeamsResponse) error() error { return r.Err } - -func patchVPPTokensTeams(ctx context.Context, request any, svc fleet.Service) (errorer, error) { - req := request.(*patchVPPTokensTeamsRequest) - - tok, err := svc.UpdateVPPTokenTeams(ctx, req.ID, req.TeamIDs) - if err != nil { - return patchVPPTokensTeamsResponse{Err: err}, nil - } - return patchVPPTokensTeamsResponse{Token: tok}, nil -} - -func (svc *Service) UpdateVPPTokenTeams(ctx context.Context, tokenID uint, teamIDs []uint) (*fleet.VPPTokenDB, error) { - if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionWrite); err != nil { - return nil, err - } - - tok, err := svc.ds.UpdateVPPTokenTeams(ctx, tokenID, teamIDs) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "updating vpp token team") - } - - return tok, nil -} - -/////////////////////////////////////////////// -// DELETE /api/_version_/fleet/vpp_tokens/%d // -/////////////////////////////////////////////// - -type getVPPTokensRequest struct{} - -type getVPPTokensResponse struct { - Tokens []*fleet.VPPTokenDB `json:"vpp_tokens"` - Err error `json:"error,omitempty"` -} - -func (r getVPPTokensResponse) error() error { return r.Err } - -func getVPPTokens(ctx context.Context, request any, svc fleet.Service) (errorer, error) { - tokens, err := svc.GetVPPTokens(ctx) - if err != nil { - return getVPPTokensResponse{Err: err}, nil - } - - if tokens == nil { - tokens = []*fleet.VPPTokenDB{} - } - - return getVPPTokensResponse{Tokens: tokens}, nil -} - -func (svc *Service) GetVPPTokens(ctx context.Context) ([]*fleet.VPPTokenDB, error) { - if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionRead); err != nil { - return nil, err - } - - return svc.ds.ListVPPTokens(ctx) -} - -type deleteVPPTokenRequest struct { - ID uint `url:"id"` -} - -type deleteVPPTokenResponse struct { - Err error `json:"error,omitempty"` -} - -func (r deleteVPPTokenResponse) error() error { return r.Err } - -func (r deleteVPPTokenResponse) Status() int { return http.StatusNoContent } - -func deleteVPPToken(ctx context.Context, request any, svc fleet.Service) (errorer, error) { - req := request.(*deleteVPPTokenRequest) - - err := svc.DeleteVPPToken(ctx, req.ID) - if err != nil { - return deleteVPPTokenResponse{Err: err}, nil - } - - return deleteVPPTokenResponse{}, nil -} - -func (svc *Service) DeleteVPPToken(ctx context.Context, tokenID uint) error { - if err := svc.authz.Authorize(ctx, &fleet.AppleCSR{}, fleet.ActionWrite); err != nil { - return err - } - tok, err := svc.ds.GetVPPToken(ctx, tokenID) - if err != nil { - return ctxerr.Wrap(ctx, err, "getting vpp token") - } - if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityDisabledVPP{ - Location: tok.Location, - }); err != nil { - return ctxerr.Wrap(ctx, err, "create activity for delete VPP token") - } - - return svc.ds.DeleteVPPToken(ctx, tokenID) -} diff --git a/server/service/mdm_test.go b/server/service/mdm_test.go index 435126d2b1e5..3a626af0277d 100644 --- a/server/service/mdm_test.go +++ b/server/service/mdm_test.go @@ -70,8 +70,6 @@ func TestGetMDMApple(t *testing.T) { } func TestMDMAppleAuthorization(t *testing.T) { - // FIXME - t.Skip() ds := new(mock.Store) license := &fleet.LicenseInfo{Tier: fleet.TierPremium} @@ -125,6 +123,16 @@ func TestMDMAppleAuthorization(t *testing.T) { return nil } + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return nil, nil + } + ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { + return nil, nil + } + ds.GetVPPTokenFunc = func(ctx context.Context, id uint) (*fleet.VPPTokenDB, error) { + return nil, ¬FoundErr{} + } + ds.DeleteMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) error { return nil } // use a custom implementation of checkAuthErr as the service call will fail @@ -1068,8 +1076,10 @@ func TestMDMWindowsConfigProfileAuthz(t *testing.T) { ds.ListMDMConfigProfilesFunc = func(ctx context.Context, teamID *uint, opt fleet.ListOptions) ([]*fleet.MDMConfigProfilePayload, *fleet.PaginationMetadata, error) { return nil, nil, nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, + hostUUIDs []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } checkShouldFail := func(t *testing.T, err error, shouldFail bool) { @@ -1142,8 +1152,10 @@ func TestUploadWindowsMDMConfigProfileValidations(t *testing.T) { cp.ProfileUUID = uuid.New().String() return &cp, nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, + hostUUIDs []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } cases := []struct { @@ -1225,16 +1237,20 @@ func TestMDMBatchSetProfiles(t *testing.T) { ds.TeamFunc = func(ctx context.Context, id uint) (*fleet.Team, error) { return &fleet.Team{ID: id, Name: "team"}, nil } - ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration) error { - return nil + ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, + winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.NewActivityFunc = func( ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time, ) error { return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, + hostUUIDs []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } testCases := []struct { diff --git a/server/service/orbit.go b/server/service/orbit.go index 394230c602dc..e894f8a1577a 100644 --- a/server/service/orbit.go +++ b/server/service/orbit.go @@ -998,11 +998,31 @@ func (svc *Service) SaveHostSoftwareInstallResult(ctx context.Context, result *f return ctxerr.Wrap(ctx, err, "get host software installation result information") } + // Self-Service packages will have a nil author for the activity. var user *fleet.User - if hsi.UserID != nil && !hsi.SelfService { - user, err = svc.ds.UserByID(ctx, *hsi.UserID) - if err != nil { - return ctxerr.Wrap(ctx, err, "get host software installation user") + if !hsi.SelfService { + if hsi.UserID != nil { + user, err = svc.ds.UserByID(ctx, *hsi.UserID) + if err != nil { + return ctxerr.Wrap(ctx, err, "get host software installation user") + } + } else { + // hsi.UserID can be nil if the user was deleted and/or if the installation was + // triggered by Fleet (policy automation). Thus we set the author of the installation + // to be the user that uploaded the package (by design). + var userID uint + if hsi.SoftwareInstallerUserID != nil { + userID = *hsi.SoftwareInstallerUserID + } + // If there's no name or email then this may be a package uploaded + // before we added authorship to uploaded packages. + if hsi.SoftwareInstallerUserName != "" && hsi.SoftwareInstallerUserEmail != "" { + user = &fleet.User{ + ID: userID, + Name: hsi.SoftwareInstallerUserName, + Email: hsi.SoftwareInstallerUserEmail, + } + } } } diff --git a/server/service/osquery.go b/server/service/osquery.go index c4112f7d0d90..689c1e776ea8 100644 --- a/server/service/osquery.go +++ b/server/service/osquery.go @@ -81,7 +81,7 @@ func (svc *Service) AuthenticateHost(ctx context.Context, nodeKey string) (*flee case err == nil: // OK case fleet.IsNotFound(err): - return nil, false, newOsqueryErrorWithInvalidNode("authentication error: invalid node key: " + nodeKey) + return nil, false, newOsqueryErrorWithInvalidNode("authentication error: invalid node key") default: return nil, false, newOsqueryError("authentication error: " + err.Error()) } @@ -1008,6 +1008,10 @@ func (svc *Service) SubmitDistributedQueryResults( logging.WithErr(ctx, err) } + if err := svc.processSoftwareForNewlyFailingPolicies(ctx, host.ID, host.TeamID, host.Platform, host.OrbitNodeKey, policyResults); err != nil { + logging.WithErr(ctx, err) + } + // filter policy results for webhooks var policyIDs []uint if globalPolicyAutomationsEnabled(ac.WebhookSettings, ac.Integrations) { @@ -1038,6 +1042,7 @@ func (svc *Service) SubmitDistributedQueryResults( }() } } + // NOTE(mna): currently, failing policies webhook wouldn't see the new // flipped policies on the next run if async processing is enabled and the // collection has not been done yet (not persisted in mysql). Should @@ -1606,6 +1611,141 @@ func (svc *Service) registerFlippedPolicies(ctx context.Context, hostID uint, ho return nil } +func (svc *Service) processSoftwareForNewlyFailingPolicies( + ctx context.Context, + hostID uint, + hostTeamID *uint, + hostPlatform string, + hostOrbitNodeKey *string, + incomingPolicyResults map[uint]*bool, +) error { + if hostOrbitNodeKey == nil || *hostOrbitNodeKey == "" { + // We do not want to queue software installations on vanilla osquery hosts. + return nil + } + if hostTeamID == nil { + // TODO(lucas): Support hosts in "No team". + return nil + } + + // Filter out results that are not failures (we are only interested on failing policies, + // we don't care about passing policies or policies that failed to execute). + incomingFailingPolicies := make(map[uint]*bool) + var incomingFailingPoliciesIDs []uint + for policyID, policyResult := range incomingPolicyResults { + if policyResult != nil && !*policyResult { + incomingFailingPolicies[policyID] = policyResult + incomingFailingPoliciesIDs = append(incomingFailingPoliciesIDs, policyID) + } + } + if len(incomingFailingPolicies) == 0 { + return nil + } + + // Get policies with associated installers for the team. + policiesWithInstaller, err := svc.ds.GetPoliciesWithAssociatedInstaller(ctx, *hostTeamID, incomingFailingPoliciesIDs) + if err != nil { + return ctxerr.Wrap(ctx, err, "failed to get policies with installer") + } + if len(policiesWithInstaller) == 0 { + return nil + } + + // Filter out results of policies that are not associated to installers. + policiesWithInstallersMap := make(map[uint]fleet.PolicySoftwareInstallerData) + for _, policyWithInstaller := range policiesWithInstaller { + policiesWithInstallersMap[policyWithInstaller.ID] = policyWithInstaller + } + policyResultsOfPoliciesWithInstallers := make(map[uint]*bool) + for policyID, passes := range incomingFailingPolicies { + if _, ok := policiesWithInstallersMap[policyID]; !ok { + continue + } + policyResultsOfPoliciesWithInstallers[policyID] = passes + } + if len(policyResultsOfPoliciesWithInstallers) == 0 { + return nil + } + + // Get the policies associated with installers that are flipping from passing to failing on this host. + policyIDsOfNewlyFailingPoliciesWithInstallers, _, err := svc.ds.FlippingPoliciesForHost( + ctx, hostID, policyResultsOfPoliciesWithInstallers, + ) + if err != nil { + return ctxerr.Wrap(ctx, err, "failed to get flipping policies for host") + } + if len(policyIDsOfNewlyFailingPoliciesWithInstallers) == 0 { + return nil + } + policyIDsOfNewlyFailingPoliciesWithInstallersSet := make(map[uint]struct{}) + for _, policyID := range policyIDsOfNewlyFailingPoliciesWithInstallers { + policyIDsOfNewlyFailingPoliciesWithInstallersSet[policyID] = struct{}{} + } + + // Finally filter out policies with installers that are not newly failing. + var failingPoliciesWithInstaller []fleet.PolicySoftwareInstallerData + for _, policyWithInstaller := range policiesWithInstaller { + if _, ok := policyIDsOfNewlyFailingPoliciesWithInstallersSet[policyWithInstaller.ID]; ok { + failingPoliciesWithInstaller = append(failingPoliciesWithInstaller, policyWithInstaller) + } + } + + for _, failingPolicyWithInstaller := range failingPoliciesWithInstaller { + installerMetadata, err := svc.ds.GetSoftwareInstallerMetadataByID(ctx, failingPolicyWithInstaller.InstallerID) + if err != nil { + return ctxerr.Wrap(ctx, err, "get software installer metadata by id") + } + logger := log.With(svc.logger, + "host_id", hostID, + "host_platform", hostPlatform, + "policy_id", failingPolicyWithInstaller.ID, + "software_installer_id", failingPolicyWithInstaller.InstallerID, + "software_title_id", installerMetadata.TitleID, + "software_installer_platform", installerMetadata.Platform, + ) + if fleet.PlatformFromHost(hostPlatform) != installerMetadata.Platform { + level.Debug(logger).Log("msg", "installer platform does not match host platform") + continue + } + hostLastInstall, err := svc.ds.GetHostLastInstallData(ctx, hostID, installerMetadata.InstallerID) + if err != nil { + return ctxerr.Wrap(ctx, err, "get host last install data") + } + // hostLastInstall.Status == nil can happen when a software is installed by Fleet and later removed. + if hostLastInstall != nil && hostLastInstall.Status != nil && + *hostLastInstall.Status == fleet.SoftwareInstallerPending { + // There's a pending install for this host and installer, + // thus we do not queue another install request. + level.Debug(svc.logger).Log( + "msg", "found pending install request for this host and installer", + "pending_execution_id", hostLastInstall.ExecutionID, + ) + continue + } + // NOTE(lucas): The user_id set in this software install will be NULL + // so this means that when generating the activity for this action + // (in SaveHostSoftwareInstallResult) + // the author will be set to the user that uploaded the software (we want this + // by design). + installUUID, err := svc.ds.InsertSoftwareInstallRequest( + ctx, hostID, + installerMetadata.InstallerID, + false, // Set Self-service as false because this is triggered by Fleet. + ) + if err != nil { + return ctxerr.Wrapf(ctx, err, + "insert software install request: host_id=%d, software_installer_id=%d", + hostID, installerMetadata.InstallerID, + ) + } + level.Debug(logger).Log( + "msg", "install request sent", + "install_uuid", installUUID, + ) + } + return nil +} + func (svc *Service) maybeDebugHost( ctx context.Context, host *fleet.Host, diff --git a/server/service/schedule/schedule.go b/server/service/schedule/schedule.go index 8965416a642e..7ca865416ab6 100644 --- a/server/service/schedule/schedule.go +++ b/server/service/schedule/schedule.go @@ -167,7 +167,13 @@ func (s *Schedule) Start() { level.Error(s.logger).Log("err", "start schedule", "details", err) ctxerr.Handle(s.ctx, err) } - s.setIntervalStartedAt(prevScheduledRun.CreatedAt) + + // if there is no previous run, set the start time to the current time. + startedAt := prevScheduledRun.CreatedAt + if startedAt.IsZero() { + startedAt = time.Now() + } + s.setIntervalStartedAt(startedAt) initialWait := 10 * time.Second if schedInterval := s.getSchedInterval(); schedInterval < initialWait { diff --git a/server/service/software_installers.go b/server/service/software_installers.go index 893396521c38..9862ade6716a 100644 --- a/server/service/software_installers.go +++ b/server/service/software_installers.go @@ -2,12 +2,15 @@ package service import ( "context" + "crypto/x509" + "encoding/json" "errors" "fmt" "io" "mime/multipart" "net" "net/http" + "net/url" "strconv" "github.com/docker/go-units" @@ -235,7 +238,8 @@ func (svc *Service) GenerateSoftwareInstallerToken(ctx context.Context, _ string } func (svc *Service) GetSoftwareInstallerTokenMetadata(ctx context.Context, _ string, _ uint) (*fleet.SoftwareInstallerTokenMetadata, - error) { + error, +) { // skipauth: No authorization check needed due to implementation returning // only license error. svc.authz.SkipAuthorization(ctx) @@ -283,7 +287,8 @@ func (r orbitDownloadSoftwareInstallerResponse) hijackRender(ctx context.Context func (svc *Service) DownloadSoftwareInstaller(ctx context.Context, _ bool, _ string, _ uint, _ *uint) (*fleet.DownloadSoftwareInstallerPayload, - error) { + error, +) { // skipauth: No authorization check needed due to implementation returning // only license error. svc.authz.SkipAuthorization(ctx) @@ -368,27 +373,27 @@ type batchSetSoftwareInstallersRequest struct { } type batchSetSoftwareInstallersResponse struct { - Err error `json:"error,omitempty"` + Installers []fleet.SoftwareInstaller `json:"installers"` + Err error `json:"error,omitempty"` } func (r batchSetSoftwareInstallersResponse) error() error { return r.Err } -func (r batchSetSoftwareInstallersResponse) Status() int { return http.StatusNoContent } - func batchSetSoftwareInstallersEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { req := request.(*batchSetSoftwareInstallersRequest) - if err := svc.BatchSetSoftwareInstallers(ctx, req.TeamName, req.Software, req.DryRun); err != nil { + installers, err := svc.BatchSetSoftwareInstallers(ctx, req.TeamName, req.Software, req.DryRun) + if err != nil { return batchSetSoftwareInstallersResponse{Err: err}, nil } - return batchSetSoftwareInstallersResponse{}, nil + return batchSetSoftwareInstallersResponse{Installers: installers}, nil } -func (svc *Service) BatchSetSoftwareInstallers(ctx context.Context, tmName string, payloads []fleet.SoftwareInstallerPayload, dryRun bool) error { +func (svc *Service) BatchSetSoftwareInstallers(ctx context.Context, tmName string, payloads []fleet.SoftwareInstallerPayload, dryRun bool) ([]fleet.SoftwareInstaller, error) { // skipauth: No authorization check needed due to implementation returning // only license error. svc.authz.SkipAuthorization(ctx) - return fleet.ErrMissingLicense + return nil, fleet.ErrMissingLicense } ////////////////////////////////////////////////////////////////////////////// @@ -455,6 +460,17 @@ type batchAssociateAppStoreAppsRequest struct { Apps []fleet.VPPBatchPayload `json:"app_store_apps"` } +func (b *batchAssociateAppStoreAppsRequest) DecodeBody(ctx context.Context, r io.Reader, u url.Values, c []*x509.Certificate) error { + if err := json.NewDecoder(r).Decode(b); err != nil { + var typeErr *json.UnmarshalTypeError + if errors.As(err, &typeErr) { + return ctxerr.Wrap(ctx, fleet.NewUserMessageError(fmt.Errorf("Couldn't edit software. %q must be a %s, found %s", typeErr.Field, typeErr.Type.String(), typeErr.Value), http.StatusBadRequest)) + } + } + + return nil +} + type batchAssociateAppStoreAppsResponse struct { Err error `json:"error,omitempty"` } diff --git a/server/service/team_policies.go b/server/service/team_policies.go index 81ebee7d40b7..8f68ecddf1f9 100644 --- a/server/service/team_policies.go +++ b/server/service/team_policies.go @@ -29,6 +29,7 @@ type teamPolicyRequest struct { Platform string `json:"platform"` Critical bool `json:"critical" premium:"true"` CalendarEventsEnabled bool `json:"calendar_events_enabled"` + SoftwareTitleID *uint `json:"software_title_id"` } type teamPolicyResponse struct { @@ -40,7 +41,7 @@ func (r teamPolicyResponse) error() error { return r.Err } func teamPolicyEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { req := request.(*teamPolicyRequest) - resp, err := svc.NewTeamPolicy(ctx, req.TeamID, fleet.PolicyPayload{ + resp, err := svc.NewTeamPolicy(ctx, req.TeamID, fleet.NewTeamPolicyPayload{ QueryID: req.QueryID, Name: req.Name, Query: req.Query, @@ -49,6 +50,7 @@ func teamPolicyEndpoint(ctx context.Context, request interface{}, svc fleet.Serv Platform: req.Platform, Critical: req.Critical, CalendarEventsEnabled: req.CalendarEventsEnabled, + SoftwareTitleID: req.SoftwareTitleID, }) if err != nil { return teamPolicyResponse{Err: err}, nil @@ -56,7 +58,7 @@ func teamPolicyEndpoint(ctx context.Context, request interface{}, svc fleet.Serv return teamPolicyResponse{Policy: resp}, nil } -func (svc Service) NewTeamPolicy(ctx context.Context, teamID uint, p fleet.PolicyPayload) (*fleet.Policy, error) { +func (svc Service) NewTeamPolicy(ctx context.Context, teamID uint, tp fleet.NewTeamPolicyPayload) (*fleet.Policy, error) { if err := svc.authz.Authorize(ctx, &fleet.Policy{ PolicyData: fleet.PolicyData{ TeamID: ptr.Uint(teamID), @@ -70,6 +72,11 @@ func (svc Service) NewTeamPolicy(ctx context.Context, teamID uint, p fleet.Polic return nil, errors.New("user must be authenticated to create team policies") } + p, err := svc.newTeamPolicyPayloadToPolicyPayload(ctx, teamID, tp) + if err != nil { + return nil, err + } + if err := p.Verify(); err != nil { return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{ Message: fmt.Sprintf("policy payload verification: %s", err), @@ -80,6 +87,10 @@ func (svc Service) NewTeamPolicy(ctx context.Context, teamID uint, p fleet.Polic return nil, ctxerr.Wrap(ctx, err, "creating policy") } + if err := svc.populatePolicyInstallSoftware(ctx, policy); err != nil { + return nil, ctxerr.Wrap(ctx, err, "populate install_software") + } + // Note: Issue #4191 proposes that we move to SQL transactions for actions so that we can // rollback an action in the event of an error writing the associated activity if err := svc.NewActivity( @@ -95,6 +106,39 @@ func (svc Service) NewTeamPolicy(ctx context.Context, teamID uint, p fleet.Polic return policy, nil } +func (svc *Service) populatePolicyInstallSoftware(ctx context.Context, p *fleet.Policy) error { + if p.SoftwareInstallerID == nil { + return nil + } + installerMetadata, err := svc.ds.GetSoftwareInstallerMetadataByID(ctx, *p.SoftwareInstallerID) + if err != nil { + return ctxerr.Wrap(ctx, err, "get software installer metadata by id") + } + p.InstallSoftware = &fleet.PolicySoftwareTitle{ + SoftwareTitleID: *installerMetadata.TitleID, + Name: installerMetadata.SoftwareTitle, + } + return nil +} + +func (svc *Service) newTeamPolicyPayloadToPolicyPayload(ctx context.Context, teamID uint, p fleet.NewTeamPolicyPayload) (fleet.PolicyPayload, error) { + softwareInstallerID, err := svc.deduceSoftwareInstallerIDFromTitleID(ctx, &teamID, p.SoftwareTitleID) + if err != nil { + return fleet.PolicyPayload{}, err + } + return fleet.PolicyPayload{ + QueryID: p.QueryID, + Name: p.Name, + Query: p.Query, + Critical: p.Critical, + Description: p.Description, + Resolution: p.Resolution, + Platform: p.Platform, + CalendarEventsEnabled: p.CalendarEventsEnabled, + SoftwareInstallerID: softwareInstallerID, + }, nil +} + ///////////////////////////////////////////////////////////////////////////////// // List ///////////////////////////////////////////////////////////////////////////////// @@ -148,11 +192,27 @@ func (svc *Service) ListTeamPolicies(ctx context.Context, teamID uint, opts flee } if mergeInherited { - p, err := svc.ds.ListMergedTeamPolicies(ctx, teamID, opts) - return p, nil, err + policies, err := svc.ds.ListMergedTeamPolicies(ctx, teamID, opts) + for i := range policies { + if err := svc.populatePolicyInstallSoftware(ctx, policies[i]); err != nil { + return nil, nil, ctxerr.Wrapf(ctx, err, "populate install_software for policy_id: %d", policies[i].ID) + } + } + return policies, nil, err } - return svc.ds.ListTeamPolicies(ctx, teamID, opts, iopts) + teamPolicies, inheritedPolicies, err = svc.ds.ListTeamPolicies(ctx, teamID, opts, iopts) + if err != nil { + return nil, nil, err + } + + for i := range teamPolicies { + if err := svc.populatePolicyInstallSoftware(ctx, teamPolicies[i]); err != nil { + return nil, nil, ctxerr.Wrapf(ctx, err, "populate install_software for policy_id: %d", teamPolicies[i].ID) + } + } + + return teamPolicies, inheritedPolicies, nil } ///////////////////////////////////////////////////////////////////////////////// @@ -240,6 +300,10 @@ func (svc Service) GetTeamPolicyByIDQueries(ctx context.Context, teamID uint, po return nil, err } + if err := svc.populatePolicyInstallSoftware(ctx, teamPolicy); err != nil { + return nil, ctxerr.Wrap(ctx, err, "populate install_software") + } + return teamPolicy, nil } @@ -418,6 +482,14 @@ func (svc *Service) modifyPolicy(ctx context.Context, teamID *uint, id uint, p f policy.FailingHostCount = 0 policy.PassingHostCount = 0 } + if p.SoftwareTitleID != nil { + softwareInstallerID, err := svc.deduceSoftwareInstallerIDFromTitleID(ctx, teamID, p.SoftwareTitleID) + if err != nil { + return nil, err + } + policy.SoftwareInstallerID = softwareInstallerID + } + logging.WithExtras(ctx, "name", policy.Name, "sql", policy.Query) err = svc.ds.SavePolicy(ctx, policy, removeAllMemberships, removeStats) @@ -425,6 +497,10 @@ func (svc *Service) modifyPolicy(ctx context.Context, teamID *uint, id uint, p f return nil, ctxerr.Wrap(ctx, err, "saving policy") } + if err := svc.populatePolicyInstallSoftware(ctx, policy); err != nil { + return nil, ctxerr.Wrap(ctx, err, "populate install_software") + } + // Note: Issue #4191 proposes that we move to SQL transactions for actions so that we can // rollback an action in the event of an error writing the associated activity if err := svc.NewActivity( @@ -440,3 +516,48 @@ func (svc *Service) modifyPolicy(ctx context.Context, teamID *uint, id uint, p f return policy, nil } + +func (svc *Service) deduceSoftwareInstallerIDFromTitleID(ctx context.Context, teamID *uint, softwareTitleID *uint) (*uint, error) { + if softwareTitleID == nil { + return nil, nil + } + + // If *p.SoftwareTitleID with value 0 is used to unset the current installer from the policy. + if *softwareTitleID == 0 { + return nil, nil + } + + if teamID == nil { + return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{ + Message: "software_title_id cannot be set on global policies", + }) + } + + softwareTitle, err := svc.SoftwareTitleByID(ctx, *softwareTitleID, teamID) + if err != nil { + if fleet.IsNotFound(err) { + return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{ + Message: fmt.Sprintf("software_title_id %d on team_id %d not found", *softwareTitleID, *teamID), + }) + } + return nil, ctxerr.Wrap(ctx, err, "software title by id") + } + if softwareTitle.AppStoreApp != nil { + return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{ + Message: fmt.Sprintf("software_title_id %d on team_id %d is assocated to a VPP app, only software installers are supported", *softwareTitleID, *teamID), + }) + } + if softwareTitle.SoftwarePackage == nil { + return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{ + Message: fmt.Sprintf("software_title_id %d on team_id %d does not have associated package", *softwareTitleID, *teamID), + }) + } + + // + // TODO(lucas): Support "No team" (softwareTitle.SoftwarePackage.TeamID == nil). + // + + // At this point we assume *softwareTitle.SoftwarePackage.TeamID == *teamID, + // because SoftwareTitleByID above receives the teamID. + return ptr.Uint(softwareTitle.SoftwarePackage.InstallerID), nil +} diff --git a/server/service/team_policies_test.go b/server/service/team_policies_test.go index 5c6c25ff8ce0..551e6e567cf1 100644 --- a/server/service/team_policies_test.go +++ b/server/service/team_policies_test.go @@ -32,7 +32,7 @@ func TestTeamPoliciesAuth(t *testing.T) { return nil, nil } ds.TeamPolicyFunc = func(ctx context.Context, teamID uint, policyID uint) (*fleet.Policy, error) { - return nil, nil + return &fleet.Policy{}, nil } ds.PolicyFunc = func(ctx context.Context, id uint) (*fleet.Policy, error) { if id == 1 { @@ -68,6 +68,9 @@ func TestTeamPoliciesAuth(t *testing.T) { ds.TeamFunc = func(ctx context.Context, tid uint) (*fleet.Team, error) { return &fleet.Team{ID: 1}, nil } + ds.GetSoftwareInstallerMetadataByIDFunc = func(ctx context.Context, id uint) (*fleet.SoftwareInstaller, error) { + return &fleet.SoftwareInstaller{}, nil + } testCases := []struct { name string @@ -149,7 +152,7 @@ func TestTeamPoliciesAuth(t *testing.T) { t.Run(tt.name, func(t *testing.T) { ctx := viewer.NewContext(ctx, viewer.Viewer{User: tt.user}) - _, err := svc.NewTeamPolicy(ctx, 1, fleet.PolicyPayload{ + _, err := svc.NewTeamPolicy(ctx, 1, fleet.NewTeamPolicyPayload{ Name: "query1", Query: "select 1;", }) diff --git a/server/service/teams.go b/server/service/teams.go index 1d8a30032de7..d2bcf6a99421 100644 --- a/server/service/teams.go +++ b/server/service/teams.go @@ -5,11 +5,12 @@ import ( "crypto/x509" "encoding/json" "fmt" - "golang.org/x/text/unicode/norm" "io" "net/http" "net/url" + "golang.org/x/text/unicode/norm" + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" ) diff --git a/server/service/teams_test.go b/server/service/teams_test.go index 9011e8bce73c..cf71aab79557 100644 --- a/server/service/teams_test.go +++ b/server/service/teams_test.go @@ -53,8 +53,9 @@ func TestTeamAuth(t *testing.T) { ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error { return nil } - ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string) error { - return nil + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hids, tids []uint, puuids, uuids []string, + ) (updates fleet.MDMProfilesUpdates, err error) { + return fleet.MDMProfilesUpdates{}, nil } ds.ListHostsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.HostListOptions) ([]*fleet.Host, error) { return []*fleet.Host{}, nil diff --git a/server/service/testdata/software-installers/README.md b/server/service/testdata/software-installers/README.md new file mode 100644 index 000000000000..b5a59d9daf64 --- /dev/null +++ b/server/service/testdata/software-installers/README.md @@ -0,0 +1,3 @@ +# testdata + +- `fleet-osquery.msi` is a dummy MSI installer created by `packaging.BuildMSI` with a fake `orbit.exe` that just has `hello world` in it. Its software title is `Fleet osquery` and its version is `1.0.0`. \ No newline at end of file diff --git a/server/service/testdata/software-installers/fleet-osquery.msi b/server/service/testdata/software-installers/fleet-osquery.msi new file mode 100644 index 000000000000..cc52ad5d4e0c Binary files /dev/null and b/server/service/testdata/software-installers/fleet-osquery.msi differ diff --git a/server/service/testdata/software-installers/no_version.pkg b/server/service/testdata/software-installers/no_version.pkg new file mode 100644 index 000000000000..c649ebf17bdc Binary files /dev/null and b/server/service/testdata/software-installers/no_version.pkg differ diff --git a/server/service/testing_client.go b/server/service/testing_client.go index b5232c2b1c5a..48476f71625e 100644 --- a/server/service/testing_client.go +++ b/server/service/testing_client.go @@ -118,12 +118,6 @@ func (ts *withServer) commonTearDownTest(t *testing.T) { require.NoError(t, ts.ds.DeleteHost(ctx, host.ID)) } - // clean up any software installers - mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error { - _, err := q.ExecContext(ctx, `DELETE FROM software_installers`) - return err - }) - lbls, err := ts.ds.ListLabels(ctx, fleet.TeamFilter{}, fleet.ListOptions{}) require.NoError(t, err) for _, lbl := range lbls { @@ -161,6 +155,12 @@ func (ts *withServer) commonTearDownTest(t *testing.T) { require.NoError(t, err) } + // Clean software installers in "No team" (the others are deleted in ts.ds.DeleteTeam above). + mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, `DELETE FROM software_installers WHERE global_or_team_id = 0;`) + return err + }) + globalPolicies, err := ts.ds.ListGlobalPolicies(ctx, fleet.ListOptions{}) require.NoError(t, err) if len(globalPolicies) > 0 { @@ -198,6 +198,11 @@ func (ts *withServer) commonTearDownTest(t *testing.T) { _, err := q.ExecContext(ctx, `DELETE FROM host_script_results`) return err }) + + mysql.ExecAdhocSQL(t, ts.ds, func(tx sqlx.ExtContext) error { + _, err := tx.ExecContext(ctx, "DELETE FROM vpp_tokens;") + return err + }) } func (ts *withServer) Do(verb, path string, params interface{}, expectedStatusCode int, queryParams ...string) *http.Response { @@ -279,6 +284,21 @@ func (ts *withServer) DoJSON(verb, path string, params interface{}, expectedStat } } +func (ts *withServer) DoJSONWithoutAuth(verb, path string, params interface{}, expectedStatusCode int, v interface{}, queryParams ...string) { + t := ts.s.T() + rawBytes, err := json.Marshal(params) + require.NoError(t, err) + resp := ts.DoRawWithHeaders(verb, path, rawBytes, expectedStatusCode, map[string]string{}, queryParams...) + t.Cleanup(func() { + resp.Body.Close() + }) + err = json.NewDecoder(resp.Body).Decode(v) + require.NoError(ts.s.T(), err) + if e, ok := v.(errorer); ok { + require.NoError(ts.s.T(), e.error()) + } +} + func (ts *withServer) getTestAdminToken() string { testUser := testUsers["admin1"] @@ -480,3 +500,24 @@ func (ts *withServer) lastActivityOfTypeMatches(name, details string, id uint) u t.Fatalf("no activity of type %s found in the last %d activities", name, len(listActivities.Activities)) return 0 } + +func (ts *withServer) lastActivityOfTypeDoesNotMatch(name, details string, id uint) { + t := ts.s.T() + + var listActivities listActivitiesResponse + ts.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, + &listActivities, "order_key", "a.id", "order_direction", "desc", "per_page", "10") + require.True(t, len(listActivities.Activities) > 0) + + for _, act := range listActivities.Activities { + if act.Type == name { + if details != "" { + require.NotNil(t, act.Details) + assert.NotEqual(t, details, string(*act.Details)) + } + if id > 0 { + assert.NotEqual(t, id, act.ID) + } + } + } +} diff --git a/server/service/vpp.go b/server/service/vpp.go index 04b1ac57a3f3..c2e25eddc060 100644 --- a/server/service/vpp.go +++ b/server/service/vpp.go @@ -2,7 +2,11 @@ package service import ( "context" + "io" + "mime/multipart" + "net/http" + "github.com/docker/go-units" "github.com/fleetdm/fleet/v4/server/fleet" ) @@ -73,3 +77,239 @@ func (svc *Service) AddAppStoreApp(ctx context.Context, _ *uint, _ fleet.VPPAppT return fleet.ErrMissingLicense } + +//////////////////////////////////////////////////////////////////////////////// +// POST /api/_version_/vpp_tokens +//////////////////////////////////////////////////////////////////////////////// + +type uploadVPPTokenRequest struct { + File *multipart.FileHeader +} + +func (uploadVPPTokenRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) { + decoded := uploadVPPTokenRequest{} + + err := r.ParseMultipartForm(512 * units.MiB) + if err != nil { + return nil, &fleet.BadRequestError{ + Message: "failed to parse multipart form", + InternalErr: err, + } + } + + if r.MultipartForm.File["token"] == nil || len(r.MultipartForm.File["token"]) == 0 { + return nil, &fleet.BadRequestError{ + Message: "token multipart field is required", + InternalErr: err, + } + } + + decoded.File = r.MultipartForm.File["token"][0] + + return &decoded, nil +} + +type uploadVPPTokenResponse struct { + Err error `json:"error,omitempty"` + Token *fleet.VPPTokenDB `json:"token,omitempty"` +} + +func (r uploadVPPTokenResponse) Status() int { return http.StatusAccepted } + +func (r uploadVPPTokenResponse) error() error { + return r.Err +} + +func uploadVPPTokenEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*uploadVPPTokenRequest) + file, err := req.File.Open() + if err != nil { + return uploadVPPTokenResponse{Err: err}, nil + } + defer file.Close() + + tok, err := svc.UploadVPPToken(ctx, file) + if err != nil { + return uploadVPPTokenResponse{Err: err}, nil + } + + return uploadVPPTokenResponse{Token: tok}, nil +} + +func (svc *Service) UploadVPPToken(ctx context.Context, file io.ReadSeeker) (*fleet.VPPTokenDB, error) { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return nil, fleet.ErrMissingLicense +} + +//////////////////////////////////////////////////// +// PATCH /api/_version_/fleet/vpp_tokens/%d/renew // +//////////////////////////////////////////////////// + +type patchVPPTokenRenewRequest struct { + ID uint `url:"id"` + File *multipart.FileHeader +} + +func (patchVPPTokenRenewRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) { + decoded := patchVPPTokenRenewRequest{} + + err := r.ParseMultipartForm(512 * units.MiB) + if err != nil { + return nil, &fleet.BadRequestError{ + Message: "failed to parse multipart form", + InternalErr: err, + } + } + + if r.MultipartForm.File["token"] == nil || len(r.MultipartForm.File["token"]) == 0 { + return nil, &fleet.BadRequestError{ + Message: "token multipart field is required", + InternalErr: err, + } + } + + decoded.File = r.MultipartForm.File["token"][0] + + return &decoded, nil +} + +type patchVPPTokenRenewResponse struct { + Err error `json:"error,omitempty"` + Token *fleet.VPPTokenDB `json:"token,omitempty"` +} + +func (r patchVPPTokenRenewResponse) Status() int { return http.StatusAccepted } + +func (r patchVPPTokenRenewResponse) error() error { + return r.Err +} + +func patchVPPTokenRenewEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*patchVPPTokenRenewRequest) + file, err := req.File.Open() + if err != nil { + return patchVPPTokenRenewResponse{Err: err}, nil + } + defer file.Close() + + tok, err := svc.UpdateVPPToken(ctx, req.ID, file) + if err != nil { + return patchVPPTokenRenewResponse{Err: err}, nil + } + + return patchVPPTokenRenewResponse{Token: tok}, nil +} + +func (svc *Service) UpdateVPPToken(ctx context.Context, tokenID uint, token io.ReadSeeker) (*fleet.VPPTokenDB, error) { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return nil, fleet.ErrMissingLicense +} + +//////////////////////////////////////////////////// +// PATCH /api/_version_/fleet/vpp_tokens/%d/teams // +//////////////////////////////////////////////////// + +type patchVPPTokensTeamsRequest struct { + ID uint `url:"id"` + TeamIDs []uint `json:"teams"` +} + +type patchVPPTokensTeamsResponse struct { + Token *fleet.VPPTokenDB `json:"token,omitempty"` + Err error `json:"error,omitempty"` +} + +func (r patchVPPTokensTeamsResponse) error() error { return r.Err } + +func patchVPPTokensTeams(ctx context.Context, request any, svc fleet.Service) (errorer, error) { + req := request.(*patchVPPTokensTeamsRequest) + + tok, err := svc.UpdateVPPTokenTeams(ctx, req.ID, req.TeamIDs) + if err != nil { + return patchVPPTokensTeamsResponse{Err: err}, nil + } + return patchVPPTokensTeamsResponse{Token: tok}, nil +} + +func (svc *Service) UpdateVPPTokenTeams(ctx context.Context, tokenID uint, teamIDs []uint) (*fleet.VPPTokenDB, error) { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return nil, fleet.ErrMissingLicense +} + +///////////////////////////////////////// +// GET /api/_version_/fleet/vpp_tokens // +///////////////////////////////////////// + +type getVPPTokensRequest struct{} + +type getVPPTokensResponse struct { + Tokens []*fleet.VPPTokenDB `json:"vpp_tokens"` + Err error `json:"error,omitempty"` +} + +func (r getVPPTokensResponse) error() error { return r.Err } + +func getVPPTokens(ctx context.Context, request any, svc fleet.Service) (errorer, error) { + tokens, err := svc.GetVPPTokens(ctx) + if err != nil { + return getVPPTokensResponse{Err: err}, nil + } + + if tokens == nil { + tokens = []*fleet.VPPTokenDB{} + } + + return getVPPTokensResponse{Tokens: tokens}, nil +} + +func (svc *Service) GetVPPTokens(ctx context.Context) ([]*fleet.VPPTokenDB, error) { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return nil, fleet.ErrMissingLicense +} + +/////////////////////////////////////////////// +// DELETE /api/_version_/fleet/vpp_tokens/%d // +/////////////////////////////////////////////// + +type deleteVPPTokenRequest struct { + ID uint `url:"id"` +} + +type deleteVPPTokenResponse struct { + Err error `json:"error,omitempty"` +} + +func (r deleteVPPTokenResponse) error() error { return r.Err } + +func (r deleteVPPTokenResponse) Status() int { return http.StatusNoContent } + +func deleteVPPToken(ctx context.Context, request any, svc fleet.Service) (errorer, error) { + req := request.(*deleteVPPTokenRequest) + + err := svc.DeleteVPPToken(ctx, req.ID) + if err != nil { + return deleteVPPTokenResponse{Err: err}, nil + } + + return deleteVPPTokenResponse{}, nil +} + +func (svc *Service) DeleteVPPToken(ctx context.Context, tokenID uint) error { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return fleet.ErrMissingLicense +} diff --git a/server/service/vpp_test.go b/server/service/vpp_test.go index 1821b9926f89..95b9c65ed0d9 100644 --- a/server/service/vpp_test.go +++ b/server/service/vpp_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mock" @@ -14,13 +15,24 @@ import ( ) func TestVPPAuth(t *testing.T) { - t.Skip() ds := new(mock.Store) license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license}) + // use a custom implementation of checkAuthErr as the service call will fail + // with a different error for in case of authorization success and the + // package-wide checkAuthErr requires no error. + checkAuthErr := func(t *testing.T, shouldFail bool, err error) { + if shouldFail { + require.Error(t, err) + require.Equal(t, (&authz.Forbidden{}).Error(), err.Error()) + } else if err != nil { + require.NotEqual(t, (&authz.Forbidden{}).Error(), err.Error()) + } + } + testCases := []struct { name string user *fleet.User @@ -64,14 +76,15 @@ func TestVPPAuth(t *testing.T) { ds.TeamExistsFunc = func(ctx context.Context, teamID uint) (bool, error) { return false, nil } - ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { return map[fleet.MDMAssetName]fleet.MDMConfigAsset{}, nil } - ds.TeamFunc = func(ctx context.Context, tid uint) (*fleet.Team, error) { return &fleet.Team{ID: 1}, nil } + ds.GetVPPTokenByTeamIDFunc = func(ctx context.Context, teamID *uint) (*fleet.VPPTokenDB, error) { + return &fleet.VPPTokenDB{ID: 1, OrgName: "org", Teams: []fleet.TeamTuple{{ID: 1}}}, nil + } // Note: these calls always return an error because they're attempting to unmarshal a // non-existent VPP token. @@ -79,18 +92,14 @@ func TestVPPAuth(t *testing.T) { if tt.teamID == nil { require.Error(t, err) } else { - if tt.shouldFailRead { - checkAuthErr(t, true, err) - } + checkAuthErr(t, tt.shouldFailRead, err) } err = svc.AddAppStoreApp(ctx, tt.teamID, fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "123", Platform: fleet.IOSPlatform}}) if tt.teamID == nil { require.Error(t, err) } else { - if tt.shouldFailWrite { - checkAuthErr(t, true, err) - } + checkAuthErr(t, tt.shouldFailWrite, err) } }) } diff --git a/server/test/mdm.go b/server/test/mdm.go index 0bcd17d258a0..0b5e8a5760a6 100644 --- a/server/test/mdm.go +++ b/server/test/mdm.go @@ -1,13 +1,17 @@ package test import ( + "context" "crypto/rand" "encoding/base64" "encoding/json" "fmt" + "testing" "time" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/stretchr/testify/require" + "github.com/tj/assert" ) func CreateVPPTokenData(expiration time.Time, orgName, location string) (*fleet.VPPTokenData, error) { @@ -31,11 +35,31 @@ func CreateVPPTokenData(expiration time.Time, orgName, location string) (*fleet. return &fleet.VPPTokenData{Token: base64Token, Location: location}, nil } +func CreateInsertGlobalVPPToken(t *testing.T, ds fleet.Datastore) *fleet.VPPTokenDB { + ctx := context.Background() + dataToken, err := CreateVPPTokenData(time.Now().Add(24*time.Hour), "Donkey Kong", "Jungle") + require.NoError(t, err) + tok1, err := ds.InsertVPPToken(ctx, dataToken) + assert.NoError(t, err) + tok1New, err := ds.UpdateVPPTokenTeams(ctx, tok1.ID, []uint{}) + assert.NoError(t, err) + + return tok1New +} + func CreateVPPTokenEncoded(expiration time.Time, orgName, location string) ([]byte, error) { dataToken, err := CreateVPPTokenData(expiration, orgName, location) if err != nil { return nil, err } + return []byte(dataToken.Token), nil +} + +func CreateVPPTokenEncodedAfterMigration(expiration time.Time, orgName, location string) ([]byte, error) { + dataToken, err := CreateVPPTokenData(expiration, orgName, location) + if err != nil { + return nil, err + } dataTokenJson, err := json.Marshal(dataToken) if err != nil { diff --git a/server/test/new_objects.go b/server/test/new_objects.go index 099b7a0e579c..f8a2de578c24 100644 --- a/server/test/new_objects.go +++ b/server/test/new_objects.go @@ -210,6 +210,12 @@ func WithPlatform(s string) NewHostOption { } } +func WithTeamID(teamID uint) NewHostOption { + return func(h *fleet.Host) { + h.TeamID = &teamID + } +} + func NewHost(tb testing.TB, ds fleet.Datastore, name, ip, key, uuid string, now time.Time, options ...NewHostOption) *fleet.Host { osqueryHostID, _ := server.GenerateRandomText(10) h := &fleet.Host{ diff --git a/server/vulnerabilities/nvd/cve.go b/server/vulnerabilities/nvd/cve.go index f7bc5fa49c40..7b321ef94788 100644 --- a/server/vulnerabilities/nvd/cve.go +++ b/server/vulnerabilities/nvd/cve.go @@ -588,6 +588,14 @@ func expandCPEAliases(cpeItem *wfn.Attributes) []*wfn.Attributes { } } + for _, cpeItem := range cpeItems { + if cpeItem.Vendor == "oracle" && cpeItem.Product == "virtualbox" { + cpeItem2 := *cpeItem + cpeItem2.Product = "vm_virtualbox" + cpeItems = append(cpeItems, &cpeItem2) + } + } + return cpeItems } diff --git a/server/vulnerabilities/nvd/cve_test.go b/server/vulnerabilities/nvd/cve_test.go index e1314833277e..691f3e321a7d 100644 --- a/server/vulnerabilities/nvd/cve_test.go +++ b/server/vulnerabilities/nvd/cve_test.go @@ -347,6 +347,14 @@ func TestTranslateCPEToCVE(t *testing.T) { }, continuesToUpdate: true, }, + // Tests the expandCPEAliases rule for virtualbox on macOS + "cpe:2.3:a:oracle:virtualbox:7.0.6:*:*:*:*:macos:*:*": { + includedCVEs: []cve{ + {ID: "CVE-2023-21989", resolvedInVersion: "7.0.8"}, + {ID: "CVE-2024-21141", resolvedInVersion: "7.0.20"}, + }, + continuesToUpdate: true, + }, } cveOSTests := []struct { diff --git a/server/worker/db_migrations.go b/server/worker/db_migrations.go index be5bc31762cf..88d0839d1985 100644 --- a/server/worker/db_migrations.go +++ b/server/worker/db_migrations.go @@ -2,7 +2,10 @@ package worker import ( "context" + "encoding/base64" "encoding/json" + "fmt" + "time" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" @@ -70,7 +73,7 @@ func (m *DBMigration) migrateVPPToken(ctx context.Context) error { return ctxerr.Wrap(ctx, err, "get VPP token to migrate") } - rawToken, didUpdate, err := tok.ExtractToken() + tokenData, didUpdate, err := extractVPPTokenFromMigration(tok) if err != nil { return ctxerr.Wrap(ctx, err, "extract VPP token metadata") } @@ -81,7 +84,47 @@ func (m *DBMigration) migrateVPPToken(ctx context.Context) error { m.Log.Log("info", "VPP token metadata was not updated") } - tokenData := fleet.VPPTokenData{Token: rawToken, Location: tok.Location} - _, err = m.Datastore.UpdateVPPToken(ctx, tok.ID, &tokenData) - return ctxerr.Wrap(ctx, err, "update VPP token") + if _, err := m.Datastore.UpdateVPPToken(ctx, tok.ID, tokenData); err != nil { + return ctxerr.Wrap(ctx, err, "update VPP token") + } + // the migated token should target "All teams" + _, err = m.Datastore.UpdateVPPTokenTeams(ctx, tok.ID, []uint{}) + return ctxerr.Wrap(ctx, err, "update VPP token teams") +} + +func extractVPPTokenFromMigration(migratedToken *fleet.VPPTokenDB) (tokData *fleet.VPPTokenData, didUpdateMetadata bool, err error) { + var vppTokenData fleet.VPPTokenData + if err := json.Unmarshal([]byte(migratedToken.Token), &vppTokenData); err != nil { + return nil, false, fmt.Errorf("unmarshaling VPP token data: %w", err) + } + + vppTokenRawBytes, err := base64.StdEncoding.DecodeString(vppTokenData.Token) + if err != nil { + return nil, false, fmt.Errorf("decoding raw vpp token data: %w", err) + } + + var vppTokenRaw fleet.VPPTokenRaw + if err := json.Unmarshal(vppTokenRawBytes, &vppTokenRaw); err != nil { + return nil, false, fmt.Errorf("unmarshaling raw vpp token data: %w", err) + } + + exp, err := time.Parse("2006-01-02T15:04:05Z0700", vppTokenRaw.ExpDate) + if err != nil { + return nil, false, fmt.Errorf("parsing vpp token expiration date: %w", err) + } + + if vppTokenData.Location != migratedToken.Location { + migratedToken.Location = vppTokenData.Location + didUpdateMetadata = true + } + if vppTokenRaw.OrgName != migratedToken.OrgName { + migratedToken.OrgName = vppTokenRaw.OrgName + didUpdateMetadata = true + } + if !exp.Equal(migratedToken.RenewDate) { + migratedToken.RenewDate = exp.UTC() + didUpdateMetadata = true + } + + return &vppTokenData, didUpdateMetadata, nil } diff --git a/server/worker/db_migrations_test.go b/server/worker/db_migrations_test.go index 8faca12acd4c..1aeeb697e9e2 100644 --- a/server/worker/db_migrations_test.go +++ b/server/worker/db_migrations_test.go @@ -12,12 +12,11 @@ import ( "github.com/fleetdm/fleet/v4/server/test" kitlog "github.com/go-kit/log" "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestDBMigrationsVPPToken(t *testing.T) { - // FIXME - t.Skip() ctx := context.Background() ds := mysql.CreateMySQLDS(t) @@ -38,7 +37,7 @@ func TestDBMigrationsVPPToken(t *testing.T) { // create the migrated token and enqueue the job expDate := time.Date(2024, 8, 27, 0, 0, 0, 0, time.UTC) - tok, err := test.CreateVPPTokenEncoded(expDate, "test-org", "test-loc") + tok, err := test.CreateVPPTokenEncodedAfterMigration(expDate, "test-org", "test-loc") require.NoError(t, err) encTok, err := mysql.EncryptWithPrivateKey(t, ds, tok) require.NoError(t, err) @@ -49,12 +48,10 @@ INSERT INTO vpp_tokens organization_name, location, renew_at, - token, - team_id, - null_team_type + token ) VALUES - ('', '', DATE('2000-01-01'), ?, NULL, 'allteams') + ('', '', DATE('2000-01-01'), ?) ` const insJob = ` @@ -93,7 +90,9 @@ VALUES (?, ?, ?, '', ?, ?, ?) // nothing more to run jobs, err := ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute)) // look in the future to catch any delayed job require.NoError(t, err) - require.Empty(t, jobs) + if !assert.Empty(t, jobs) { + t.Logf(">>> %#+v", jobs[0]) + } // token should've been updated vppTok, err := ds.GetVPPTokenByLocation(ctx, "test-loc") @@ -101,7 +100,7 @@ VALUES (?, ?, ?, '', ?, ?, ?) require.Equal(t, "test-org", vppTok.OrgName) require.Equal(t, "test-loc", vppTok.Location) require.Equal(t, expDate, vppTok.RenewDate) - require.Equal(t, string(tok), vppTok.Token) + require.Contains(t, string(tok), `"token":"`+vppTok.Token+`"`) // the DB-stored token is the "token" JSON field in the raw tok require.NotNil(t, vppTok.Teams) require.Len(t, vppTok.Teams, 0) diff --git a/server/worker/macos_setup_assistant.go b/server/worker/macos_setup_assistant.go index 59676b85595c..d47391713832 100644 --- a/server/worker/macos_setup_assistant.go +++ b/server/worker/macos_setup_assistant.go @@ -229,18 +229,20 @@ func (m *MacosSetupAssistant) runHostsTransferred(ctx context.Context, args maco return ctxerr.Wrap(ctx, err, "get team") } - skipSerials, assignSerials, err := m.Datastore.ScreenDEPAssignProfileSerialsForCooldown(ctx, args.HostSerialNumbers) + cooldownSerials, assignSerials, err := m.Datastore.ScreenDEPAssignProfileSerialsForCooldown(ctx, args.HostSerialNumbers) if err != nil { return ctxerr.Wrap(ctx, err, "run hosts transferred") } - if !fromCooldown { - // if not a retry, then we need to screen the serials for cooldown - if len(skipSerials) > 0 { - // NOTE: the `dep_cooldown` job of the `integrations` cron picks up the assignments - // after the cooldown period is over - level.Info(m.Log).Log("msg", "run hosts transferred: skipping assign profile for devices on cooldown", "serials", fmt.Sprintf("%s", skipSerials)) + // if it's a retry, serials on cooldown need to be assigned as well. + if fromCooldown { + for k, v := range cooldownSerials { + assignSerials[k] = append(assignSerials[k], v...) } + } else if len(cooldownSerials) > 0 { + // NOTE: the `dep_cooldown` job of the `integrations` cron picks up the assignments + // after the cooldown period is over + level.Info(m.Log).Log("msg", "run hosts transferred: skipping assign profile for devices on cooldown", "serials", fmt.Sprintf("%s", cooldownSerials)) } if len(assignSerials) == 0 { @@ -389,17 +391,17 @@ func QueueMacosSetupAssistantJob( } func ProcessDEPCooldowns(ctx context.Context, ds fleet.Datastore, logger kitlog.Logger) error { - serialsByTeamId, err := ds.GetDEPAssignProfileExpiredCooldowns(ctx) + serialsByTeamID, err := ds.GetDEPAssignProfileExpiredCooldowns(ctx) if err != nil { return ctxerr.Wrap(ctx, err, "getting cooldowns") } - if len(serialsByTeamId) == 0 { + if len(serialsByTeamID) == 0 { level.Info(logger).Log("msg", "no cooldowns to process") return nil } // queue job for each team so that macOS setup assistant worker can pick it up and process it - for teamID, serials := range serialsByTeamId { + for teamID, serials := range serialsByTeamID { if len(serials) == 0 { logger.Log("msg", "no cooldowns", "team_id", teamID) continue diff --git a/server/worker/macos_setup_assistant_test.go b/server/worker/macos_setup_assistant_test.go index ffb449491168..f137b418bb42 100644 --- a/server/worker/macos_setup_assistant_test.go +++ b/server/worker/macos_setup_assistant_test.go @@ -24,8 +24,6 @@ import ( ) func TestMacosSetupAssistant(t *testing.T) { - // FIXME - t.Skip() ctx := context.Background() ds := mysql.CreateMySQLDS(t) // call TruncateTables immediately as some DB migrations may create jobs @@ -195,19 +193,6 @@ func TestMacosSetupAssistant(t *testing.T) { require.False(t, modTime.Before(start)) } } - // the default token is not used by any team, only defined for no team (due - // to it defaulting to no team) - for _, tmID := range tmIDs { - profUUID, modTime, err := ds.GetMDMAppleDefaultSetupAssistant(ctx, tmID, "FIXME") - if tmID == nil { - require.NoError(t, err) - require.Equal(t, defaultProfileName, profUUID, "tmID", getTeamID(tmID)) - require.False(t, modTime.Before(start)) - } else { - require.Error(t, err) - require.ErrorIs(t, err, sql.ErrNoRows) - } - } require.Equal(t, map[string]string{ "serial-0": defaultProfileName, "serial-1": defaultProfileName, diff --git a/terraform/addons/monitoring/lambda/go.mod b/terraform/addons/monitoring/lambda/go.mod index ddf1ce8fe456..bd2389b8ef11 100644 --- a/terraform/addons/monitoring/lambda/go.mod +++ b/terraform/addons/monitoring/lambda/go.mod @@ -1,6 +1,6 @@ module github.com/fleetdm/fleet/terraform/addons/monitoring/lambda -go 1.22.6 +go 1.23.1 require ( github.com/aws/aws-lambda-go v1.41.0 diff --git a/terraform/addons/ses/main.tf b/terraform/addons/ses/main.tf index 6c7304f1ba12..00aec19fe0eb 100644 --- a/terraform/addons/ses/main.tf +++ b/terraform/addons/ses/main.tf @@ -1,3 +1,10 @@ +locals { + spf_domains = [ + aws_ses_domain_identity.default.domain, + "_amazonses.${aws_ses_domain_identity.default.domain}" + ] +} + resource "aws_ses_domain_identity" "default" { domain = var.domain } @@ -19,11 +26,12 @@ resource "aws_route53_record" "amazonses_dkim_record" { resource "aws_route53_record" "spf_domain" { - zone_id = var.zone_id - name = "_amazonses.${aws_ses_domain_identity.default.domain}" - type = "TXT" - ttl = "600" - records = ["v=spf1 include:amazonses.com -all"] + for_each = toset(local.spf_domains) + zone_id = var.zone_id + name = each.key + type = "TXT" + ttl = "600" + records = ["v=spf1 include:amazonses.com -all"] } resource "aws_iam_policy" "main" { diff --git a/terraform/addons/ses/variables.tf b/terraform/addons/ses/variables.tf index be93f34ccff6..a174e55f0ae6 100644 --- a/terraform/addons/ses/variables.tf +++ b/terraform/addons/ses/variables.tf @@ -4,6 +4,6 @@ variable "domain" { } variable "zone_id" { - type = string + type = string description = "Route53 Zone ID" } diff --git a/tools/cloner-check/generated_files/appconfig.txt b/tools/cloner-check/generated_files/appconfig.txt index 9166cec7a875..86388d153e44 100644 --- a/tools/cloner-check/generated_files/appconfig.txt +++ b/tools/cloner-check/generated_files/appconfig.txt @@ -97,7 +97,7 @@ github.com/fleetdm/fleet/v4/server/fleet/GoogleCalendarIntegration Domain string github.com/fleetdm/fleet/v4/server/fleet/GoogleCalendarIntegration ApiKey map[string]string github.com/fleetdm/fleet/v4/server/fleet/AppConfig MDM fleet.MDM github.com/fleetdm/fleet/v4/server/fleet/MDM DeprecatedAppleBMDefaultTeam string -github.com/fleetdm/fleet/v4/server/fleet/MDM AppleBussinessManager optjson.Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMAppleABMAssignmentInfo] +github.com/fleetdm/fleet/v4/server/fleet/MDM AppleBusinessManager optjson.Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMAppleABMAssignmentInfo] github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMAppleABMAssignmentInfo] Set bool github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMAppleABMAssignmentInfo] Valid bool github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMAppleABMAssignmentInfo] Value []fleet.MDMAppleABMAssignmentInfo diff --git a/tools/mdm/migration/mdmproxy/Dockerfile b/tools/mdm/migration/mdmproxy/Dockerfile index 5d3369304bb8..6355224466cc 100644 --- a/tools/mdm/migration/mdmproxy/Dockerfile +++ b/tools/mdm/migration/mdmproxy/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.22.5-alpine3.20@sha256:8c9183f715b0b4eca05b8b3dbf59766aaedb41ec07477b132ee2891ac0110a07 +FROM golang:1.23.1-alpine3.20@sha256:436e2d978524b15498b98faa367553ba6c3655671226f500c72ceb7afb2ef0b1 ARG TAG RUN apk update && apk add --no-cache git RUN git clone -b $TAG --depth=1 --no-tags --progress --no-recurse-submodules https://github.com/fleetdm/fleet.git && cd /go/fleet/tools/mdm/migration/mdmproxy && go build . diff --git a/tools/mdm/migration/mdmproxy/mdmproxy.go b/tools/mdm/migration/mdmproxy/mdmproxy.go index 723db4f19f71..c59bf4b425b5 100644 --- a/tools/mdm/migration/mdmproxy/mdmproxy.go +++ b/tools/mdm/migration/mdmproxy/mdmproxy.go @@ -84,14 +84,6 @@ func (m *mdmProxy) handleProxy(w http.ResponseWriter, r *http.Request) { return } - if !strings.HasPrefix(r.URL.Path, "/mdm") { - if m.logSkipped { - log.Printf("Forbidden non-mdm request: %s %s", r.Method, r.URL.String()) - } - http.Error(w, "Forbidden", http.StatusForbidden) - return - } - // Send all micromdm repo requests to the existing server if strings.HasPrefix(r.URL.Path, "/repo") { log.Printf("%s %s -> Existing (Repo)", r.Method, r.URL.String()) @@ -100,6 +92,14 @@ func (m *mdmProxy) handleProxy(w http.ResponseWriter, r *http.Request) { } + if !strings.HasPrefix(r.URL.Path, "/mdm") { + if m.logSkipped { + log.Printf("Forbidden non-mdm request: %s %s", r.Method, r.URL.String()) + } + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + // Read the body of the request body, err := io.ReadAll(r.Body) _ = r.Body.Close() diff --git a/tools/mdm/windows/bitlocker/go.mod b/tools/mdm/windows/bitlocker/go.mod index 031341594e3e..56ee33f9d039 100755 --- a/tools/mdm/windows/bitlocker/go.mod +++ b/tools/mdm/windows/bitlocker/go.mod @@ -1,6 +1,6 @@ module bitlocker -go 1.22.6 +go 1.23.1 require github.com/go-ole/go-ole v1.3.0 diff --git a/tools/terraform/go.mod b/tools/terraform/go.mod index 4874fdaddae2..98d450eacccf 100644 --- a/tools/terraform/go.mod +++ b/tools/terraform/go.mod @@ -1,6 +1,6 @@ module terraform-provider-fleetdm -go 1.22.6 +go 1.23.1 require ( github.com/hashicorp/terraform-plugin-framework v1.7.0 diff --git a/website/api/controllers/articles/view-articles.js b/website/api/controllers/articles/view-articles.js index 8f28c6c5c470..b7f11213ebdd 100644 --- a/website/api/controllers/articles/view-articles.js +++ b/website/api/controllers/articles/view-articles.js @@ -48,6 +48,7 @@ module.exports = { return page; } }); + articles = _.sortBy(articles, 'meta.publishedOn'); } let pageTitleForMeta = 'Fleet blog'; @@ -107,6 +108,7 @@ module.exports = { currentSection, pageTitleForMeta, pageDescriptionForMeta, + algoliaPublicKey: sails.config.custom.algoliaPublicKey, }; } diff --git a/website/api/controllers/deliver-talk-to-us-form-submission.js b/website/api/controllers/deliver-talk-to-us-form-submission.js index af26395dc151..aae36db70f5e 100644 --- a/website/api/controllers/deliver-talk-to-us-form-submission.js +++ b/website/api/controllers/deliver-talk-to-us-form-submission.js @@ -73,17 +73,28 @@ module.exports = { if(_.includes(sails.config.custom.bannedEmailDomainsForWebsiteSubmissions, emailDomain.toLowerCase())){ throw 'invalidEmailDomain'; } - + // Set a default psychological stage and change reason. + let psyStageAndChangeReason = { + psychologicalStage: '4 - Has use case', + psychologicalStageChangeReason: 'Website - Contact forms' + }; + if(this.req.me){ + // If this user is logged in, check their current psychological stage, and if it is higher than 4, we won't set a psystage. + // This way, if a user has a psytage >4, we won't regress their psystage because they submitted this form. + if(['4 - Has use case', '5 - Personally confident', '6 - Has team buy-in'].includes(this.req.me.psychologicalStage)) { + psyStageAndChangeReason = {}; + } + } if(numberOfHosts >= 700){ - sails.helpers.salesforce.updateOrCreateContactAndAccountAndCreateLead.with({ + sails.helpers.salesforce.updateOrCreateContactAndAccount.with({ emailAddress: emailAddress, firstName: firstName, lastName: lastName, organization: organization, - numberOfHosts: numberOfHosts, primaryBuyingSituation: primaryBuyingSituation === 'eo-security' ? 'Endpoint operations - Security' : primaryBuyingSituation === 'eo-it' ? 'Endpoint operations - IT' : primaryBuyingSituation === 'mdm' ? 'Device management (MDM)' : primaryBuyingSituation === 'vm' ? 'Vulnerability management' : undefined, contactSource: 'Website - Contact forms', - leadDescription: `Submitted the "Talk to us" form and was taken to the Calendly page for the "Talk to us" event.`, + description: `Submitted the "Talk to us" form and was taken to the Calendly page for the "Talk to us" event.`, + ...psyStageAndChangeReason// Only (potentially) set psystage and change reason for >700 hosts. }).exec((err)=>{ if(err) { sails.log.warn(`Background task failed: When a user submitted the "Talk to us" form, a lead/contact could not be updated in the CRM for this email address: ${emailAddress}.`, err); diff --git a/website/api/controllers/view-endpoint-ops.js b/website/api/controllers/view-endpoint-ops.js index c5e2ec892b06..fbeeda63aebb 100644 --- a/website/api/controllers/view-endpoint-ops.js +++ b/website/api/controllers/view-endpoint-ops.js @@ -22,13 +22,23 @@ module.exports = { } // Get testimonials for the <scrolalble-tweets> component. let testimonialsForScrollableTweets = _.clone(sails.config.builtStaticContent.testimonials); + // Default the pagePersonalization to the user's primaryBuyingSituation. + let pagePersonalization = this.req.session.primaryBuyingSituation; + // If a pageMode query parameter is set, update the pagePersonalization value. + // Note: This is the only page we're using this method instead of using the primaryBuyingSiutation value set in the users session. + // This lets us link to the security and IT versions of the endpoint ops page from the unpersonalized homepage without changing the users primaryBuyingSituation. + if(this.req.param('pageMode') === 'it'){ + pagePersonalization = 'eo-it'; + } else if(this.req.param('pageMode') === 'security'){ + pagePersonalization = 'eo-security'; + } // Specify an order for the testimonials on this page using the last names of quote authors - let testimonialOrderForThisPage = ['Charles Zaffery','Dan Grzelak','Nico Waisman','Tom Larkin','Austin Anderson','Erik Gomez','Nick Fohs','Brendan Shaklovitz','Mike Arpaia','Andre Shields','Dhruv Majumdar','Ahmed Elshaer','Abubakar Yousafzai','Harrison Ravazzolo','Wes Whetstone','Kenny Botelho', 'Chandra Majumdar','Eric Tan', 'Alvaro Gutierrez', 'Joe Pistone']; - if(['eo-it', 'mdm'].includes(this.req.session.primaryBuyingSituation)){ - testimonialOrderForThisPage = [ 'Harrison Ravazzolo', 'Eric Tan','Erik Gomez', 'Tom Larkin', 'Nick Fohs', 'Wes Whetstone', 'Mike Arpaia', 'Kenny Botelho', 'Alvaro Gutierrez']; - } else if(['eo-security', 'vm'].includes(this.req.session.primaryBuyingSituation)){ + let testimonialOrderForThisPage = ['Charles Zaffery','Dan Grzelak','Nico Waisman','Tom Larkin','Austin Anderson','Erik Gomez','Nick Fohs','Brendan Shaklovitz','Mike Arpaia','Andre Shields','Dhruv Majumdar','Ahmed Elshaer','Abubakar Yousafzai','Wes Whetstone','Kenny Botelho', 'Chandra Majumdar','Eric Tan', 'Alvaro Gutierrez', 'Joe Pistone']; + if(['eo-it', 'mdm'].includes(pagePersonalization)){ + testimonialOrderForThisPage = [ 'Eric Tan','Erik Gomez', 'Tom Larkin', 'Nick Fohs', 'Wes Whetstone', 'Mike Arpaia', 'Kenny Botelho', 'Alvaro Gutierrez']; + } else if(['eo-security', 'vm'].includes(pagePersonalization)){ testimonialOrderForThisPage = ['Nico Waisman','Charles Zaffery','Abubakar Yousafzai','Eric Tan','Mike Arpaia','Chandra Majumdar','Ahmed Elshaer','Brendan Shaklovitz','Austin Anderson','Dan Grzelak','Dhruv Majumdar','Alvaro Gutierrez', 'Joe Pistone']; } // Filter the testimonials by product category and the filtered list we built above. @@ -48,6 +58,7 @@ module.exports = { // Respond with view. return { testimonialsForScrollableTweets, + pagePersonalization, }; } diff --git a/website/api/controllers/webhooks/receive-from-github.js b/website/api/controllers/webhooks/receive-from-github.js index 3923a50a06f8..4dcbad029b2c 100644 --- a/website/api/controllers/webhooks/receive-from-github.js +++ b/website/api/controllers/webhooks/receive-from-github.js @@ -89,6 +89,8 @@ module.exports = { 'SFriendLee', 'ddribeiro', 'rebeccaui', + 'allenhouchins', + 'harrisonravazzolo', ]; let GREEN_LABEL_COLOR = 'C2E0C6';// « Used in multiple places below. (FUTURE: Use the "+" prefix for this instead of color. 2022-05-05) diff --git a/website/api/controllers/webhooks/receive-usage-analytics.js b/website/api/controllers/webhooks/receive-usage-analytics.js index 9094c11d79bc..94f7fec744de 100644 --- a/website/api/controllers/webhooks/receive-usage-analytics.js +++ b/website/api/controllers/webhooks/receive-usage-analytics.js @@ -39,6 +39,10 @@ module.exports = { numHostSoftwareInstalledPaths: {type: 'number', defaultsTo: 0}, numSoftwareCPEs: {type: 'number', defaultsTo: 0}, numSoftwareCVEs: {type: 'number', defaultsTo: 0}, + aiFeaturesDisabled: {type: 'boolean', defaultsTo: false }, + maintenanceWindowsEnabled: {type: 'boolean', defaultsTo: false }, + maintenanceWindowsConfigured: {type: 'boolean', defaultsTo: false }, + numHostsFleetDesktopEnabled: {type: 'number', defaultsTo: 0 }, }, diff --git a/website/api/helpers/salesforce/update-or-create-contact-and-account-and-create-lead.js b/website/api/helpers/salesforce/update-or-create-contact-and-account-and-create-lead.js index 980ffae8c0b6..05e9b29c956f 100644 --- a/website/api/helpers/salesforce/update-or-create-contact-and-account-and-create-lead.js +++ b/website/api/helpers/salesforce/update-or-create-contact-and-account-and-create-lead.js @@ -30,6 +30,10 @@ module.exports = { '6 - Has team buy-in' ] }, + psychologicalStageChangeReason: { + type: 'string', + example: 'Website - Organic start flow' + }, // For new leads. leadDescription: { type: 'string', @@ -58,7 +62,7 @@ module.exports = { - fn: async function ({emailAddress, linkedinUrl, firstName, lastName, organization, primaryBuyingSituation, psychologicalStage, contactSource, leadDescription, numberOfHosts}) { + fn: async function ({emailAddress, linkedinUrl, firstName, lastName, organization, primaryBuyingSituation, psychologicalStage, psychologicalStageChangeReason, contactSource, leadDescription, numberOfHosts}) { if(sails.config.environment !== 'production') { sails.log('Skipping Salesforce integration...'); return; @@ -72,6 +76,7 @@ module.exports = { linkedinUrl, primaryBuyingSituation, psychologicalStage, + psychologicalStageChangeReason, contactSource, description: leadDescription, }); diff --git a/website/api/models/HistoricalUsageSnapshot.js b/website/api/models/HistoricalUsageSnapshot.js index 5481d42e5330..1f14b9b291cb 100644 --- a/website/api/models/HistoricalUsageSnapshot.js +++ b/website/api/models/HistoricalUsageSnapshot.js @@ -43,6 +43,10 @@ module.exports = { numHostSoftwareInstalledPaths: {required: true, type: 'number'}, numSoftwareCPEs: {required: true, type: 'number'}, numSoftwareCVEs: {required: true, type: 'number'}, + aiFeaturesDisabled: {required: true, type: 'boolean'}, + maintenanceWindowsEnabled: {required: true, type: 'boolean'}, + maintenanceWindowsConfigured: {required: true, type: 'boolean'}, + numHostsFleetDesktopEnabled: {required: true, type: 'number'}, // ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗ // ║╣ ║║║╠╩╗║╣ ║║╚═╗ diff --git a/website/assets/dependencies/docsearch.min.js b/website/assets/dependencies/docsearch.min.js index 336fc3e3038f..1851df2b633c 100644 --- a/website/assets/dependencies/docsearch.min.js +++ b/website/assets/dependencies/docsearch.min.js @@ -1,4 +1,3 @@ - -/*! @docsearch/js 3.5.1 | MIT License | © Algolia, Inc. and contributors | https://docsearch.algolia.com */ -!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e=e||self).docsearch=t()}(this,(function(){"use strict";function e(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function t(t){for(var n=1;n<arguments.length;n++){var o=null!=arguments[n]?arguments[n]:{};n%2?e(Object(o),!0).forEach((function(e){r(t,e,o[e])})):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(o)):e(Object(o)).forEach((function(e){Object.defineProperty(t,e,Object.getOwnPropertyDescriptor(o,e))}))}return t}function n(e){return n="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},n(e)}function r(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function o(){return o=Object.assign||function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},o.apply(this,arguments)}function i(e,t){if(null==e)return{};var n,r,o=function(e,t){if(null==e)return{};var n,r,o={},i=Object.keys(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}function c(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){var n=null==e?null:"undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(null==n)return;var r,o,i=[],c=!0,a=!1;try{for(n=n.call(e);!(c=(r=n.next()).done)&&(i.push(r.value),!t||i.length!==t);c=!0);}catch(e){a=!0,o=e}finally{try{c||null==n.return||n.return()}finally{if(a)throw o}}return i}(e,t)||u(e,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function a(e){return function(e){if(Array.isArray(e))return l(e)}(e)||function(e){if("undefined"!=typeof Symbol&&null!=e[Symbol.iterator]||null!=e["@@iterator"])return Array.from(e)}(e)||u(e)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function u(e,t){if(e){if("string"==typeof e)return l(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);return"Object"===n&&e.constructor&&(n=e.constructor.name),"Map"===n||"Set"===n?Array.from(e):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?l(e,t):void 0}}function l(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n<t;n++)r[n]=e[n];return r}var s,f,p,m,v,d={},h=[],y=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i;function b(e,t){for(var n in t)e[n]=t[n];return e}function _(e){var t=e.parentNode;t&&t.removeChild(e)}function g(e,t,n){var r,o,i,c=arguments,a={};for(i in t)"key"==i?r=t[i]:"ref"==i?o=t[i]:a[i]=t[i];if(arguments.length>3)for(n=[n],i=3;i<arguments.length;i++)n.push(c[i]);if(null!=n&&(a.children=n),"function"==typeof e&&null!=e.defaultProps)for(i in e.defaultProps)void 0===a[i]&&(a[i]=e.defaultProps[i]);return O(e,a,r,o,null)}function O(e,t,n,r,o){var i={type:e,props:t,key:n,ref:r,__k:null,__:null,__b:0,__e:null,__d:void 0,__c:null,__h:null,constructor:void 0,__v:null==o?++s.__v:o};return null!=s.vnode&&s.vnode(i),i}function S(e){return e.children}function j(e,t){this.props=e,this.context=t}function w(e,t){if(null==t)return e.__?w(e.__,e.__.__k.indexOf(e)+1):null;for(var n;t<e.__k.length;t++)if(null!=(n=e.__k[t])&&null!=n.__e)return n.__e;return"function"==typeof e.type?w(e):null}function E(e){var t,n;if(null!=(e=e.__)&&null!=e.__c){for(e.__e=e.__c.base=null,t=0;t<e.__k.length;t++)if(null!=(n=e.__k[t])&&null!=n.__e){e.__e=e.__c.base=n.__e;break}return E(e)}}function P(e){(!e.__d&&(e.__d=!0)&&f.push(e)&&!I.__r++||m!==s.debounceRendering)&&((m=s.debounceRendering)||p)(I)}function I(){for(var e;I.__r=f.length;)e=f.sort((function(e,t){return e.__v.__b-t.__v.__b})),f=[],e.some((function(e){var t,n,r,o,i,c;e.__d&&(i=(o=(t=e).__v).__e,(c=t.__P)&&(n=[],(r=b({},o)).__v=o.__v+1,q(c,o,r,t.__n,void 0!==c.ownerSVGElement,null!=o.__h?[i]:null,n,null==i?w(o):i,o.__h),L(n,o),o.__e!=i&&E(o)))}))}function D(e,t,n,r,o,i,c,a,u,l){var s,f,p,m,v,y,b,_=r&&r.__k||h,g=_.length;for(n.__k=[],s=0;s<t.length;s++)if(null!=(m=n.__k[s]=null==(m=t[s])||"boolean"==typeof m?null:"string"==typeof m||"number"==typeof m?O(null,m,null,null,m):Array.isArray(m)?O(S,{children:m},null,null,null):m.__b>0?O(m.type,m.props,m.key,null,m.__v):m)){if(m.__=n,m.__b=n.__b+1,null===(p=_[s])||p&&m.key==p.key&&m.type===p.type)_[s]=void 0;else for(f=0;f<g;f++){if((p=_[f])&&m.key==p.key&&m.type===p.type){_[f]=void 0;break}p=null}q(e,m,p=p||d,o,i,c,a,u,l),v=m.__e,(f=m.ref)&&p.ref!=f&&(b||(b=[]),p.ref&&b.push(p.ref,null,m),b.push(f,m.__c||v,m)),null!=v?(null==y&&(y=v),"function"==typeof m.type&&null!=m.__k&&m.__k===p.__k?m.__d=u=k(m,u,e):u=C(e,m,p,_,v,u),l||"option"!==n.type?"function"==typeof n.type&&(n.__d=u):e.value=""):u&&p.__e==u&&u.parentNode!=e&&(u=w(p))}for(n.__e=y,s=g;s--;)null!=_[s]&&("function"==typeof n.type&&null!=_[s].__e&&_[s].__e==n.__d&&(n.__d=w(r,s+1)),U(_[s],_[s]));if(b)for(s=0;s<b.length;s++)H(b[s],b[++s],b[++s])}function k(e,t,n){var r,o;for(r=0;r<e.__k.length;r++)(o=e.__k[r])&&(o.__=e,t="function"==typeof o.type?k(o,t,n):C(n,o,o,e.__k,o.__e,t));return t}function A(e,t){return t=t||[],null==e||"boolean"==typeof e||(Array.isArray(e)?e.some((function(e){A(e,t)})):t.push(e)),t}function C(e,t,n,r,o,i){var c,a,u;if(void 0!==t.__d)c=t.__d,t.__d=void 0;else if(null==n||o!=i||null==o.parentNode)e:if(null==i||i.parentNode!==e)e.appendChild(o),c=null;else{for(a=i,u=0;(a=a.nextSibling)&&u<r.length;u+=2)if(a==o)break e;e.insertBefore(o,i),c=i}return void 0!==c?c:o.nextSibling}function x(e,t,n){"-"===t[0]?e.setProperty(t,n):e[t]=null==n?"":"number"!=typeof n||y.test(t)?n:n+"px"}function N(e,t,n,r,o){var i;e:if("style"===t)if("string"==typeof n)e.style.cssText=n;else{if("string"==typeof r&&(e.style.cssText=r=""),r)for(t in r)n&&t in n||x(e.style,t,"");if(n)for(t in n)r&&n[t]===r[t]||x(e.style,t,n[t])}else if("o"===t[0]&&"n"===t[1])i=t!==(t=t.replace(/Capture$/,"")),t=t.toLowerCase()in e?t.toLowerCase().slice(2):t.slice(2),e.l||(e.l={}),e.l[t+i]=n,n?r||e.addEventListener(t,i?R:T,i):e.removeEventListener(t,i?R:T,i);else if("dangerouslySetInnerHTML"!==t){if(o)t=t.replace(/xlink[H:h]/,"h").replace(/sName$/,"s");else if("href"!==t&&"list"!==t&&"form"!==t&&"download"!==t&&t in e)try{e[t]=null==n?"":n;break e}catch(e){}"function"==typeof n||(null!=n&&(!1!==n||"a"===t[0]&&"r"===t[1])?e.setAttribute(t,n):e.removeAttribute(t))}}function T(e){this.l[e.type+!1](s.event?s.event(e):e)}function R(e){this.l[e.type+!0](s.event?s.event(e):e)}function q(e,t,n,r,o,i,c,a,u){var l,f,p,m,v,d,h,y,_,g,O,w=t.type;if(void 0!==t.constructor)return null;null!=n.__h&&(u=n.__h,a=t.__e=n.__e,t.__h=null,i=[a]),(l=s.__b)&&l(t);try{e:if("function"==typeof w){if(y=t.props,_=(l=w.contextType)&&r[l.__c],g=l?_?_.props.value:l.__:r,n.__c?h=(f=t.__c=n.__c).__=f.__E:("prototype"in w&&w.prototype.render?t.__c=f=new w(y,g):(t.__c=f=new j(y,g),f.constructor=w,f.render=F),_&&_.sub(f),f.props=y,f.state||(f.state={}),f.context=g,f.__n=r,p=f.__d=!0,f.__h=[]),null==f.__s&&(f.__s=f.state),null!=w.getDerivedStateFromProps&&(f.__s==f.state&&(f.__s=b({},f.__s)),b(f.__s,w.getDerivedStateFromProps(y,f.__s))),m=f.props,v=f.state,p)null==w.getDerivedStateFromProps&&null!=f.componentWillMount&&f.componentWillMount(),null!=f.componentDidMount&&f.__h.push(f.componentDidMount);else{if(null==w.getDerivedStateFromProps&&y!==m&&null!=f.componentWillReceiveProps&&f.componentWillReceiveProps(y,g),!f.__e&&null!=f.shouldComponentUpdate&&!1===f.shouldComponentUpdate(y,f.__s,g)||t.__v===n.__v){f.props=y,f.state=f.__s,t.__v!==n.__v&&(f.__d=!1),f.__v=t,t.__e=n.__e,t.__k=n.__k,f.__h.length&&c.push(f);break e}null!=f.componentWillUpdate&&f.componentWillUpdate(y,f.__s,g),null!=f.componentDidUpdate&&f.__h.push((function(){f.componentDidUpdate(m,v,d)}))}f.context=g,f.props=y,f.state=f.__s,(l=s.__r)&&l(t),f.__d=!1,f.__v=t,f.__P=e,l=f.render(f.props,f.state,f.context),f.state=f.__s,null!=f.getChildContext&&(r=b(b({},r),f.getChildContext())),p||null==f.getSnapshotBeforeUpdate||(d=f.getSnapshotBeforeUpdate(m,v)),O=null!=l&&l.type===S&&null==l.key?l.props.children:l,D(e,Array.isArray(O)?O:[O],t,n,r,o,i,c,a,u),f.base=t.__e,t.__h=null,f.__h.length&&c.push(f),h&&(f.__E=f.__=null),f.__e=!1}else null==i&&t.__v===n.__v?(t.__k=n.__k,t.__e=n.__e):t.__e=M(n.__e,t,n,r,o,i,c,u);(l=s.diffed)&&l(t)}catch(e){t.__v=null,(u||null!=i)&&(t.__e=a,t.__h=!!u,i[i.indexOf(a)]=null),s.__e(e,t,n)}}function L(e,t){s.__c&&s.__c(t,e),e.some((function(t){try{e=t.__h,t.__h=[],e.some((function(e){e.call(t)}))}catch(e){s.__e(e,t.__v)}}))}function M(e,t,n,r,o,i,c,a){var u,l,s,f,p=n.props,m=t.props,v=t.type,y=0;if("svg"===v&&(o=!0),null!=i)for(;y<i.length;y++)if((u=i[y])&&(u===e||(v?u.localName==v:3==u.nodeType))){e=u,i[y]=null;break}if(null==e){if(null===v)return document.createTextNode(m);e=o?document.createElementNS("http://www.w3.org/2000/svg",v):document.createElement(v,m.is&&m),i=null,a=!1}if(null===v)p===m||a&&e.data===m||(e.data=m);else{if(i=i&&h.slice.call(e.childNodes),l=(p=n.props||d).dangerouslySetInnerHTML,s=m.dangerouslySetInnerHTML,!a){if(null!=i)for(p={},f=0;f<e.attributes.length;f++)p[e.attributes[f].name]=e.attributes[f].value;(s||l)&&(s&&(l&&s.__html==l.__html||s.__html===e.innerHTML)||(e.innerHTML=s&&s.__html||""))}if(function(e,t,n,r,o){var i;for(i in n)"children"===i||"key"===i||i in t||N(e,i,null,n[i],r);for(i in t)o&&"function"!=typeof t[i]||"children"===i||"key"===i||"value"===i||"checked"===i||n[i]===t[i]||N(e,i,t[i],n[i],r)}(e,m,p,o,a),s)t.__k=[];else if(y=t.props.children,D(e,Array.isArray(y)?y:[y],t,n,r,o&&"foreignObject"!==v,i,c,e.firstChild,a),null!=i)for(y=i.length;y--;)null!=i[y]&&_(i[y]);a||("value"in m&&void 0!==(y=m.value)&&(y!==e.value||"progress"===v&&!y)&&N(e,"value",y,p.value,!1),"checked"in m&&void 0!==(y=m.checked)&&y!==e.checked&&N(e,"checked",y,p.checked,!1))}return e}function H(e,t,n){try{"function"==typeof e?e(t):e.current=t}catch(e){s.__e(e,n)}}function U(e,t,n){var r,o,i;if(s.unmount&&s.unmount(e),(r=e.ref)&&(r.current&&r.current!==e.__e||H(r,null,t)),n||"function"==typeof e.type||(n=null!=(o=e.__e)),e.__e=e.__d=void 0,null!=(r=e.__c)){if(r.componentWillUnmount)try{r.componentWillUnmount()}catch(e){s.__e(e,t)}r.base=r.__P=null}if(r=e.__k)for(i=0;i<r.length;i++)r[i]&&U(r[i],t,n);null!=o&&_(o)}function F(e,t,n){return this.constructor(e,n)}function B(e,t,n){var r,o,i;s.__&&s.__(e,t),o=(r="function"==typeof n)?null:n&&n.__k||t.__k,i=[],q(t,e=(!r&&n||t).__k=g(S,null,[e]),o||d,d,void 0!==t.ownerSVGElement,!r&&n?[n]:o?null:t.firstChild?h.slice.call(t.childNodes):null,i,!r&&n?n:o?o.__e:t.firstChild,r),L(i,e)}function V(e,t){B(e,t,V)}function W(e,t,n){var r,o,i,c=arguments,a=b({},e.props);for(i in t)"key"==i?r=t[i]:"ref"==i?o=t[i]:a[i]=t[i];if(arguments.length>3)for(n=[n],i=3;i<arguments.length;i++)n.push(c[i]);return null!=n&&(a.children=n),O(e.type,a,r||e.key,o||e.ref,null)}s={__e:function(e,t){for(var n,r,o;t=t.__;)if((n=t.__c)&&!n.__)try{if((r=n.constructor)&&null!=r.getDerivedStateFromError&&(n.setState(r.getDerivedStateFromError(e)),o=n.__d),null!=n.componentDidCatch&&(n.componentDidCatch(e),o=n.__d),o)return n.__E=n}catch(t){e=t}throw e},__v:0},j.prototype.setState=function(e,t){var n;n=null!=this.__s&&this.__s!==this.state?this.__s:this.__s=b({},this.state),"function"==typeof e&&(e=e(b({},n),this.props)),e&&b(n,e),null!=e&&this.__v&&(t&&this.__h.push(t),P(this))},j.prototype.forceUpdate=function(e){this.__v&&(this.__e=!0,e&&this.__h.push(e),P(this))},j.prototype.render=S,f=[],p="function"==typeof Promise?Promise.prototype.then.bind(Promise.resolve()):setTimeout,I.__r=0,v=0;var K,z,J,$=0,Q=[],Z=s.__b,Y=s.__r,G=s.diffed,X=s.__c,ee=s.unmount;function te(e,t){s.__h&&s.__h(z,e,$||t),$=0;var n=z.__H||(z.__H={__:[],__h:[]});return e>=n.__.length&&n.__.push({}),n.__[e]}function ne(e){return $=1,re(pe,e)}function re(e,t,n){var r=te(K++,2);return r.t=e,r.__c||(r.__=[n?n(t):pe(void 0,t),function(e){var t=r.t(r.__[0],e);r.__[0]!==t&&(r.__=[t,r.__[1]],r.__c.setState({}))}],r.__c=z),r.__}function oe(e,t){var n=te(K++,3);!s.__s&&fe(n.__H,t)&&(n.__=e,n.__H=t,z.__H.__h.push(n))}function ie(e,t){var n=te(K++,4);!s.__s&&fe(n.__H,t)&&(n.__=e,n.__H=t,z.__h.push(n))}function ce(e,t){var n=te(K++,7);return fe(n.__H,t)&&(n.__=e(),n.__H=t,n.__h=e),n.__}function ae(){Q.forEach((function(e){if(e.__P)try{e.__H.__h.forEach(le),e.__H.__h.forEach(se),e.__H.__h=[]}catch(t){e.__H.__h=[],s.__e(t,e.__v)}})),Q=[]}s.__b=function(e){z=null,Z&&Z(e)},s.__r=function(e){Y&&Y(e),K=0;var t=(z=e.__c).__H;t&&(t.__h.forEach(le),t.__h.forEach(se),t.__h=[])},s.diffed=function(e){G&&G(e);var t=e.__c;t&&t.__H&&t.__H.__h.length&&(1!==Q.push(t)&&J===s.requestAnimationFrame||((J=s.requestAnimationFrame)||function(e){var t,n=function(){clearTimeout(r),ue&&cancelAnimationFrame(t),setTimeout(e)},r=setTimeout(n,100);ue&&(t=requestAnimationFrame(n))})(ae)),z=void 0},s.__c=function(e,t){t.some((function(e){try{e.__h.forEach(le),e.__h=e.__h.filter((function(e){return!e.__||se(e)}))}catch(n){t.some((function(e){e.__h&&(e.__h=[])})),t=[],s.__e(n,e.__v)}})),X&&X(e,t)},s.unmount=function(e){ee&&ee(e);var t=e.__c;if(t&&t.__H)try{t.__H.__.forEach(le)}catch(e){s.__e(e,t.__v)}};var ue="function"==typeof requestAnimationFrame;function le(e){var t=z;"function"==typeof e.__c&&e.__c(),z=t}function se(e){var t=z;e.__c=e.__(),z=t}function fe(e,t){return!e||e.length!==t.length||t.some((function(t,n){return t!==e[n]}))}function pe(e,t){return"function"==typeof t?t(e):t}function me(e,t){for(var n in t)e[n]=t[n];return e}function ve(e,t){for(var n in e)if("__source"!==n&&!(n in t))return!0;for(var r in t)if("__source"!==r&&e[r]!==t[r])return!0;return!1}function de(e){this.props=e}(de.prototype=new j).isPureReactComponent=!0,de.prototype.shouldComponentUpdate=function(e,t){return ve(this.props,e)||ve(this.state,t)};var he=s.__b;s.__b=function(e){e.type&&e.type.__f&&e.ref&&(e.props.ref=e.ref,e.ref=null),he&&he(e)};var ye="undefined"!=typeof Symbol&&Symbol.for&&Symbol.for("react.forward_ref")||3911;var be=function(e,t){return null==e?null:A(A(e).map(t))},_e={map:be,forEach:be,count:function(e){return e?A(e).length:0},only:function(e){var t=A(e);if(1!==t.length)throw"Children.only";return t[0]},toArray:A},ge=s.__e;function Oe(){this.__u=0,this.t=null,this.__b=null}function Se(e){var t=e.__.__c;return t&&t.__e&&t.__e(e)}function je(){this.u=null,this.o=null}s.__e=function(e,t,n){if(e.then)for(var r,o=t;o=o.__;)if((r=o.__c)&&r.__c)return null==t.__e&&(t.__e=n.__e,t.__k=n.__k),r.__c(e,t);ge(e,t,n)},(Oe.prototype=new j).__c=function(e,t){var n=t.__c,r=this;null==r.t&&(r.t=[]),r.t.push(n);var o=Se(r.__v),i=!1,c=function(){i||(i=!0,n.componentWillUnmount=n.__c,o?o(a):a())};n.__c=n.componentWillUnmount,n.componentWillUnmount=function(){c(),n.__c&&n.__c()};var a=function(){if(!--r.__u){if(r.state.__e){var e=r.state.__e;r.__v.__k[0]=function e(t,n,r){return t&&(t.__v=null,t.__k=t.__k&&t.__k.map((function(t){return e(t,n,r)})),t.__c&&t.__c.__P===n&&(t.__e&&r.insertBefore(t.__e,t.__d),t.__c.__e=!0,t.__c.__P=r)),t}(e,e.__c.__P,e.__c.__O)}var t;for(r.setState({__e:r.__b=null});t=r.t.pop();)t.forceUpdate()}},u=!0===t.__h;r.__u++||u||r.setState({__e:r.__b=r.__v.__k[0]}),e.then(c,c)},Oe.prototype.componentWillUnmount=function(){this.t=[]},Oe.prototype.render=function(e,t){if(this.__b){if(this.__v.__k){var n=document.createElement("div"),r=this.__v.__k[0].__c;this.__v.__k[0]=function e(t,n,r){return t&&(t.__c&&t.__c.__H&&(t.__c.__H.__.forEach((function(e){"function"==typeof e.__c&&e.__c()})),t.__c.__H=null),null!=(t=me({},t)).__c&&(t.__c.__P===r&&(t.__c.__P=n),t.__c=null),t.__k=t.__k&&t.__k.map((function(t){return e(t,n,r)}))),t}(this.__b,n,r.__O=r.__P)}this.__b=null}var o=t.__e&&g(S,null,e.fallback);return o&&(o.__h=null),[g(S,null,t.__e?null:e.children),o]};var we=function(e,t,n){if(++n[1]===n[0]&&e.o.delete(t),e.props.revealOrder&&("t"!==e.props.revealOrder[0]||!e.o.size))for(n=e.u;n;){for(;n.length>3;)n.pop()();if(n[1]<n[0])break;e.u=n=n[2]}};function Ee(e){return this.getChildContext=function(){return e.context},e.children}function Pe(e){var t=this,n=e.i;t.componentWillUnmount=function(){B(null,t.l),t.l=null,t.i=null},t.i&&t.i!==n&&t.componentWillUnmount(),e.__v?(t.l||(t.i=n,t.l={nodeType:1,parentNode:n,childNodes:[],appendChild:function(e){this.childNodes.push(e),t.i.appendChild(e)},insertBefore:function(e,n){this.childNodes.push(e),t.i.appendChild(e)},removeChild:function(e){this.childNodes.splice(this.childNodes.indexOf(e)>>>1,1),t.i.removeChild(e)}}),B(g(Ee,{context:t.context},e.__v),t.l)):t.l&&t.componentWillUnmount()}function Ie(e,t){return g(Pe,{__v:e,i:t})}(je.prototype=new j).__e=function(e){var t=this,n=Se(t.__v),r=t.o.get(e);return r[0]++,function(o){var i=function(){t.props.revealOrder?(r.push(o),we(t,e,r)):o()};n?n(i):i()}},je.prototype.render=function(e){this.u=null,this.o=new Map;var t=A(e.children);e.revealOrder&&"b"===e.revealOrder[0]&&t.reverse();for(var n=t.length;n--;)this.o.set(t[n],this.u=[1,0,this.u]);return e.children},je.prototype.componentDidUpdate=je.prototype.componentDidMount=function(){var e=this;this.o.forEach((function(t,n){we(e,n,t)}))};var De="undefined"!=typeof Symbol&&Symbol.for&&Symbol.for("react.element")||60103,ke=/^(?:accent|alignment|arabic|baseline|cap|clip(?!PathU)|color|fill|flood|font|glyph(?!R)|horiz|marker(?!H|W|U)|overline|paint|stop|strikethrough|stroke|text(?!L)|underline|unicode|units|v|vector|vert|word|writing|x(?!C))[A-Z]/,Ae=function(e){return("undefined"!=typeof Symbol&&"symbol"==n(Symbol())?/fil|che|rad/i:/fil|che|ra/i).test(e)};function Ce(e,t,n){return null==t.__k&&(t.textContent=""),B(e,t),"function"==typeof n&&n(),e?e.__c:null}j.prototype.isReactComponent={},["componentWillMount","componentWillReceiveProps","componentWillUpdate"].forEach((function(e){Object.defineProperty(j.prototype,e,{configurable:!0,get:function(){return this["UNSAFE_"+e]},set:function(t){Object.defineProperty(this,e,{configurable:!0,writable:!0,value:t})}})}));var xe=s.event;function Ne(){}function Te(){return this.cancelBubble}function Re(){return this.defaultPrevented}s.event=function(e){return xe&&(e=xe(e)),e.persist=Ne,e.isPropagationStopped=Te,e.isDefaultPrevented=Re,e.nativeEvent=e};var qe,Le={configurable:!0,get:function(){return this.class}},Me=s.vnode;s.vnode=function(e){var t=e.type,n=e.props,r=n;if("string"==typeof t){for(var o in r={},n){var i=n[o];"value"===o&&"defaultValue"in n&&null==i||("defaultValue"===o&&"value"in n&&null==n.value?o="value":"download"===o&&!0===i?i="":/ondoubleclick/i.test(o)?o="ondblclick":/^onchange(textarea|input)/i.test(o+t)&&!Ae(n.type)?o="oninput":/^on(Ani|Tra|Tou|BeforeInp)/.test(o)?o=o.toLowerCase():ke.test(o)?o=o.replace(/[A-Z0-9]/,"-$&").toLowerCase():null===i&&(i=void 0),r[o]=i)}"select"==t&&r.multiple&&Array.isArray(r.value)&&(r.value=A(n.children).forEach((function(e){e.props.selected=-1!=r.value.indexOf(e.props.value)}))),"select"==t&&null!=r.defaultValue&&(r.value=A(n.children).forEach((function(e){e.props.selected=r.multiple?-1!=r.defaultValue.indexOf(e.props.value):r.defaultValue==e.props.value}))),e.props=r}t&&n.class!=n.className&&(Le.enumerable="className"in n,null!=n.className&&(r.class=n.className),Object.defineProperty(r,"className",Le)),e.$$typeof=De,Me&&Me(e)};var He=s.__r;s.__r=function(e){He&&He(e),qe=e.__c};var Ue={ReactCurrentDispatcher:{current:{readContext:function(e){return qe.__n[e.__c].props.value}}}};"object"==("undefined"==typeof performance?"undefined":n(performance))&&"function"==typeof performance.now&&performance.now.bind(performance);function Fe(e){return!!e&&e.$$typeof===De}var Be={useState:ne,useReducer:re,useEffect:oe,useLayoutEffect:ie,useRef:function(e){return $=5,ce((function(){return{current:e}}),[])},useImperativeHandle:function(e,t,n){$=6,ie((function(){"function"==typeof e?e(t()):e&&(e.current=t())}),null==n?n:n.concat(e))},useMemo:ce,useCallback:function(e,t){return $=8,ce((function(){return e}),t)},useContext:function(e){var t=z.context[e.__c],n=te(K++,9);return n.__c=e,t?(null==n.__&&(n.__=!0,t.sub(z)),t.props.value):e.__},useDebugValue:function(e,t){s.useDebugValue&&s.useDebugValue(t?t(e):e)},version:"16.8.0",Children:_e,render:Ce,hydrate:function(e,t,n){return V(e,t),"function"==typeof n&&n(),e?e.__c:null},unmountComponentAtNode:function(e){return!!e.__k&&(B(null,e),!0)},createPortal:Ie,createElement:g,createContext:function(e,t){var n={__c:t="__cC"+v++,__:e,Consumer:function(e,t){return e.children(t)},Provider:function(e){var n,r;return this.getChildContext||(n=[],(r={})[t]=this,this.getChildContext=function(){return r},this.shouldComponentUpdate=function(e){this.props.value!==e.value&&n.some(P)},this.sub=function(e){n.push(e);var t=e.componentWillUnmount;e.componentWillUnmount=function(){n.splice(n.indexOf(e),1),t&&t.call(e)}}),e.children}};return n.Provider.__=n.Consumer.contextType=n},createFactory:function(e){return g.bind(null,e)},cloneElement:function(e){return Fe(e)?W.apply(null,arguments):e},createRef:function(){return{current:null}},Fragment:S,isValidElement:Fe,findDOMNode:function(e){return e&&(e.base||1===e.nodeType&&e)||null},Component:j,PureComponent:de,memo:function(e,t){function n(e){var n=this.props.ref,r=n==e.ref;return!r&&n&&(n.call?n(null):n.current=null),t?!t(this.props,e)||!r:ve(this.props,e)}function r(t){return this.shouldComponentUpdate=n,g(e,t)}return r.displayName="Memo("+(e.displayName||e.name)+")",r.prototype.isReactComponent=!0,r.__f=!0,r},forwardRef:function(e){function t(t,r){var o=me({},t);return delete o.ref,e(o,(r=t.ref||r)&&("object"!=n(r)||"current"in r)?r:null)}return t.$$typeof=ye,t.render=t,t.prototype.isReactComponent=t.__f=!0,t.displayName="ForwardRef("+(e.displayName||e.name)+")",t},unstable_batchedUpdates:function(e,t){return e(t)},StrictMode:S,Suspense:Oe,SuspenseList:je,lazy:function(e){var t,n,r;function o(o){if(t||(t=e()).then((function(e){n=e.default||e}),(function(e){r=e})),r)throw r;if(!n)throw t;return g(n,o)}return o.displayName="Lazy",o.__f=!0,o},__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED:Ue};function Ve(){return Be.createElement("svg",{width:"15",height:"15",className:"DocSearch-Control-Key-Icon"},Be.createElement("path",{d:"M4.505 4.496h2M5.505 5.496v5M8.216 4.496l.055 5.993M10 7.5c.333.333.5.667.5 1v2M12.326 4.5v5.996M8.384 4.496c1.674 0 2.116 0 2.116 1.5s-.442 1.5-2.116 1.5M3.205 9.303c-.09.448-.277 1.21-1.241 1.203C1 10.5.5 9.513.5 8V7c0-1.57.5-2.5 1.464-2.494.964.006 1.134.598 1.24 1.342M12.553 10.5h1.953",strokeWidth:"1.2",stroke:"currentColor",fill:"none",strokeLinecap:"square"}))}function We(){return Be.createElement("svg",{width:"20",height:"20",className:"DocSearch-Search-Icon",viewBox:"0 0 20 20"},Be.createElement("path",{d:"M14.386 14.386l4.0877 4.0877-4.0877-4.0877c-2.9418 2.9419-7.7115 2.9419-10.6533 0-2.9419-2.9418-2.9419-7.7115 0-10.6533 2.9418-2.9419 7.7115-2.9419 10.6533 0 2.9419 2.9418 2.9419 7.7115 0 10.6533z",stroke:"currentColor",fill:"none",fillRule:"evenodd",strokeLinecap:"round",strokeLinejoin:"round"}))}var Ke=["translations"];function ze(){return ze=Object.assign||function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},ze.apply(this,arguments)}function Je(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){var n=null==e?null:"undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(null==n)return;var r,o,i=[],c=!0,a=!1;try{for(n=n.call(e);!(c=(r=n.next()).done)&&(i.push(r.value),!t||i.length!==t);c=!0);}catch(e){a=!0,o=e}finally{try{c||null==n.return||n.return()}finally{if(a)throw o}}return i}(e,t)||function(e,t){if(!e)return;if("string"==typeof e)return $e(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);"Object"===n&&e.constructor&&(n=e.constructor.name);if("Map"===n||"Set"===n)return Array.from(e);if("Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n))return $e(e,t)}(e,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function $e(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n<t;n++)r[n]=e[n];return r}function Qe(e,t){if(null==e)return{};var n,r,o=function(e,t){if(null==e)return{};var n,r,o={},i=Object.keys(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}var Ze="Ctrl";var Ye=Be.forwardRef((function(e,t){var n=e.translations,r=void 0===n?{}:n,o=Qe(e,Ke),i=r.buttonText,c=void 0===i?"Search":i,a=r.buttonAriaLabel,u=void 0===a?"Search":a,l=Je(ne(null),2),s=l[0],f=l[1];return oe((function(){"undefined"!=typeof navigator&&(/(Mac|iPhone|iPod|iPad)/i.test(navigator.platform)?f("⌘"):f(Ze))}),[]),Be.createElement("button",ze({type:"button",className:"DocSearch DocSearch-Button","aria-label":u},o,{ref:t}),Be.createElement("span",{className:"DocSearch-Button-Container"},Be.createElement(We,null),Be.createElement("span",{className:"DocSearch-Button-Placeholder"},c)),Be.createElement("span",{className:"DocSearch-Button-Keys"},null!==s&&Be.createElement(Be.Fragment,null,Be.createElement("kbd",{className:"DocSearch-Button-Key"},s===Ze?Be.createElement(Ve,null):s),Be.createElement("kbd",{className:"DocSearch-Button-Key"},"K"))))}));function Ge(e,t){var n=void 0;return function(){for(var r=arguments.length,o=new Array(r),i=0;i<r;i++)o[i]=arguments[i];n&&clearTimeout(n),n=setTimeout((function(){return e.apply(void 0,o)}),t)}}function Xe(e){return e.reduce((function(e,t){return e.concat(t)}),[])}var et=0;function tt(e){return 0===e.collections.length?0:e.collections.reduce((function(e,t){return e+t.items.length}),0)}function nt(e){return e!==Object(e)}function rt(e,t){if(e===t)return!0;if(nt(e)||nt(t)||"function"==typeof e||"function"==typeof t)return e===t;if(Object.keys(e).length!==Object.keys(t).length)return!1;for(var n=0,r=Object.keys(e);n<r.length;n++){var o=r[n];if(!(o in t))return!1;if(!rt(e[o],t[o]))return!1}return!0}var ot=function(){};var it=[{segment:"autocomplete-core",version:"1.9.3"}];function ct(e){var t=e.item,n=e.items;return{index:t.__autocomplete_indexName,items:[t],positions:[1+n.findIndex((function(e){return e.objectID===t.objectID}))],queryID:t.__autocomplete_queryID,algoliaSource:["autocomplete"]}}function at(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){var n=null==e?null:"undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(null!=n){var r,o,i,c,a=[],u=!0,l=!1;try{if(i=(n=n.call(e)).next,0===t){if(Object(n)!==n)return;u=!1}else for(;!(u=(r=i.call(n)).done)&&(a.push(r.value),a.length!==t);u=!0);}catch(e){l=!0,o=e}finally{try{if(!u&&null!=n.return&&(c=n.return(),Object(c)!==c))return}finally{if(l)throw o}}return a}}(e,t)||function(e,t){if(!e)return;if("string"==typeof e)return ut(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);"Object"===n&&e.constructor&&(n=e.constructor.name);if("Map"===n||"Set"===n)return Array.from(e);if("Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n))return ut(e,t)}(e,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function ut(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n<t;n++)r[n]=e[n];return r}var lt=["items"],st=["items"];function ft(e){return ft="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},ft(e)}function pt(e){return function(e){if(Array.isArray(e))return mt(e)}(e)||function(e){if("undefined"!=typeof Symbol&&null!=e[Symbol.iterator]||null!=e["@@iterator"])return Array.from(e)}(e)||function(e,t){if(!e)return;if("string"==typeof e)return mt(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);"Object"===n&&e.constructor&&(n=e.constructor.name);if("Map"===n||"Set"===n)return Array.from(e);if("Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n))return mt(e,t)}(e)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function mt(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n<t;n++)r[n]=e[n];return r}function vt(e,t){if(null==e)return{};var n,r,o=function(e,t){if(null==e)return{};var n,r,o={},i=Object.keys(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}function dt(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function ht(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?dt(Object(n),!0).forEach((function(t){yt(e,t,n[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):dt(Object(n)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))}))}return e}function yt(e,t,n){return(t=function(e){var t=function(e,t){if("object"!==ft(e)||null===e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var r=n.call(e,t||"default");if("object"!==ft(r))return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"===ft(t)?t:String(t)}(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function bt(e){for(var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:20,n=[],r=0;r<e.objectIDs.length;r+=t)n.push(ht(ht({},e),{},{objectIDs:e.objectIDs.slice(r,r+t)}));return n}function _t(e){return e.map((function(e){var t=e.items,n=vt(e,lt);return ht(ht({},n),{},{objectIDs:(null==t?void 0:t.map((function(e){return e.objectID})))||n.objectIDs})}))}function gt(e){var t,n,r,o=(t=at((e.version||"").split(".").map(Number),2),n=t[0],r=t[1],n>=3||2===n&&r>=4||1===n&&r>=10);function i(t,n,r){if(o&&void 0!==r){var i=r[0].__autocomplete_algoliaCredentials,c={"X-Algolia-Application-Id":i.appId,"X-Algolia-API-Key":i.apiKey};e.apply(void 0,[t].concat(pt(n),[{headers:c}]))}else e.apply(void 0,[t].concat(pt(n)))}return{init:function(t,n){e("init",{appId:t,apiKey:n})},setUserToken:function(t){e("setUserToken",t)},clickedObjectIDsAfterSearch:function(){for(var e=arguments.length,t=new Array(e),n=0;n<e;n++)t[n]=arguments[n];t.length>0&&i("clickedObjectIDsAfterSearch",_t(t),t[0].items)},clickedObjectIDs:function(){for(var e=arguments.length,t=new Array(e),n=0;n<e;n++)t[n]=arguments[n];t.length>0&&i("clickedObjectIDs",_t(t),t[0].items)},clickedFilters:function(){for(var t=arguments.length,n=new Array(t),r=0;r<t;r++)n[r]=arguments[r];n.length>0&&e.apply(void 0,["clickedFilters"].concat(n))},convertedObjectIDsAfterSearch:function(){for(var e=arguments.length,t=new Array(e),n=0;n<e;n++)t[n]=arguments[n];t.length>0&&i("convertedObjectIDsAfterSearch",_t(t),t[0].items)},convertedObjectIDs:function(){for(var e=arguments.length,t=new Array(e),n=0;n<e;n++)t[n]=arguments[n];t.length>0&&i("convertedObjectIDs",_t(t),t[0].items)},convertedFilters:function(){for(var t=arguments.length,n=new Array(t),r=0;r<t;r++)n[r]=arguments[r];n.length>0&&e.apply(void 0,["convertedFilters"].concat(n))},viewedObjectIDs:function(){for(var e=arguments.length,t=new Array(e),n=0;n<e;n++)t[n]=arguments[n];t.length>0&&t.reduce((function(e,t){var n=t.items,r=vt(t,st);return[].concat(pt(e),pt(bt(ht(ht({},r),{},{objectIDs:(null==n?void 0:n.map((function(e){return e.objectID})))||r.objectIDs})).map((function(e){return{items:n,payload:e}}))))}),[]).forEach((function(e){var t=e.items;return i("viewedObjectIDs",[e.payload],t)}))},viewedFilters:function(){for(var t=arguments.length,n=new Array(t),r=0;r<t;r++)n[r]=arguments[r];n.length>0&&e.apply(void 0,["viewedFilters"].concat(n))}}}function Ot(e){var t=e.items.reduce((function(e,t){var n;return e[t.__autocomplete_indexName]=(null!==(n=e[t.__autocomplete_indexName])&&void 0!==n?n:[]).concat(t),e}),{});return Object.keys(t).map((function(e){return{index:e,items:t[e],algoliaSource:["autocomplete"]}}))}function St(e){return e.objectID&&e.__autocomplete_indexName&&e.__autocomplete_queryID}function jt(e){return jt="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},jt(e)}function wt(e){return function(e){if(Array.isArray(e))return Et(e)}(e)||function(e){if("undefined"!=typeof Symbol&&null!=e[Symbol.iterator]||null!=e["@@iterator"])return Array.from(e)}(e)||function(e,t){if(!e)return;if("string"==typeof e)return Et(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);"Object"===n&&e.constructor&&(n=e.constructor.name);if("Map"===n||"Set"===n)return Array.from(e);if("Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n))return Et(e,t)}(e)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function Et(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n<t;n++)r[n]=e[n];return r}function Pt(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function It(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?Pt(Object(n),!0).forEach((function(t){Dt(e,t,n[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):Pt(Object(n)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))}))}return e}function Dt(e,t,n){return(t=function(e){var t=function(e,t){if("object"!==jt(e)||null===e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var r=n.call(e,t||"default");if("object"!==jt(r))return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"===jt(t)?t:String(t)}(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var kt="2.6.0",At="https://cdn.jsdelivr.net/npm/search-insights@".concat(kt,"/dist/search-insights.min.js"),Ct=Ge((function(e){var t=e.onItemsChange,n=e.items,r=e.insights,o=e.state;t({insights:r,insightsEvents:Ot({items:n}).map((function(e){return It({eventName:"Items Viewed"},e)})),state:o})}),400);function xt(e){var t=function(e){return It({onItemsChange:function(e){var t=e.insights,n=e.insightsEvents;t.viewedObjectIDs.apply(t,wt(n.map((function(e){return It(It({},e),{},{algoliaSource:[].concat(wt(e.algoliaSource||[]),["autocomplete-internal"])})}))))},onSelect:function(e){var t=e.insights,n=e.insightsEvents;t.clickedObjectIDsAfterSearch.apply(t,wt(n.map((function(e){return It(It({},e),{},{algoliaSource:[].concat(wt(e.algoliaSource||[]),["autocomplete-internal"])})}))))},onActive:ot},e)}(e),n=t.insightsClient,r=t.onItemsChange,o=t.onSelect,i=t.onActive,c=n;n||function(e){if("undefined"!=typeof window)e({window:window})}((function(e){var t=e.window,n=t.AlgoliaAnalyticsObject||"aa";"string"==typeof n&&(c=t[n]),c||(t.AlgoliaAnalyticsObject=n,t[n]||(t[n]=function(){t[n].queue||(t[n].queue=[]);for(var e=arguments.length,r=new Array(e),o=0;o<e;o++)r[o]=arguments[o];t[n].queue.push(r)}),t[n].version=kt,c=t[n],function(e){var t="[Autocomplete]: Could not load search-insights.js. Please load it manually following https://alg.li/insights-autocomplete";try{var n=e.document.createElement("script");n.async=!0,n.src=At,n.onerror=function(){console.error(t)},document.body.appendChild(n)}catch(e){console.error(t)}}(t))}));var a=gt(c),u={current:[]},l=Ge((function(e){var t=e.state;if(t.isOpen){var n=t.collections.reduce((function(e,t){return[].concat(wt(e),wt(t.items))}),[]).filter(St);rt(u.current.map((function(e){return e.objectID})),n.map((function(e){return e.objectID})))||(u.current=n,n.length>0&&Ct({onItemsChange:r,items:n,insights:a,state:t}))}}),0);return{name:"aa.algoliaInsightsPlugin",subscribe:function(e){var t=e.setContext,n=e.onSelect,r=e.onActive;c("addAlgoliaAgent","insights-plugin"),t({algoliaInsightsPlugin:{__algoliaSearchParameters:{clickAnalytics:!0},insights:a}}),n((function(e){var t=e.item,n=e.state,r=e.event;St(t)&&o({state:n,event:r,insights:a,item:t,insightsEvents:[It({eventName:"Item Selected"},ct({item:t,items:u.current}))]})})),r((function(e){var t=e.item,n=e.state,r=e.event;St(t)&&i({state:n,event:r,insights:a,item:t,insightsEvents:[It({eventName:"Item Active"},ct({item:t,items:u.current}))]})}))},onStateChange:function(e){var t=e.state;l({state:t})},__autocomplete_pluginOptions:e}}function Nt(e,t){var n=t;return{then:function(t,r){return Nt(e.then(Rt(t,n,e),Rt(r,n,e)),n)},catch:function(t){return Nt(e.catch(Rt(t,n,e)),n)},finally:function(t){return t&&n.onCancelList.push(t),Nt(e.finally(Rt(t&&function(){return n.onCancelList=[],t()},n,e)),n)},cancel:function(){n.isCanceled=!0;var e=n.onCancelList;n.onCancelList=[],e.forEach((function(e){e()}))},isCanceled:function(){return!0===n.isCanceled}}}function Tt(e){return Nt(e,{isCanceled:!1,onCancelList:[]})}function Rt(e,t,n){return e?function(n){return t.isCanceled?n:e(n)}:n}function qt(e,t,n,r){if(!n)return null;if(e<0&&(null===t||null!==r&&0===t))return n+e;var o=(null===t?-1:t)+e;return o<=-1||o>=n?null===r?null:0:o}function Lt(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function Mt(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?Lt(Object(n),!0).forEach((function(t){Ht(e,t,n[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):Lt(Object(n)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))}))}return e}function Ht(e,t,n){return(t=function(e){var t=function(e,t){if("object"!==Ut(e)||null===e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var r=n.call(e,t||"default");if("object"!==Ut(r))return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"===Ut(t)?t:String(t)}(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function Ut(e){return Ut="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},Ut(e)}function Ft(e){var t=function(e){var t=e.collections.map((function(e){return e.items.length})).reduce((function(e,t,n){var r=(e[n-1]||0)+t;return e.push(r),e}),[]).reduce((function(t,n){return n<=e.activeItemId?t+1:t}),0);return e.collections[t]}(e);if(!t)return null;var n=t.items[function(e){for(var t=e.state,n=e.collection,r=!1,o=0,i=0;!1===r;){var c=t.collections[o];if(c===n){r=!0;break}i+=c.items.length,o++}return t.activeItemId-i}({state:e,collection:t})],r=t.source;return{item:n,itemInputValue:r.getItemInputValue({item:n,state:e}),itemUrl:r.getItemUrl({item:n,state:e}),source:r}}var Bt=/((gt|sm)-|galaxy nexus)|samsung[- ]|samsungbrowser/i;function Vt(e){return Vt="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},Vt(e)}function Wt(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function Kt(e,t,n){return(t=function(e){var t=function(e,t){if("object"!==Vt(e)||null===e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var r=n.call(e,t||"default");if("object"!==Vt(r))return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"===Vt(t)?t:String(t)}(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function zt(e,t,n){var r,o=t.initialState;return{getState:function(){return o},dispatch:function(r,i){var c=function(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?Wt(Object(n),!0).forEach((function(t){Kt(e,t,n[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):Wt(Object(n)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))}))}return e}({},o);o=e(o,{type:r,props:t,payload:i}),n({state:o,prevState:c})},pendingRequests:(r=[],{add:function(e){return r.push(e),e.finally((function(){r=r.filter((function(t){return t!==e}))}))},cancelAll:function(){r.forEach((function(e){return e.cancel()}))},isEmpty:function(){return 0===r.length}})}}function Jt(e){return Jt="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},Jt(e)}function $t(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function Qt(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?$t(Object(n),!0).forEach((function(t){Zt(e,t,n[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):$t(Object(n)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))}))}return e}function Zt(e,t,n){return(t=function(e){var t=function(e,t){if("object"!==Jt(e)||null===e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var r=n.call(e,t||"default");if("object"!==Jt(r))return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"===Jt(t)?t:String(t)}(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function Yt(e){return Yt="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},Yt(e)}function Gt(e){return function(e){if(Array.isArray(e))return Xt(e)}(e)||function(e){if("undefined"!=typeof Symbol&&null!=e[Symbol.iterator]||null!=e["@@iterator"])return Array.from(e)}(e)||function(e,t){if(!e)return;if("string"==typeof e)return Xt(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);"Object"===n&&e.constructor&&(n=e.constructor.name);if("Map"===n||"Set"===n)return Array.from(e);if("Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n))return Xt(e,t)}(e)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function Xt(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n<t;n++)r[n]=e[n];return r}function en(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function tn(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?en(Object(n),!0).forEach((function(t){nn(e,t,n[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):en(Object(n)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))}))}return e}function nn(e,t,n){return(t=function(e){var t=function(e,t){if("object"!==Yt(e)||null===e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var r=n.call(e,t||"default");if("object"!==Yt(r))return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"===Yt(t)?t:String(t)}(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function rn(e,t){var n,r="undefined"!=typeof window?window:{},o=e.plugins||[];return tn(tn({debug:!1,openOnFocus:!1,placeholder:"",autoFocus:!1,defaultActiveItemId:null,stallThreshold:300,insights:!1,environment:r,shouldPanelOpen:function(e){return tt(e.state)>0},reshape:function(e){return e.sources}},e),{},{id:null!==(n=e.id)&&void 0!==n?n:"autocomplete-".concat(et++),plugins:o,initialState:tn({activeItemId:null,query:"",completion:null,collections:[],isOpen:!1,status:"idle",context:{}},e.initialState),onStateChange:function(t){var n;null===(n=e.onStateChange)||void 0===n||n.call(e,t),o.forEach((function(e){var n;return null===(n=e.onStateChange)||void 0===n?void 0:n.call(e,t)}))},onSubmit:function(t){var n;null===(n=e.onSubmit)||void 0===n||n.call(e,t),o.forEach((function(e){var n;return null===(n=e.onSubmit)||void 0===n?void 0:n.call(e,t)}))},onReset:function(t){var n;null===(n=e.onReset)||void 0===n||n.call(e,t),o.forEach((function(e){var n;return null===(n=e.onReset)||void 0===n?void 0:n.call(e,t)}))},getSources:function(n){return Promise.all([].concat(Gt(o.map((function(e){return e.getSources}))),[e.getSources]).filter(Boolean).map((function(e){return function(e,t){var n=[];return Promise.resolve(e(t)).then((function(e){return Promise.all(e.filter((function(e){return Boolean(e)})).map((function(e){if(e.sourceId,n.includes(e.sourceId))throw new Error("[Autocomplete] The `sourceId` ".concat(JSON.stringify(e.sourceId)," is not unique."));n.push(e.sourceId);var t={getItemInputValue:function(e){return e.state.query},getItemUrl:function(){},onSelect:function(e){(0,e.setIsOpen)(!1)},onActive:ot,onResolve:ot};Object.keys(t).forEach((function(e){t[e].__default=!0}));var r=Mt(Mt({},t),e);return Promise.resolve(r)})))}))}(e,n)}))).then((function(e){return Xe(e)})).then((function(e){return e.map((function(e){return tn(tn({},e),{},{onSelect:function(n){e.onSelect(n),t.forEach((function(e){var t;return null===(t=e.onSelect)||void 0===t?void 0:t.call(e,n)}))},onActive:function(n){e.onActive(n),t.forEach((function(e){var t;return null===(t=e.onActive)||void 0===t?void 0:t.call(e,n)}))},onResolve:function(n){e.onResolve(n),t.forEach((function(e){var t;return null===(t=e.onResolve)||void 0===t?void 0:t.call(e,n)}))}})}))}))},navigator:tn({navigate:function(e){var t=e.itemUrl;r.location.assign(t)},navigateNewTab:function(e){var t=e.itemUrl,n=r.open(t,"_blank","noopener");null==n||n.focus()},navigateNewWindow:function(e){var t=e.itemUrl;r.open(t,"_blank","noopener")}},e.navigator)})}function on(e){return on="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},on(e)}function cn(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function an(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?cn(Object(n),!0).forEach((function(t){un(e,t,n[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):cn(Object(n)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))}))}return e}function un(e,t,n){return(t=function(e){var t=function(e,t){if("object"!==on(e)||null===e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var r=n.call(e,t||"default");if("object"!==on(r))return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"===on(t)?t:String(t)}(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function ln(e){return ln="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},ln(e)}function sn(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function fn(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?sn(Object(n),!0).forEach((function(t){pn(e,t,n[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):sn(Object(n)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))}))}return e}function pn(e,t,n){return(t=function(e){var t=function(e,t){if("object"!==ln(e)||null===e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var r=n.call(e,t||"default");if("object"!==ln(r))return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"===ln(t)?t:String(t)}(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function mn(e){return function(e){if(Array.isArray(e))return vn(e)}(e)||function(e){if("undefined"!=typeof Symbol&&null!=e[Symbol.iterator]||null!=e["@@iterator"])return Array.from(e)}(e)||function(e,t){if(!e)return;if("string"==typeof e)return vn(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);"Object"===n&&e.constructor&&(n=e.constructor.name);if("Map"===n||"Set"===n)return Array.from(e);if("Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n))return vn(e,t)}(e)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function vn(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n<t;n++)r[n]=e[n];return r}function dn(e){return Boolean(e.execute)}function hn(e,t,n){if(o=e,Boolean(null==o?void 0:o.execute)){var r="algolia"===e.requesterId?Object.assign.apply(Object,[{}].concat(mn(Object.keys(n.context).map((function(e){var t;return null===(t=n.context[e])||void 0===t?void 0:t.__algoliaSearchParameters}))))):{};return fn(fn({},e),{},{requests:e.queries.map((function(n){return{query:"algolia"===e.requesterId?fn(fn({},n),{},{params:fn(fn({},r),n.params)}):n,sourceId:t,transformResponse:e.transformResponse}}))})}var o;return{items:e,sourceId:t}}function yn(e){var t=e.reduce((function(e,t){if(!dn(t))return e.push(t),e;var n=t.searchClient,r=t.execute,o=t.requesterId,i=t.requests,c=e.find((function(e){return dn(t)&&dn(e)&&e.searchClient===n&&Boolean(o)&&e.requesterId===o}));if(c){var a;(a=c.items).push.apply(a,mn(i))}else{var u={execute:r,requesterId:o,items:i,searchClient:n};e.push(u)}return e}),[]).map((function(e){if(!dn(e))return Promise.resolve(e);var t=e,n=t.execute,r=t.items;return n({searchClient:t.searchClient,requests:r})}));return Promise.all(t).then((function(e){return Xe(e)}))}function bn(e,t,n){return t.map((function(t){var r,o=e.filter((function(e){return e.sourceId===t.sourceId})),i=o.map((function(e){return e.items})),c=o[0].transformResponse,a=c?c({results:r=i,hits:r.map((function(e){return e.hits})).filter(Boolean),facetHits:r.map((function(e){var t;return null===(t=e.facetHits)||void 0===t?void 0:t.map((function(e){return{label:e.value,count:e.count,_highlightResult:{label:{value:e.highlighted}}}}))})).filter(Boolean)}):i;return t.onResolve({source:t,results:i,items:a,state:n.getState()}),a.every(Boolean),'The `getItems` function from source "'.concat(t.sourceId,'" must return an array of items but returned ').concat(JSON.stringify(void 0),".\n\nDid you forget to return items?\n\nSee: https://www.algolia.com/doc/ui-libraries/autocomplete/core-concepts/sources/#param-getitems"),{source:t,items:a}}))}function _n(e){return _n="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},_n(e)}var gn=["event","nextState","props","query","refresh","store"];function On(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function Sn(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?On(Object(n),!0).forEach((function(t){jn(e,t,n[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):On(Object(n)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))}))}return e}function jn(e,t,n){return(t=function(e){var t=function(e,t){if("object"!==_n(e)||null===e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var r=n.call(e,t||"default");if("object"!==_n(r))return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"===_n(t)?t:String(t)}(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function wn(e,t){if(null==e)return{};var n,r,o=function(e,t){if(null==e)return{};var n,r,o={},i=Object.keys(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}var En,Pn,In,Dn=null,kn=(En=-1,Pn=-1,In=void 0,function(e){var t=++En;return Promise.resolve(e).then((function(e){return In&&t<Pn?In:(Pn=t,In=e,e)}))});function An(e){var t=e.event,n=e.nextState,r=void 0===n?{}:n,o=e.props,i=e.query,c=e.refresh,a=e.store,u=wn(e,gn);Dn&&o.environment.clearTimeout(Dn);var l=u.setCollections,s=u.setIsOpen,f=u.setQuery,p=u.setActiveItemId,m=u.setStatus;if(f(i),p(o.defaultActiveItemId),!i&&!1===o.openOnFocus){var v,d=a.getState().collections.map((function(e){return Sn(Sn({},e),{},{items:[]})}));m("idle"),l(d),s(null!==(v=r.isOpen)&&void 0!==v?v:o.shouldPanelOpen({state:a.getState()}));var h=Tt(kn(d).then((function(){return Promise.resolve()})));return a.pendingRequests.add(h)}m("loading"),Dn=o.environment.setTimeout((function(){m("stalled")}),o.stallThreshold);var y=Tt(kn(o.getSources(Sn({query:i,refresh:c,state:a.getState()},u)).then((function(e){return Promise.all(e.map((function(e){return Promise.resolve(e.getItems(Sn({query:i,refresh:c,state:a.getState()},u))).then((function(t){return hn(t,e.sourceId,a.getState())}))}))).then(yn).then((function(t){return bn(t,e,a)})).then((function(e){return function(e){var t=e.collections,n=e.props,r=e.state,o=t.reduce((function(e,t){return an(an({},e),{},un({},t.source.sourceId,an(an({},t.source),{},{getItems:function(){return Xe(t.items)}})))}),{}),i=n.plugins.reduce((function(e,t){return t.reshape?t.reshape(e):e}),{sourcesBySourceId:o,state:r}).sourcesBySourceId;return Xe(n.reshape({sourcesBySourceId:i,sources:Object.values(i),state:r})).filter(Boolean).map((function(e){return{source:e,items:e.getItems()}}))}({collections:e,props:o,state:a.getState()})}))})))).then((function(e){var n;m("idle"),l(e);var f=o.shouldPanelOpen({state:a.getState()});s(null!==(n=r.isOpen)&&void 0!==n?n:o.openOnFocus&&!i&&f||f);var p=Ft(a.getState());if(null!==a.getState().activeItemId&&p){var v=p.item,d=p.itemInputValue,h=p.itemUrl,y=p.source;y.onActive(Sn({event:t,item:v,itemInputValue:d,itemUrl:h,refresh:c,source:y,state:a.getState()},u))}})).finally((function(){m("idle"),Dn&&o.environment.clearTimeout(Dn)}));return a.pendingRequests.add(y)}function Cn(e){return Cn="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},Cn(e)}var xn=["event","props","refresh","store"];function Nn(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function Tn(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?Nn(Object(n),!0).forEach((function(t){Rn(e,t,n[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):Nn(Object(n)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))}))}return e}function Rn(e,t,n){return(t=function(e){var t=function(e,t){if("object"!==Cn(e)||null===e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var r=n.call(e,t||"default");if("object"!==Cn(r))return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"===Cn(t)?t:String(t)}(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function qn(e,t){if(null==e)return{};var n,r,o=function(e,t){if(null==e)return{};var n,r,o={},i=Object.keys(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}function Ln(e){return Ln="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},Ln(e)}var Mn=["props","refresh","store"],Hn=["inputElement","formElement","panelElement"],Un=["inputElement"],Fn=["inputElement","maxLength"],Bn=["sourceIndex"],Vn=["sourceIndex"],Wn=["item","source","sourceIndex"];function Kn(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function zn(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?Kn(Object(n),!0).forEach((function(t){Jn(e,t,n[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):Kn(Object(n)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))}))}return e}function Jn(e,t,n){return(t=function(e){var t=function(e,t){if("object"!==Ln(e)||null===e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var r=n.call(e,t||"default");if("object"!==Ln(r))return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"===Ln(t)?t:String(t)}(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function $n(e,t){if(null==e)return{};var n,r,o=function(e,t){if(null==e)return{};var n,r,o={},i=Object.keys(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}function Qn(e){var t=e.props,n=e.refresh,r=e.store,o=$n(e,Mn),i=function(e,t){return void 0!==t?"".concat(e,"-").concat(t):e};return{getEnvironmentProps:function(e){var n=e.inputElement,o=e.formElement,i=e.panelElement;function c(e){!r.getState().isOpen&&r.pendingRequests.isEmpty()||e.target===n||!1===[o,i].some((function(t){return n=t,r=e.target,n===r||n.contains(r);var n,r}))&&(r.dispatch("blur",null),t.debug||r.pendingRequests.cancelAll())}return zn({onTouchStart:c,onMouseDown:c,onTouchMove:function(e){!1!==r.getState().isOpen&&n===t.environment.document.activeElement&&e.target!==n&&n.blur()}},$n(e,Hn))},getRootProps:function(e){return zn({role:"combobox","aria-expanded":r.getState().isOpen,"aria-haspopup":"listbox","aria-owns":r.getState().isOpen?"".concat(t.id,"-list"):void 0,"aria-labelledby":"".concat(t.id,"-label")},e)},getFormProps:function(e){e.inputElement;return zn({action:"",noValidate:!0,role:"search",onSubmit:function(i){var c;i.preventDefault(),t.onSubmit(zn({event:i,refresh:n,state:r.getState()},o)),r.dispatch("submit",null),null===(c=e.inputElement)||void 0===c||c.blur()},onReset:function(i){var c;i.preventDefault(),t.onReset(zn({event:i,refresh:n,state:r.getState()},o)),r.dispatch("reset",null),null===(c=e.inputElement)||void 0===c||c.focus()}},$n(e,Un))},getLabelProps:function(e){var n=e||{},r=n.sourceIndex,o=$n(n,Bn);return zn({htmlFor:"".concat(i(t.id,r),"-input"),id:"".concat(i(t.id,r),"-label")},o)},getInputProps:function(e){var i;function c(e){(t.openOnFocus||Boolean(r.getState().query))&&An(zn({event:e,props:t,query:r.getState().completion||r.getState().query,refresh:n,store:r},o)),r.dispatch("focus",null)}var a=e||{},u=(a.inputElement,a.maxLength),l=void 0===u?512:u,s=$n(a,Fn),f=Ft(r.getState()),p=function(e){return Boolean(e&&e.match(Bt))}((null===(i=t.environment.navigator)||void 0===i?void 0:i.userAgent)||""),m=null!=f&&f.itemUrl&&!p?"go":"search";return zn({"aria-autocomplete":"both","aria-activedescendant":r.getState().isOpen&&null!==r.getState().activeItemId?"".concat(t.id,"-item-").concat(r.getState().activeItemId):void 0,"aria-controls":r.getState().isOpen?"".concat(t.id,"-list"):void 0,"aria-labelledby":"".concat(t.id,"-label"),value:r.getState().completion||r.getState().query,id:"".concat(t.id,"-input"),autoComplete:"off",autoCorrect:"off",autoCapitalize:"off",enterKeyHint:m,spellCheck:"false",autoFocus:t.autoFocus,placeholder:t.placeholder,maxLength:l,type:"search",onChange:function(e){An(zn({event:e,props:t,query:e.currentTarget.value.slice(0,l),refresh:n,store:r},o))},onKeyDown:function(e){!function(e){var t=e.event,n=e.props,r=e.refresh,o=e.store,i=qn(e,xn);if("ArrowUp"===t.key||"ArrowDown"===t.key){var c=function(){var e=n.environment.document.getElementById("".concat(n.id,"-item-").concat(o.getState().activeItemId));e&&(e.scrollIntoViewIfNeeded?e.scrollIntoViewIfNeeded(!1):e.scrollIntoView(!1))},a=function(){var e=Ft(o.getState());if(null!==o.getState().activeItemId&&e){var n=e.item,c=e.itemInputValue,a=e.itemUrl,u=e.source;u.onActive(Tn({event:t,item:n,itemInputValue:c,itemUrl:a,refresh:r,source:u,state:o.getState()},i))}};t.preventDefault(),!1===o.getState().isOpen&&(n.openOnFocus||Boolean(o.getState().query))?An(Tn({event:t,props:n,query:o.getState().query,refresh:r,store:o},i)).then((function(){o.dispatch(t.key,{nextActiveItemId:n.defaultActiveItemId}),a(),setTimeout(c,0)})):(o.dispatch(t.key,{}),a(),c())}else if("Escape"===t.key)t.preventDefault(),o.dispatch(t.key,null),o.pendingRequests.cancelAll();else if("Tab"===t.key)o.dispatch("blur",null),o.pendingRequests.cancelAll();else if("Enter"===t.key){if(null===o.getState().activeItemId||o.getState().collections.every((function(e){return 0===e.items.length})))return void(n.debug||o.pendingRequests.cancelAll());t.preventDefault();var u=Ft(o.getState()),l=u.item,s=u.itemInputValue,f=u.itemUrl,p=u.source;if(t.metaKey||t.ctrlKey)void 0!==f&&(p.onSelect(Tn({event:t,item:l,itemInputValue:s,itemUrl:f,refresh:r,source:p,state:o.getState()},i)),n.navigator.navigateNewTab({itemUrl:f,item:l,state:o.getState()}));else if(t.shiftKey)void 0!==f&&(p.onSelect(Tn({event:t,item:l,itemInputValue:s,itemUrl:f,refresh:r,source:p,state:o.getState()},i)),n.navigator.navigateNewWindow({itemUrl:f,item:l,state:o.getState()}));else if(t.altKey);else{if(void 0!==f)return p.onSelect(Tn({event:t,item:l,itemInputValue:s,itemUrl:f,refresh:r,source:p,state:o.getState()},i)),void n.navigator.navigate({itemUrl:f,item:l,state:o.getState()});An(Tn({event:t,nextState:{isOpen:!1},props:n,query:s,refresh:r,store:o},i)).then((function(){p.onSelect(Tn({event:t,item:l,itemInputValue:s,itemUrl:f,refresh:r,source:p,state:o.getState()},i))}))}}}(zn({event:e,props:t,refresh:n,store:r},o))},onFocus:c,onBlur:ot,onClick:function(n){e.inputElement!==t.environment.document.activeElement||r.getState().isOpen||c(n)}},s)},getPanelProps:function(e){return zn({onMouseDown:function(e){e.preventDefault()},onMouseLeave:function(){r.dispatch("mouseleave",null)}},e)},getListProps:function(e){var n=e||{},r=n.sourceIndex,o=$n(n,Vn);return zn({role:"listbox","aria-labelledby":"".concat(i(t.id,r),"-label"),id:"".concat(i(t.id,r),"-list")},o)},getItemProps:function(e){var c=e.item,a=e.source,u=e.sourceIndex,l=$n(e,Wn);return zn({id:"".concat(i(t.id,u),"-item-").concat(c.__autocomplete_id),role:"option","aria-selected":r.getState().activeItemId===c.__autocomplete_id,onMouseMove:function(e){if(c.__autocomplete_id!==r.getState().activeItemId){r.dispatch("mousemove",c.__autocomplete_id);var t=Ft(r.getState());if(null!==r.getState().activeItemId&&t){var i=t.item,a=t.itemInputValue,u=t.itemUrl,l=t.source;l.onActive(zn({event:e,item:i,itemInputValue:a,itemUrl:u,refresh:n,source:l,state:r.getState()},o))}}},onMouseDown:function(e){e.preventDefault()},onClick:function(e){var i=a.getItemInputValue({item:c,state:r.getState()}),u=a.getItemUrl({item:c,state:r.getState()});(u?Promise.resolve():An(zn({event:e,nextState:{isOpen:!1},props:t,query:i,refresh:n,store:r},o))).then((function(){a.onSelect(zn({event:e,item:c,itemInputValue:i,itemUrl:u,refresh:n,source:a,state:r.getState()},o))}))}},l)}}}function Zn(e){return Zn="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},Zn(e)}function Yn(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function Gn(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?Yn(Object(n),!0).forEach((function(t){Xn(e,t,n[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):Yn(Object(n)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))}))}return e}function Xn(e,t,n){return(t=function(e){var t=function(e,t){if("object"!==Zn(e)||null===e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var r=n.call(e,t||"default");if("object"!==Zn(r))return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"===Zn(t)?t:String(t)}(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function er(e){var t,n,r,o,i=e.plugins,c=e.options,a=null===(t=((null===(n=c.__autocomplete_metadata)||void 0===n?void 0:n.userAgents)||[])[0])||void 0===t?void 0:t.segment,u=a?Xn({},a,Object.keys((null===(r=c.__autocomplete_metadata)||void 0===r?void 0:r.options)||{})):{};return{plugins:i.map((function(e){return{name:e.name,options:Object.keys(e.__autocomplete_pluginOptions||[])}})),options:Gn({"autocomplete-core":Object.keys(c)},u),ua:it.concat((null===(o=c.__autocomplete_metadata)||void 0===o?void 0:o.userAgents)||[])}}function tr(e){var t,n=e.state;return!1===n.isOpen||null===n.activeItemId?null:(null===(t=Ft(n))||void 0===t?void 0:t.itemInputValue)||null}function nr(e){return nr="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},nr(e)}function rr(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function or(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?rr(Object(n),!0).forEach((function(t){ir(e,t,n[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):rr(Object(n)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))}))}return e}function ir(e,t,n){return(t=function(e){var t=function(e,t){if("object"!==nr(e)||null===e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var r=n.call(e,t||"default");if("object"!==nr(r))return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"===nr(t)?t:String(t)}(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var cr=function(e,t){switch(t.type){case"setActiveItemId":case"mousemove":return or(or({},e),{},{activeItemId:t.payload});case"setQuery":return or(or({},e),{},{query:t.payload,completion:null});case"setCollections":return or(or({},e),{},{collections:t.payload});case"setIsOpen":return or(or({},e),{},{isOpen:t.payload});case"setStatus":return or(or({},e),{},{status:t.payload});case"setContext":return or(or({},e),{},{context:or(or({},e.context),t.payload)});case"ArrowDown":var n=or(or({},e),{},{activeItemId:t.payload.hasOwnProperty("nextActiveItemId")?t.payload.nextActiveItemId:qt(1,e.activeItemId,tt(e),t.props.defaultActiveItemId)});return or(or({},n),{},{completion:tr({state:n})});case"ArrowUp":var r=or(or({},e),{},{activeItemId:qt(-1,e.activeItemId,tt(e),t.props.defaultActiveItemId)});return or(or({},r),{},{completion:tr({state:r})});case"Escape":return e.isOpen?or(or({},e),{},{activeItemId:null,isOpen:!1,completion:null}):or(or({},e),{},{activeItemId:null,query:"",status:"idle",collections:[]});case"submit":return or(or({},e),{},{activeItemId:null,isOpen:!1,status:"idle"});case"reset":return or(or({},e),{},{activeItemId:!0===t.props.openOnFocus?t.props.defaultActiveItemId:null,status:"idle",query:""});case"focus":return or(or({},e),{},{activeItemId:t.props.defaultActiveItemId,isOpen:(t.props.openOnFocus||Boolean(e.query))&&t.props.shouldPanelOpen({state:e})});case"blur":return t.props.debug?e:or(or({},e),{},{isOpen:!1,activeItemId:null});case"mouseleave":return or(or({},e),{},{activeItemId:t.props.defaultActiveItemId});default:return"The reducer action ".concat(JSON.stringify(t.type)," is not supported."),e}};function ar(e){return ar="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},ar(e)}function ur(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function lr(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?ur(Object(n),!0).forEach((function(t){sr(e,t,n[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):ur(Object(n)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))}))}return e}function sr(e,t,n){return(t=function(e){var t=function(e,t){if("object"!==ar(e)||null===e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var r=n.call(e,t||"default");if("object"!==ar(r))return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"===ar(t)?t:String(t)}(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function fr(e){var t=[],n=rn(e,t),r=zt(cr,n,(function(e){var t=e.prevState,r=e.state;n.onStateChange(lr({prevState:t,state:r,refresh:c,navigator:n.navigator},o))})),o=function(e){var t=e.store;return{setActiveItemId:function(e){t.dispatch("setActiveItemId",e)},setQuery:function(e){t.dispatch("setQuery",e)},setCollections:function(e){var n=0,r=e.map((function(e){return Qt(Qt({},e),{},{items:Xe(e.items).map((function(e){return Qt(Qt({},e),{},{__autocomplete_id:n++})}))})}));t.dispatch("setCollections",r)},setIsOpen:function(e){t.dispatch("setIsOpen",e)},setStatus:function(e){t.dispatch("setStatus",e)},setContext:function(e){t.dispatch("setContext",e)}}}({store:r}),i=Qn(lr({props:n,refresh:c,store:r,navigator:n.navigator},o));function c(){return An(lr({event:new Event("input"),nextState:{isOpen:r.getState().isOpen},props:n,navigator:n.navigator,query:r.getState().query,refresh:c,store:r},o))}if(e.insights&&!n.plugins.some((function(e){return"aa.algoliaInsightsPlugin"===e.name}))){var a="boolean"==typeof e.insights?{}:e.insights;n.plugins.push(xt(a))}return n.plugins.forEach((function(e){var r;return null===(r=e.subscribe)||void 0===r?void 0:r.call(e,lr(lr({},o),{},{navigator:n.navigator,refresh:c,onSelect:function(e){t.push({onSelect:e})},onActive:function(e){t.push({onActive:e})},onResolve:function(e){t.push({onResolve:e})}}))})),function(e){var t,n,r=e.metadata,o=e.environment;if(null===(t=o.navigator)||void 0===t||null===(n=t.userAgent)||void 0===n?void 0:n.includes("Algolia Crawler")){var i=o.document.createElement("meta"),c=o.document.querySelector("head");i.name="algolia:metadata",setTimeout((function(){i.content=JSON.stringify(r),c.appendChild(i)}),0)}}({metadata:er({plugins:n.plugins,options:e}),environment:n.environment}),lr(lr({refresh:c,navigator:n.navigator},i),o)}function pr(e){var t=e.translations,n=(void 0===t?{}:t).searchByText,r=void 0===n?"Search by":n;return Be.createElement("a",{href:"https://www.algolia.com/ref/docsearch/?utm_source=".concat(window.location.hostname,"&utm_medium=referral&utm_content=powered_by&utm_campaign=docsearch"),target:"_blank",rel:"noopener noreferrer"},Be.createElement("span",{className:"DocSearch-Label"},r),Be.createElement("svg",{width:"77",height:"19","aria-label":"Algolia",role:"img",id:"Layer_1",xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 2196.2 500"},Be.createElement("defs",null,Be.createElement("style",null,".cls-1,.cls-2{fill:#003dff;}.cls-2{fill-rule:evenodd;}")),Be.createElement("path",{className:"cls-2",d:"M1070.38,275.3V5.91c0-3.63-3.24-6.39-6.82-5.83l-50.46,7.94c-2.87,.45-4.99,2.93-4.99,5.84l.17,273.22c0,12.92,0,92.7,95.97,95.49,3.33,.1,6.09-2.58,6.09-5.91v-40.78c0-2.96-2.19-5.51-5.12-5.84-34.85-4.01-34.85-47.57-34.85-54.72Z"}),Be.createElement("rect",{className:"cls-1",x:"1845.88",y:"104.73",width:"62.58",height:"277.9",rx:"5.9",ry:"5.9"}),Be.createElement("path",{className:"cls-2",d:"M1851.78,71.38h50.77c3.26,0,5.9-2.64,5.9-5.9V5.9c0-3.62-3.24-6.39-6.82-5.83l-50.77,7.95c-2.87,.45-4.99,2.92-4.99,5.83v51.62c0,3.26,2.64,5.9,5.9,5.9Z"}),Be.createElement("path",{className:"cls-2",d:"M1764.03,275.3V5.91c0-3.63-3.24-6.39-6.82-5.83l-50.46,7.94c-2.87,.45-4.99,2.93-4.99,5.84l.17,273.22c0,12.92,0,92.7,95.97,95.49,3.33,.1,6.09-2.58,6.09-5.91v-40.78c0-2.96-2.19-5.51-5.12-5.84-34.85-4.01-34.85-47.57-34.85-54.72Z"}),Be.createElement("path",{className:"cls-2",d:"M1631.95,142.72c-11.14-12.25-24.83-21.65-40.78-28.31-15.92-6.53-33.26-9.85-52.07-9.85-18.78,0-36.15,3.17-51.92,9.85-15.59,6.66-29.29,16.05-40.76,28.31-11.47,12.23-20.38,26.87-26.76,44.03-6.38,17.17-9.24,37.37-9.24,58.36,0,20.99,3.19,36.87,9.55,54.21,6.38,17.32,15.14,32.11,26.45,44.36,11.29,12.23,24.83,21.62,40.6,28.46,15.77,6.83,40.12,10.33,52.4,10.48,12.25,0,36.78-3.82,52.7-10.48,15.92-6.68,29.46-16.23,40.78-28.46,11.29-12.25,20.05-27.04,26.25-44.36,6.22-17.34,9.24-33.22,9.24-54.21,0-20.99-3.34-41.19-10.03-58.36-6.38-17.17-15.14-31.8-26.43-44.03Zm-44.43,163.75c-11.47,15.75-27.56,23.7-48.09,23.7-20.55,0-36.63-7.8-48.1-23.7-11.47-15.75-17.21-34.01-17.21-61.2,0-26.89,5.59-49.14,17.06-64.87,11.45-15.75,27.54-23.52,48.07-23.52,20.55,0,36.63,7.78,48.09,23.52,11.47,15.57,17.36,37.98,17.36,64.87,0,27.19-5.72,45.3-17.19,61.2Z"}),Be.createElement("path",{className:"cls-2",d:"M894.42,104.73h-49.33c-48.36,0-90.91,25.48-115.75,64.1-14.52,22.58-22.99,49.63-22.99,78.73,0,44.89,20.13,84.92,51.59,111.1,2.93,2.6,6.05,4.98,9.31,7.14,12.86,8.49,28.11,13.47,44.52,13.47,1.23,0,2.46-.03,3.68-.09,.36-.02,.71-.05,1.07-.07,.87-.05,1.75-.11,2.62-.2,.34-.03,.68-.08,1.02-.12,.91-.1,1.82-.21,2.73-.34,.21-.03,.42-.07,.63-.1,32.89-5.07,61.56-30.82,70.9-62.81v57.83c0,3.26,2.64,5.9,5.9,5.9h50.42c3.26,0,5.9-2.64,5.9-5.9V110.63c0-3.26-2.64-5.9-5.9-5.9h-56.32Zm0,206.92c-12.2,10.16-27.97,13.98-44.84,15.12-.16,.01-.33,.03-.49,.04-1.12,.07-2.24,.1-3.36,.1-42.24,0-77.12-35.89-77.12-79.37,0-10.25,1.96-20.01,5.42-28.98,11.22-29.12,38.77-49.74,71.06-49.74h49.33v142.83Z"}),Be.createElement("path",{className:"cls-2",d:"M2133.97,104.73h-49.33c-48.36,0-90.91,25.48-115.75,64.1-14.52,22.58-22.99,49.63-22.99,78.73,0,44.89,20.13,84.92,51.59,111.1,2.93,2.6,6.05,4.98,9.31,7.14,12.86,8.49,28.11,13.47,44.52,13.47,1.23,0,2.46-.03,3.68-.09,.36-.02,.71-.05,1.07-.07,.87-.05,1.75-.11,2.62-.2,.34-.03,.68-.08,1.02-.12,.91-.1,1.82-.21,2.73-.34,.21-.03,.42-.07,.63-.1,32.89-5.07,61.56-30.82,70.9-62.81v57.83c0,3.26,2.64,5.9,5.9,5.9h50.42c3.26,0,5.9-2.64,5.9-5.9V110.63c0-3.26-2.64-5.9-5.9-5.9h-56.32Zm0,206.92c-12.2,10.16-27.97,13.98-44.84,15.12-.16,.01-.33,.03-.49,.04-1.12,.07-2.24,.1-3.36,.1-42.24,0-77.12-35.89-77.12-79.37,0-10.25,1.96-20.01,5.42-28.98,11.22-29.12,38.77-49.74,71.06-49.74h49.33v142.83Z"}),Be.createElement("path",{className:"cls-2",d:"M1314.05,104.73h-49.33c-48.36,0-90.91,25.48-115.75,64.1-11.79,18.34-19.6,39.64-22.11,62.59-.58,5.3-.88,10.68-.88,16.14s.31,11.15,.93,16.59c4.28,38.09,23.14,71.61,50.66,94.52,2.93,2.6,6.05,4.98,9.31,7.14,12.86,8.49,28.11,13.47,44.52,13.47h0c17.99,0,34.61-5.93,48.16-15.97,16.29-11.58,28.88-28.54,34.48-47.75v50.26h-.11v11.08c0,21.84-5.71,38.27-17.34,49.36-11.61,11.08-31.04,16.63-58.25,16.63-11.12,0-28.79-.59-46.6-2.41-2.83-.29-5.46,1.5-6.27,4.22l-12.78,43.11c-1.02,3.46,1.27,7.02,4.83,7.53,21.52,3.08,42.52,4.68,54.65,4.68,48.91,0,85.16-10.75,108.89-32.21,21.48-19.41,33.15-48.89,35.2-88.52V110.63c0-3.26-2.64-5.9-5.9-5.9h-56.32Zm0,64.1s.65,139.13,0,143.36c-12.08,9.77-27.11,13.59-43.49,14.7-.16,.01-.33,.03-.49,.04-1.12,.07-2.24,.1-3.36,.1-1.32,0-2.63-.03-3.94-.1-40.41-2.11-74.52-37.26-74.52-79.38,0-10.25,1.96-20.01,5.42-28.98,11.22-29.12,38.77-49.74,71.06-49.74h49.33Z"}),Be.createElement("path",{className:"cls-1",d:"M249.83,0C113.3,0,2,110.09,.03,246.16c-2,138.19,110.12,252.7,248.33,253.5,42.68,.25,83.79-10.19,120.3-30.03,3.56-1.93,4.11-6.83,1.08-9.51l-23.38-20.72c-4.75-4.21-11.51-5.4-17.36-2.92-25.48,10.84-53.17,16.38-81.71,16.03-111.68-1.37-201.91-94.29-200.13-205.96,1.76-110.26,92-199.41,202.67-199.41h202.69V407.41l-115-102.18c-3.72-3.31-9.42-2.66-12.42,1.31-18.46,24.44-48.53,39.64-81.93,37.34-46.33-3.2-83.87-40.5-87.34-86.81-4.15-55.24,39.63-101.52,94-101.52,49.18,0,89.68,37.85,93.91,85.95,.38,4.28,2.31,8.27,5.52,11.12l29.95,26.55c3.4,3.01,8.79,1.17,9.63-3.3,2.16-11.55,2.92-23.58,2.07-35.92-4.82-70.34-61.8-126.93-132.17-131.26-80.68-4.97-148.13,58.14-150.27,137.25-2.09,77.1,61.08,143.56,138.19,145.26,32.19,.71,62.03-9.41,86.14-26.95l150.26,133.2c6.44,5.71,16.61,1.14,16.61-7.47V9.48C499.66,4.25,495.42,0,490.18,0H249.83Z"})))}function mr(e){return Be.createElement("svg",{width:"15",height:"15","aria-label":e.ariaLabel,role:"img"},Be.createElement("g",{fill:"none",stroke:"currentColor",strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:"1.2"},e.children))}function vr(e){var t=e.translations,n=void 0===t?{}:t,r=n.selectText,o=void 0===r?"to select":r,i=n.selectKeyAriaLabel,c=void 0===i?"Enter key":i,a=n.navigateText,u=void 0===a?"to navigate":a,l=n.navigateUpKeyAriaLabel,s=void 0===l?"Arrow up":l,f=n.navigateDownKeyAriaLabel,p=void 0===f?"Arrow down":f,m=n.closeText,v=void 0===m?"to close":m,d=n.closeKeyAriaLabel,h=void 0===d?"Escape key":d,y=n.searchByText,b=void 0===y?"Search by":y;return Be.createElement(Be.Fragment,null,Be.createElement("div",{className:"DocSearch-Logo"},Be.createElement(pr,{translations:{searchByText:b}})),Be.createElement("ul",{className:"DocSearch-Commands"},Be.createElement("li",null,Be.createElement("kbd",{className:"DocSearch-Commands-Key"},Be.createElement(mr,{ariaLabel:c},Be.createElement("path",{d:"M12 3.53088v3c0 1-1 2-2 2H4M7 11.53088l-3-3 3-3"}))),Be.createElement("span",{className:"DocSearch-Label"},o)),Be.createElement("li",null,Be.createElement("kbd",{className:"DocSearch-Commands-Key"},Be.createElement(mr,{ariaLabel:p},Be.createElement("path",{d:"M7.5 3.5v8M10.5 8.5l-3 3-3-3"}))),Be.createElement("kbd",{className:"DocSearch-Commands-Key"},Be.createElement(mr,{ariaLabel:s},Be.createElement("path",{d:"M7.5 11.5v-8M10.5 6.5l-3-3-3 3"}))),Be.createElement("span",{className:"DocSearch-Label"},u)),Be.createElement("li",null,Be.createElement("kbd",{className:"DocSearch-Commands-Key"},Be.createElement(mr,{ariaLabel:h},Be.createElement("path",{d:"M13.6167 8.936c-.1065.3583-.6883.962-1.4875.962-.7993 0-1.653-.9165-1.653-2.1258v-.5678c0-1.2548.7896-2.1016 1.653-2.1016.8634 0 1.3601.4778 1.4875 1.0724M9 6c-.1352-.4735-.7506-.9219-1.46-.8972-.7092.0246-1.344.57-1.344 1.2166s.4198.8812 1.3445.9805C8.465 7.3992 8.968 7.9337 9 8.5c.032.5663-.454 1.398-1.4595 1.398C6.6593 9.898 6 9 5.963 8.4851m-1.4748.5368c-.2635.5941-.8099.876-1.5443.876s-1.7073-.6248-1.7073-2.204v-.4603c0-1.0416.721-2.131 1.7073-2.131.9864 0 1.6425 1.031 1.5443 2.2492h-2.956"}))),Be.createElement("span",{className:"DocSearch-Label"},v))))}function dr(e){var t=e.hit,n=e.children;return Be.createElement("a",{href:t.url},n)}function hr(){return Be.createElement("svg",{viewBox:"0 0 38 38",stroke:"currentColor",strokeOpacity:".5"},Be.createElement("g",{fill:"none",fillRule:"evenodd"},Be.createElement("g",{transform:"translate(1 1)",strokeWidth:"2"},Be.createElement("circle",{strokeOpacity:".3",cx:"18",cy:"18",r:"18"}),Be.createElement("path",{d:"M36 18c0-9.94-8.06-18-18-18"},Be.createElement("animateTransform",{attributeName:"transform",type:"rotate",from:"0 18 18",to:"360 18 18",dur:"1s",repeatCount:"indefinite"})))))}function yr(){return Be.createElement("svg",{width:"20",height:"20",viewBox:"0 0 20 20"},Be.createElement("g",{stroke:"currentColor",fill:"none",fillRule:"evenodd",strokeLinecap:"round",strokeLinejoin:"round"},Be.createElement("path",{d:"M3.18 6.6a8.23 8.23 0 1112.93 9.94h0a8.23 8.23 0 01-11.63 0"}),Be.createElement("path",{d:"M6.44 7.25H2.55V3.36M10.45 6v5.6M10.45 11.6L13 13"})))}function br(){return Be.createElement("svg",{width:"20",height:"20",viewBox:"0 0 20 20"},Be.createElement("path",{d:"M10 10l5.09-5.09L10 10l5.09 5.09L10 10zm0 0L4.91 4.91 10 10l-5.09 5.09L10 10z",stroke:"currentColor",fill:"none",fillRule:"evenodd",strokeLinecap:"round",strokeLinejoin:"round"}))}function _r(){return Be.createElement("svg",{className:"DocSearch-Hit-Select-Icon",width:"20",height:"20",viewBox:"0 0 20 20"},Be.createElement("g",{stroke:"currentColor",fill:"none",fillRule:"evenodd",strokeLinecap:"round",strokeLinejoin:"round"},Be.createElement("path",{d:"M18 3v4c0 2-2 4-4 4H2"}),Be.createElement("path",{d:"M8 17l-6-6 6-6"})))}var gr=function(){return Be.createElement("svg",{width:"20",height:"20",viewBox:"0 0 20 20"},Be.createElement("path",{d:"M17 6v12c0 .52-.2 1-1 1H4c-.7 0-1-.33-1-1V2c0-.55.42-1 1-1h8l5 5zM14 8h-3.13c-.51 0-.87-.34-.87-.87V4",stroke:"currentColor",fill:"none",fillRule:"evenodd",strokeLinejoin:"round"}))};function Or(e){switch(e.type){case"lvl1":return Be.createElement(gr,null);case"content":return Be.createElement(jr,null);default:return Be.createElement(Sr,null)}}function Sr(){return Be.createElement("svg",{width:"20",height:"20",viewBox:"0 0 20 20"},Be.createElement("path",{d:"M13 13h4-4V8H7v5h6v4-4H7V8H3h4V3v5h6V3v5h4-4v5zm-6 0v4-4H3h4z",stroke:"currentColor",fill:"none",fillRule:"evenodd",strokeLinecap:"round",strokeLinejoin:"round"}))}function jr(){return Be.createElement("svg",{width:"20",height:"20",viewBox:"0 0 20 20"},Be.createElement("path",{d:"M17 5H3h14zm0 5H3h14zm0 5H3h14z",stroke:"currentColor",fill:"none",fillRule:"evenodd",strokeLinejoin:"round"}))}function wr(){return Be.createElement("svg",{width:"20",height:"20",viewBox:"0 0 20 20"},Be.createElement("path",{d:"M10 14.2L5 17l1-5.6-4-4 5.5-.7 2.5-5 2.5 5 5.6.8-4 4 .9 5.5z",stroke:"currentColor",fill:"none",fillRule:"evenodd",strokeLinejoin:"round"}))}function Er(){return Be.createElement("svg",{width:"40",height:"40",viewBox:"0 0 20 20",fill:"none",fillRule:"evenodd",stroke:"currentColor",strokeLinecap:"round",strokeLinejoin:"round"},Be.createElement("path",{d:"M19 4.8a16 16 0 00-2-1.2m-3.3-1.2A16 16 0 001.1 4.7M16.7 8a12 12 0 00-2.8-1.4M10 6a12 12 0 00-6.7 2M12.3 14.7a4 4 0 00-4.5 0M14.5 11.4A8 8 0 0010 10M3 16L18 2M10 18h0"}))}function Pr(){return Be.createElement("svg",{width:"40",height:"40",viewBox:"0 0 20 20",fill:"none",fillRule:"evenodd",stroke:"currentColor",strokeLinecap:"round",strokeLinejoin:"round"},Be.createElement("path",{d:"M15.5 4.8c2 3 1.7 7-1 9.7h0l4.3 4.3-4.3-4.3a7.8 7.8 0 01-9.8 1m-2.2-2.2A7.8 7.8 0 0113.2 2.4M2 18L18 2"}))}function Ir(e){var t=e.translations,n=void 0===t?{}:t,r=n.titleText,o=void 0===r?"Unable to fetch results":r,i=n.helpText,c=void 0===i?"You might want to check your network connection.":i;return Be.createElement("div",{className:"DocSearch-ErrorScreen"},Be.createElement("div",{className:"DocSearch-Screen-Icon"},Be.createElement(Er,null)),Be.createElement("p",{className:"DocSearch-Title"},o),Be.createElement("p",{className:"DocSearch-Help"},c))}var Dr=["translations"];function kr(e){return function(e){if(Array.isArray(e))return Ar(e)}(e)||function(e){if("undefined"!=typeof Symbol&&null!=e[Symbol.iterator]||null!=e["@@iterator"])return Array.from(e)}(e)||function(e,t){if(!e)return;if("string"==typeof e)return Ar(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);"Object"===n&&e.constructor&&(n=e.constructor.name);if("Map"===n||"Set"===n)return Array.from(e);if("Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n))return Ar(e,t)}(e)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function Ar(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n<t;n++)r[n]=e[n];return r}function Cr(e,t){if(null==e)return{};var n,r,o=function(e,t){if(null==e)return{};var n,r,o={},i=Object.keys(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}function xr(e){var t=e.translations,n=void 0===t?{}:t,r=Cr(e,Dr),o=n.noResultsText,i=void 0===o?"No results for":o,c=n.suggestedQueryText,a=void 0===c?"Try searching for":c,u=n.reportMissingResultsText,l=void 0===u?"Believe this query should return results?":u,s=n.reportMissingResultsLinkText,f=void 0===s?"Let us know.":s,p=r.state.context.searchSuggestions;return Be.createElement("div",{className:"DocSearch-NoResults"},Be.createElement("div",{className:"DocSearch-Screen-Icon"},Be.createElement(Pr,null)),Be.createElement("p",{className:"DocSearch-Title"},i,' "',Be.createElement("strong",null,r.state.query),'"'),p&&p.length>0&&Be.createElement("div",{className:"DocSearch-NoResults-Prefill-List"},Be.createElement("p",{className:"DocSearch-Help"},a,":"),Be.createElement("ul",null,p.slice(0,3).reduce((function(e,t){return[].concat(kr(e),[Be.createElement("li",{key:t},Be.createElement("button",{className:"DocSearch-Prefill",key:t,type:"button",onClick:function(){r.setQuery(t.toLowerCase()+" "),r.refresh(),r.inputRef.current.focus()}},t))])}),[]))),r.getMissingResultsUrl&&Be.createElement("p",{className:"DocSearch-Help"},"".concat(l," "),Be.createElement("a",{href:r.getMissingResultsUrl({query:r.state.query}),target:"_blank",rel:"noopener noreferrer"},f)))}var Nr=["hit","attribute","tagName"];function Tr(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function Rr(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?Tr(Object(n),!0).forEach((function(t){qr(e,t,n[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):Tr(Object(n)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))}))}return e}function qr(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function Lr(e,t){if(null==e)return{};var n,r,o=function(e,t){if(null==e)return{};var n,r,o={},i=Object.keys(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}function Mr(e,t){return t.split(".").reduce((function(e,t){return null!=e&&e[t]?e[t]:null}),e)}function Hr(e){var t=e.hit,n=e.attribute,r=e.tagName;return g(void 0===r?"span":r,Rr(Rr({},Lr(e,Nr)),{},{dangerouslySetInnerHTML:{__html:Mr(t,"_snippetResult.".concat(n,".value"))||Mr(t,n)}}))}function Ur(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){var n=null==e?null:"undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(null==n)return;var r,o,i=[],c=!0,a=!1;try{for(n=n.call(e);!(c=(r=n.next()).done)&&(i.push(r.value),!t||i.length!==t);c=!0);}catch(e){a=!0,o=e}finally{try{c||null==n.return||n.return()}finally{if(a)throw o}}return i}(e,t)||function(e,t){if(!e)return;if("string"==typeof e)return Fr(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);"Object"===n&&e.constructor&&(n=e.constructor.name);if("Map"===n||"Set"===n)return Array.from(e);if("Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n))return Fr(e,t)}(e,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function Fr(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n<t;n++)r[n]=e[n];return r}function Br(){return Br=Object.assign||function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},Br.apply(this,arguments)}function Vr(e){return e.collection&&0!==e.collection.items.length?Be.createElement("section",{className:"DocSearch-Hits"},Be.createElement("div",{className:"DocSearch-Hit-source"},e.title),Be.createElement("ul",e.getListProps(),e.collection.items.map((function(t,n){return Be.createElement(Wr,Br({key:[e.title,t.objectID].join(":"),item:t,index:n},e))})))):null}function Wr(e){var t=e.item,n=e.index,r=e.renderIcon,o=e.renderAction,i=e.getItemProps,c=e.onItemClick,a=e.collection,u=e.hitComponent,l=Ur(Be.useState(!1),2),s=l[0],f=l[1],p=Ur(Be.useState(!1),2),m=p[0],v=p[1],d=Be.useRef(null),h=u;return Be.createElement("li",Br({className:["DocSearch-Hit",t.__docsearch_parent&&"DocSearch-Hit--Child",s&&"DocSearch-Hit--deleting",m&&"DocSearch-Hit--favoriting"].filter(Boolean).join(" "),onTransitionEnd:function(){d.current&&d.current()}},i({item:t,source:a.source,onClick:function(e){c(t,e)}})),Be.createElement(h,{hit:t},Be.createElement("div",{className:"DocSearch-Hit-Container"},r({item:t,index:n}),t.hierarchy[t.type]&&"lvl1"===t.type&&Be.createElement("div",{className:"DocSearch-Hit-content-wrapper"},Be.createElement(Hr,{className:"DocSearch-Hit-title",hit:t,attribute:"hierarchy.lvl1"}),t.content&&Be.createElement(Hr,{className:"DocSearch-Hit-path",hit:t,attribute:"content"})),t.hierarchy[t.type]&&("lvl2"===t.type||"lvl3"===t.type||"lvl4"===t.type||"lvl5"===t.type||"lvl6"===t.type)&&Be.createElement("div",{className:"DocSearch-Hit-content-wrapper"},Be.createElement(Hr,{className:"DocSearch-Hit-title",hit:t,attribute:"hierarchy.".concat(t.type)}),Be.createElement(Hr,{className:"DocSearch-Hit-path",hit:t,attribute:"hierarchy.lvl1"})),"content"===t.type&&Be.createElement("div",{className:"DocSearch-Hit-content-wrapper"},Be.createElement(Hr,{className:"DocSearch-Hit-title",hit:t,attribute:"content"}),Be.createElement(Hr,{className:"DocSearch-Hit-path",hit:t,attribute:"hierarchy.lvl1"})),o({item:t,runDeleteTransition:function(e){f(!0),d.current=e},runFavoriteTransition:function(e){v(!0),d.current=e}}))))}function Kr(e,t,n){return e.reduce((function(e,r){var o=t(r);return e.hasOwnProperty(o)||(e[o]=[]),e[o].length<(n||5)&&e[o].push(r),e}),{})}function zr(e){return e}function Jr(e){return 1===e.button||e.altKey||e.ctrlKey||e.metaKey||e.shiftKey}function $r(){}var Qr=/(<mark>|<\/mark>)/g,Zr=RegExp(Qr.source);function Yr(e){var t,n,r,o,i,c=e;if(!c.__docsearch_parent&&!e._highlightResult)return e.hierarchy.lvl0;var a=((c.__docsearch_parent?null===(t=c.__docsearch_parent)||void 0===t||null===(n=t._highlightResult)||void 0===n||null===(r=n.hierarchy)||void 0===r?void 0:r.lvl0:null===(o=e._highlightResult)||void 0===o||null===(i=o.hierarchy)||void 0===i?void 0:i.lvl0)||{}).value;return a&&Zr.test(a)?a.replace(Qr,""):a}function Gr(){return Gr=Object.assign||function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},Gr.apply(this,arguments)}function Xr(e){return Be.createElement("div",{className:"DocSearch-Dropdown-Container"},e.state.collections.map((function(t){if(0===t.items.length)return null;var n=Yr(t.items[0]);return Be.createElement(Vr,Gr({},e,{key:t.source.sourceId,title:n,collection:t,renderIcon:function(e){var n,r=e.item,o=e.index;return Be.createElement(Be.Fragment,null,r.__docsearch_parent&&Be.createElement("svg",{className:"DocSearch-Hit-Tree",viewBox:"0 0 24 54"},Be.createElement("g",{stroke:"currentColor",fill:"none",fillRule:"evenodd",strokeLinecap:"round",strokeLinejoin:"round"},r.__docsearch_parent!==(null===(n=t.items[o+1])||void 0===n?void 0:n.__docsearch_parent)?Be.createElement("path",{d:"M8 6v21M20 27H8.3"}):Be.createElement("path",{d:"M8 6v42M20 27H8.3"}))),Be.createElement("div",{className:"DocSearch-Hit-icon"},Be.createElement(Or,{type:r.type})))},renderAction:function(){return Be.createElement("div",{className:"DocSearch-Hit-action"},Be.createElement(_r,null))}}))})),e.resultsFooterComponent&&Be.createElement("section",{className:"DocSearch-HitsFooter"},Be.createElement(e.resultsFooterComponent,{state:e.state})))}var eo=["translations"];function to(){return to=Object.assign||function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},to.apply(this,arguments)}function no(e,t){if(null==e)return{};var n,r,o=function(e,t){if(null==e)return{};var n,r,o={},i=Object.keys(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}function ro(e){var t=e.translations,n=void 0===t?{}:t,r=no(e,eo),o=n.recentSearchesTitle,i=void 0===o?"Recent":o,c=n.noRecentSearchesText,a=void 0===c?"No recent searches":c,u=n.saveRecentSearchButtonTitle,l=void 0===u?"Save this search":u,s=n.removeRecentSearchButtonTitle,f=void 0===s?"Remove this search from history":s,p=n.favoriteSearchesTitle,m=void 0===p?"Favorite":p,v=n.removeFavoriteSearchButtonTitle,d=void 0===v?"Remove this search from favorites":v;return"idle"===r.state.status&&!1===r.hasCollections?r.disableUserPersonalization?null:Be.createElement("div",{className:"DocSearch-StartScreen"},Be.createElement("p",{className:"DocSearch-Help"},a)):!1===r.hasCollections?null:Be.createElement("div",{className:"DocSearch-Dropdown-Container"},Be.createElement(Vr,to({},r,{title:i,collection:r.state.collections[0],renderIcon:function(){return Be.createElement("div",{className:"DocSearch-Hit-icon"},Be.createElement(yr,null))},renderAction:function(e){var t=e.item,n=e.runFavoriteTransition,o=e.runDeleteTransition;return Be.createElement(Be.Fragment,null,Be.createElement("div",{className:"DocSearch-Hit-action"},Be.createElement("button",{className:"DocSearch-Hit-action-button",title:l,type:"submit",onClick:function(e){e.preventDefault(),e.stopPropagation(),n((function(){r.favoriteSearches.add(t),r.recentSearches.remove(t),r.refresh()}))}},Be.createElement(wr,null))),Be.createElement("div",{className:"DocSearch-Hit-action"},Be.createElement("button",{className:"DocSearch-Hit-action-button",title:f,type:"submit",onClick:function(e){e.preventDefault(),e.stopPropagation(),o((function(){r.recentSearches.remove(t),r.refresh()}))}},Be.createElement(br,null))))}})),Be.createElement(Vr,to({},r,{title:m,collection:r.state.collections[1],renderIcon:function(){return Be.createElement("div",{className:"DocSearch-Hit-icon"},Be.createElement(wr,null))},renderAction:function(e){var t=e.item,n=e.runDeleteTransition;return Be.createElement("div",{className:"DocSearch-Hit-action"},Be.createElement("button",{className:"DocSearch-Hit-action-button",title:d,type:"submit",onClick:function(e){e.preventDefault(),e.stopPropagation(),n((function(){r.favoriteSearches.remove(t),r.refresh()}))}},Be.createElement(br,null)))}})))}var oo=["translations"];function io(){return io=Object.assign||function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},io.apply(this,arguments)}function co(e,t){if(null==e)return{};var n,r,o=function(e,t){if(null==e)return{};var n,r,o={},i=Object.keys(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}var ao=Be.memo((function(e){var t=e.translations,n=void 0===t?{}:t,r=co(e,oo);if("error"===r.state.status)return Be.createElement(Ir,{translations:null==n?void 0:n.errorScreen});var o=r.state.collections.some((function(e){return e.items.length>0}));return r.state.query?!1===o?Be.createElement(xr,io({},r,{translations:null==n?void 0:n.noResultsScreen})):Be.createElement(Xr,r):Be.createElement(ro,io({},r,{hasCollections:o,translations:null==n?void 0:n.startScreen}))}),(function(e,t){return"loading"===t.state.status||"stalled"===t.state.status})),uo=["translations"];function lo(){return lo=Object.assign||function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},lo.apply(this,arguments)}function so(e,t){if(null==e)return{};var n,r,o=function(e,t){if(null==e)return{};var n,r,o={},i=Object.keys(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}function fo(e){var t=e.translations,n=void 0===t?{}:t,r=so(e,uo),o=n.resetButtonTitle,i=void 0===o?"Clear the query":o,c=n.resetButtonAriaLabel,a=void 0===c?"Clear the query":c,u=n.cancelButtonText,l=void 0===u?"Cancel":u,s=n.cancelButtonAriaLabel,f=void 0===s?"Cancel":s,p=r.getFormProps({inputElement:r.inputRef.current}).onReset;return Be.useEffect((function(){r.autoFocus&&r.inputRef.current&&r.inputRef.current.focus()}),[r.autoFocus,r.inputRef]),Be.useEffect((function(){r.isFromSelection&&r.inputRef.current&&r.inputRef.current.select()}),[r.isFromSelection,r.inputRef]),Be.createElement(Be.Fragment,null,Be.createElement("form",{className:"DocSearch-Form",onSubmit:function(e){e.preventDefault()},onReset:p},Be.createElement("label",lo({className:"DocSearch-MagnifierLabel"},r.getLabelProps()),Be.createElement(We,null)),Be.createElement("div",{className:"DocSearch-LoadingIndicator"},Be.createElement(hr,null)),Be.createElement("input",lo({className:"DocSearch-Input",ref:r.inputRef},r.getInputProps({inputElement:r.inputRef.current,autoFocus:r.autoFocus,maxLength:64}))),Be.createElement("button",{type:"reset",title:i,className:"DocSearch-Reset","aria-label":a,hidden:!r.state.query},Be.createElement(br,null))),Be.createElement("button",{className:"DocSearch-Cancel",type:"reset","aria-label":f,onClick:r.onClose},l))}var po=["_highlightResult","_snippetResult"];function mo(e,t){if(null==e)return{};var n,r,o=function(e,t){if(null==e)return{};var n,r,o={},i=Object.keys(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}function vo(e){return!1===function(){var e="__TEST_KEY__";try{return localStorage.setItem(e,""),localStorage.removeItem(e),!0}catch(e){return!1}}()?{setItem:function(){},getItem:function(){return[]}}:{setItem:function(t){return window.localStorage.setItem(e,JSON.stringify(t))},getItem:function(){var t=window.localStorage.getItem(e);return t?JSON.parse(t):[]}}}function ho(e){var t=e.key,n=e.limit,r=void 0===n?5:n,o=vo(t),i=o.getItem().slice(0,r);return{add:function(e){var t=e,n=(t._highlightResult,t._snippetResult,mo(t,po)),c=i.findIndex((function(e){return e.objectID===n.objectID}));c>-1&&i.splice(c,1),i.unshift(n),i=i.slice(0,r),o.setItem(i)},remove:function(e){i=i.filter((function(t){return t.objectID!==e.objectID})),o.setItem(i)},getAll:function(){return i}}}var yo=["facetName","facetQuery"];function bo(e){var t,n="algoliasearch-client-js-".concat(e.key),r=function(){return void 0===t&&(t=e.localStorage||window.localStorage),t},o=function(){return JSON.parse(r().getItem(n)||"{}")};return{get:function(e,t){var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{miss:function(){return Promise.resolve()}};return Promise.resolve().then((function(){var n=JSON.stringify(e),r=o()[n];return Promise.all([r||t(),void 0!==r])})).then((function(e){var t=c(e,2),r=t[0],o=t[1];return Promise.all([r,o||n.miss(r)])})).then((function(e){return c(e,1)[0]}))},set:function(e,t){return Promise.resolve().then((function(){var i=o();return i[JSON.stringify(e)]=t,r().setItem(n,JSON.stringify(i)),t}))},delete:function(e){return Promise.resolve().then((function(){var t=o();delete t[JSON.stringify(e)],r().setItem(n,JSON.stringify(t))}))},clear:function(){return Promise.resolve().then((function(){r().removeItem(n)}))}}}function _o(e){var t=a(e.caches),n=t.shift();return void 0===n?{get:function(e,t){var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{miss:function(){return Promise.resolve()}};return t().then((function(e){return Promise.all([e,n.miss(e)])})).then((function(e){return c(e,1)[0]}))},set:function(e,t){return Promise.resolve(t)},delete:function(e){return Promise.resolve()},clear:function(){return Promise.resolve()}}:{get:function(e,r){var o=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{miss:function(){return Promise.resolve()}};return n.get(e,r,o).catch((function(){return _o({caches:t}).get(e,r,o)}))},set:function(e,r){return n.set(e,r).catch((function(){return _o({caches:t}).set(e,r)}))},delete:function(e){return n.delete(e).catch((function(){return _o({caches:t}).delete(e)}))},clear:function(){return n.clear().catch((function(){return _o({caches:t}).clear()}))}}}function go(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{serializable:!0},t={};return{get:function(n,r){var o=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{miss:function(){return Promise.resolve()}},i=JSON.stringify(n);if(i in t)return Promise.resolve(e.serializable?JSON.parse(t[i]):t[i]);var c=r(),a=o&&o.miss||function(){return Promise.resolve()};return c.then((function(e){return a(e)})).then((function(){return c}))},set:function(n,r){return t[JSON.stringify(n)]=e.serializable?JSON.stringify(r):r,Promise.resolve(r)},delete:function(e){return delete t[JSON.stringify(e)],Promise.resolve()},clear:function(){return t={},Promise.resolve()}}}function Oo(e){for(var t=e.length-1;t>0;t--){var n=Math.floor(Math.random()*(t+1)),r=e[t];e[t]=e[n],e[n]=r}return e}function So(e,t){return t?(Object.keys(t).forEach((function(n){e[n]=t[n](e)})),e):e}function jo(e){for(var t=arguments.length,n=new Array(t>1?t-1:0),r=1;r<t;r++)n[r-1]=arguments[r];var o=0;return e.replace(/%s/g,(function(){return encodeURIComponent(n[o++])}))}var wo="4.8.5",Eo={WithinQueryParameters:0,WithinHeaders:1};function Po(e,t){var n=e||{},r=n.data||{};return Object.keys(n).forEach((function(e){-1===["timeout","headers","queryParameters","data","cacheable"].indexOf(e)&&(r[e]=n[e])})),{data:Object.entries(r).length>0?r:void 0,timeout:n.timeout||t,headers:n.headers||{},queryParameters:n.queryParameters||{},cacheable:n.cacheable}}var Io={Read:1,Write:2,Any:3},Do=1,ko=2,Ao=3,Co=12e4;function xo(e){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:Do;return t(t({},e),{},{status:n,lastUpdate:Date.now()})}function No(e){return"string"==typeof e?{protocol:"https",url:e,accept:Io.Any}:{protocol:e.protocol||"https",url:e.url,accept:e.accept||Io.Any}}var To="GET",Ro="POST";function qo(e,t){return Promise.all(t.map((function(t){return e.get(t,(function(){return Promise.resolve(xo(t))}))}))).then((function(e){var n=e.filter((function(e){return function(e){return e.status===Do||Date.now()-e.lastUpdate>Co}(e)})),r=e.filter((function(e){return function(e){return e.status===Ao&&Date.now()-e.lastUpdate<=Co}(e)})),o=[].concat(a(n),a(r));return{getTimeout:function(e,t){return(0===r.length&&0===e?1:r.length+3+e)*t},statelessHosts:o.length>0?o.map((function(e){return No(e)})):t}}))}function Lo(e,n,r,o){var i=[],c=function(e,n){if(e.method===To||void 0===e.data&&void 0===n.data)return;var r=Array.isArray(e.data)?e.data:t(t({},e.data),n.data);return JSON.stringify(r)}(r,o),u=function(e,n){var r=t(t({},e.headers),n.headers),o={};return Object.keys(r).forEach((function(e){var t=r[e];o[e.toLowerCase()]=t})),o}(e,o),l=r.method,s=r.method!==To?{}:t(t({},r.data),o.data),f=t(t(t({"x-algolia-agent":e.userAgent.value},e.queryParameters),s),o.queryParameters),p=0,m=function t(n,a){var s=n.pop();if(void 0===s)throw{name:"RetryError",message:"Unreachable hosts - your application id may be incorrect. If the error persists, contact support@algolia.com.",transporterStackTrace:Fo(i)};var m={data:c,headers:u,method:l,url:Ho(s,r.path,f),connectTimeout:a(p,e.timeouts.connect),responseTimeout:a(p,o.timeout)},v=function(e){var t={request:m,response:e,host:s,triesLeft:n.length};return i.push(t),t},d={onSucess:function(e){return function(e){try{return JSON.parse(e.content)}catch(t){throw function(e,t){return{name:"DeserializationError",message:e,response:t}}(t.message,e)}}(e)},onRetry:function(r){var o=v(r);return r.isTimedOut&&p++,Promise.all([e.logger.info("Retryable failure",Bo(o)),e.hostsCache.set(s,xo(s,r.isTimedOut?Ao:ko))]).then((function(){return t(n,a)}))},onFail:function(e){throw v(e),function(e,t){var n=e.content,r=e.status,o=n;try{o=JSON.parse(n).message}catch(e){}return function(e,t,n){return{name:"ApiError",message:e,status:t,transporterStackTrace:n}}(o,r,t)}(e,Fo(i))}};return e.requester.send(m).then((function(e){return function(e,t){return function(e){var t=e.status;return e.isTimedOut||function(e){var t=e.isTimedOut,n=e.status;return!t&&0==~~n}(e)||2!=~~(t/100)&&4!=~~(t/100)}(e)?t.onRetry(e):2==~~(e.status/100)?t.onSucess(e):t.onFail(e)}(e,d)}))};return qo(e.hostsCache,n).then((function(e){return m(a(e.statelessHosts).reverse(),e.getTimeout)}))}function Mo(e){var t={value:"Algolia for JavaScript (".concat(e,")"),add:function(e){var n="; ".concat(e.segment).concat(void 0!==e.version?" (".concat(e.version,")"):"");return-1===t.value.indexOf(n)&&(t.value="".concat(t.value).concat(n)),t}};return t}function Ho(e,t,n){var r=Uo(n),o="".concat(e.protocol,"://").concat(e.url,"/").concat("/"===t.charAt(0)?t.substr(1):t);return r.length&&(o+="?".concat(r)),o}function Uo(e){return Object.keys(e).map((function(t){return jo("%s=%s",t,(n=e[t],"[object Object]"===Object.prototype.toString.call(n)||"[object Array]"===Object.prototype.toString.call(n)?JSON.stringify(e[t]):e[t]));var n})).join("&")}function Fo(e){return e.map((function(e){return Bo(e)}))}function Bo(e){var n=e.request.headers["x-algolia-api-key"]?{"x-algolia-api-key":"*****"}:{};return t(t({},e),{},{request:t(t({},e.request),{},{headers:t(t({},e.request.headers),n)})})}var Vo=function(e){var n=e.appId,r=function(e,t,n){var r={"x-algolia-api-key":n,"x-algolia-application-id":t};return{headers:function(){return e===Eo.WithinHeaders?r:{}},queryParameters:function(){return e===Eo.WithinQueryParameters?r:{}}}}(void 0!==e.authMode?e.authMode:Eo.WithinHeaders,n,e.apiKey),o=function(e){var t=e.hostsCache,n=e.logger,r=e.requester,o=e.requestsCache,i=e.responsesCache,a=e.timeouts,u=e.userAgent,l=e.hosts,s=e.queryParameters,f={hostsCache:t,logger:n,requester:r,requestsCache:o,responsesCache:i,timeouts:a,userAgent:u,headers:e.headers,queryParameters:s,hosts:l.map((function(e){return No(e)})),read:function(e,t){var n=Po(t,f.timeouts.read),r=function(){return Lo(f,f.hosts.filter((function(e){return 0!=(e.accept&Io.Read)})),e,n)};if(!0!==(void 0!==n.cacheable?n.cacheable:e.cacheable))return r();var o={request:e,mappedRequestOptions:n,transporter:{queryParameters:f.queryParameters,headers:f.headers}};return f.responsesCache.get(o,(function(){return f.requestsCache.get(o,(function(){return f.requestsCache.set(o,r()).then((function(e){return Promise.all([f.requestsCache.delete(o),e])}),(function(e){return Promise.all([f.requestsCache.delete(o),Promise.reject(e)])})).then((function(e){var t=c(e,2);return t[0],t[1]}))}))}),{miss:function(e){return f.responsesCache.set(o,e)}})},write:function(e,t){return Lo(f,f.hosts.filter((function(e){return 0!=(e.accept&Io.Write)})),e,Po(t,f.timeouts.write))}};return f}(t(t({hosts:[{url:"".concat(n,"-dsn.algolia.net"),accept:Io.Read},{url:"".concat(n,".algolia.net"),accept:Io.Write}].concat(Oo([{url:"".concat(n,"-1.algolianet.com")},{url:"".concat(n,"-2.algolianet.com")},{url:"".concat(n,"-3.algolianet.com")}]))},e),{},{headers:t(t(t({},r.headers()),{"content-type":"application/x-www-form-urlencoded"}),e.headers),queryParameters:t(t({},r.queryParameters()),e.queryParameters)})),i={transporter:o,appId:n,addAlgoliaAgent:function(e,t){o.userAgent.add({segment:e,version:t})},clearCache:function(){return Promise.all([o.requestsCache.clear(),o.responsesCache.clear()]).then((function(){}))}};return So(i,e.methods)},Wo=function(e){return function(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},r={transporter:e.transporter,appId:e.appId,indexName:t};return So(r,n.methods)}},Ko=function(e){return function(n,r){var o=n.map((function(e){return t(t({},e),{},{params:Uo(e.params||{})})}));return e.transporter.read({method:Ro,path:"1/indexes/*/queries",data:{requests:o},cacheable:!0},r)}},zo=function(e){return function(n,r){return Promise.all(n.map((function(n){var o=n.params,c=o.facetName,a=o.facetQuery,u=i(o,yo);return Wo(e)(n.indexName,{methods:{searchForFacetValues:Qo}}).searchForFacetValues(c,a,t(t({},r),u))})))}},Jo=function(e){return function(t,n,r){return e.transporter.read({method:Ro,path:jo("1/answers/%s/prediction",e.indexName),data:{query:t,queryLanguages:n},cacheable:!0},r)}},$o=function(e){return function(t,n){return e.transporter.read({method:Ro,path:jo("1/indexes/%s/query",e.indexName),data:{query:t},cacheable:!0},n)}},Qo=function(e){return function(t,n,r){return e.transporter.read({method:Ro,path:jo("1/indexes/%s/facets/%s/query",e.indexName,t),data:{facetQuery:n},cacheable:!0},r)}},Zo=1,Yo=2,Go=3;function Xo(e,n,r){var o,i={appId:e,apiKey:n,timeouts:{connect:1,read:2,write:30},requester:{send:function(e){return new Promise((function(t){var n=new XMLHttpRequest;n.open(e.method,e.url,!0),Object.keys(e.headers).forEach((function(t){return n.setRequestHeader(t,e.headers[t])}));var r,o=function(e,r){return setTimeout((function(){n.abort(),t({status:0,content:r,isTimedOut:!0})}),1e3*e)},i=o(e.connectTimeout,"Connection timeout");n.onreadystatechange=function(){n.readyState>n.OPENED&&void 0===r&&(clearTimeout(i),r=o(e.responseTimeout,"Socket timeout"))},n.onerror=function(){0===n.status&&(clearTimeout(i),clearTimeout(r),t({content:n.responseText||"Network request failed",status:n.status,isTimedOut:!1}))},n.onload=function(){clearTimeout(i),clearTimeout(r),t({content:n.responseText,status:n.status,isTimedOut:!1})},n.send(e.data)}))}},logger:(o=Go,{debug:function(e,t){return Zo>=o&&console.debug(e,t),Promise.resolve()},info:function(e,t){return Yo>=o&&console.info(e,t),Promise.resolve()},error:function(e,t){return console.error(e,t),Promise.resolve()}}),responsesCache:go(),requestsCache:go({serializable:!1}),hostsCache:_o({caches:[bo({key:"".concat(wo,"-").concat(e)}),go()]}),userAgent:Mo(wo).add({segment:"Browser",version:"lite"}),authMode:Eo.WithinQueryParameters};return Vo(t(t(t({},i),r),{},{methods:{search:Ko,searchForFacetValues:zo,multipleQueries:Ko,multipleSearchForFacetValues:zo,initIndex:function(e){return function(t){return Wo(e)(t,{methods:{search:$o,searchForFacetValues:Qo,findAnswers:Jo}})}}}}))}Xo.version=wo;var ei="3.5.1";var ti=["footer","searchBox"];function ni(){return ni=Object.assign||function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},ni.apply(this,arguments)}function ri(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function oi(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?ri(Object(n),!0).forEach((function(t){ii(e,t,n[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):ri(Object(n)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))}))}return e}function ii(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function ci(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){var n=null==e?null:"undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(null==n)return;var r,o,i=[],c=!0,a=!1;try{for(n=n.call(e);!(c=(r=n.next()).done)&&(i.push(r.value),!t||i.length!==t);c=!0);}catch(e){a=!0,o=e}finally{try{c||null==n.return||n.return()}finally{if(a)throw o}}return i}(e,t)||function(e,t){if(!e)return;if("string"==typeof e)return ai(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);"Object"===n&&e.constructor&&(n=e.constructor.name);if("Map"===n||"Set"===n)return Array.from(e);if("Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n))return ai(e,t)}(e,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function ai(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n<t;n++)r[n]=e[n];return r}function ui(e,t){if(null==e)return{};var n,r,o=function(e,t){if(null==e)return{};var n,r,o={},i=Object.keys(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}function li(e){var t=e.appId,n=e.apiKey,r=e.indexName,o=e.placeholder,i=void 0===o?"Search docs":o,c=e.searchParameters,a=e.maxResultsPerGroup,u=e.onClose,l=void 0===u?$r:u,s=e.transformItems,f=void 0===s?zr:s,p=e.hitComponent,m=void 0===p?dr:p,v=e.resultsFooterComponent,d=void 0===v?function(){return null}:v,h=e.navigator,y=e.initialScrollY,b=void 0===y?0:y,_=e.transformSearchClient,g=void 0===_?zr:_,O=e.disableUserPersonalization,S=void 0!==O&&O,j=e.initialQuery,w=void 0===j?"":j,E=e.translations,P=void 0===E?{}:E,I=e.getMissingResultsUrl,D=e.insights,k=void 0!==D&&D,A=P.footer,C=P.searchBox,x=ui(P,ti),N=ci(Be.useState({query:"",collections:[],completion:null,context:{},isOpen:!1,activeItemId:null,status:"idle"}),2),T=N[0],R=N[1],q=Be.useRef(null),L=Be.useRef(null),M=Be.useRef(null),H=Be.useRef(null),U=Be.useRef(null),F=Be.useRef(10),B=Be.useRef("undefined"!=typeof window?window.getSelection().toString().slice(0,64):"").current,V=Be.useRef(w||B).current,W=function(e,t,n){return Be.useMemo((function(){var r=Xo(e,t);return r.addAlgoliaAgent("docsearch",ei),!1===/docsearch.js \(.*\)/.test(r.transporter.userAgent.value)&&r.addAlgoliaAgent("docsearch-react",ei),n(r)}),[e,t,n])}(t,n,g),K=Be.useRef(ho({key:"__DOCSEARCH_FAVORITE_SEARCHES__".concat(r),limit:10})).current,z=Be.useRef(ho({key:"__DOCSEARCH_RECENT_SEARCHES__".concat(r),limit:0===K.getAll().length?7:4})).current,J=Be.useCallback((function(e){if(!S){var t="content"===e.type?e.__docsearch_parent:e;t&&-1===K.getAll().findIndex((function(e){return e.objectID===t.objectID}))&&z.add(t)}}),[K,z,S]),$=Be.useCallback((function(e){if(T.context.algoliaInsightsPlugin&&e.__autocomplete_id){var t=e,n={eventName:"Item Selected",index:t.__autocomplete_indexName,items:[t],positions:[e.__autocomplete_id],queryID:t.__autocomplete_queryID};T.context.algoliaInsightsPlugin.insights.clickedObjectIDsAfterSearch(n)}}),[T.context.algoliaInsightsPlugin]),Q=Be.useMemo((function(){return fr({id:"docsearch",defaultActiveItemId:0,placeholder:i,openOnFocus:!0,initialState:{query:V,context:{searchSuggestions:[]}},insights:k,navigator:h,onStateChange:function(e){R(e.state)},getSources:function(e){var o=e.query,i=e.state,u=e.setContext,s=e.setStatus;if(!o)return S?[]:[{sourceId:"recentSearches",onSelect:function(e){var t=e.item,n=e.event;J(t),Jr(n)||l()},getItemUrl:function(e){return e.item.url},getItems:function(){return z.getAll()}},{sourceId:"favoriteSearches",onSelect:function(e){var t=e.item,n=e.event;J(t),Jr(n)||l()},getItemUrl:function(e){return e.item.url},getItems:function(){return K.getAll()}}];var p=Boolean(k);return W.search([{query:o,indexName:r,params:oi({attributesToRetrieve:["hierarchy.lvl0","hierarchy.lvl1","hierarchy.lvl2","hierarchy.lvl3","hierarchy.lvl4","hierarchy.lvl5","hierarchy.lvl6","content","type","url"],attributesToSnippet:["hierarchy.lvl1:".concat(F.current),"hierarchy.lvl2:".concat(F.current),"hierarchy.lvl3:".concat(F.current),"hierarchy.lvl4:".concat(F.current),"hierarchy.lvl5:".concat(F.current),"hierarchy.lvl6:".concat(F.current),"content:".concat(F.current)],snippetEllipsisText:"…",highlightPreTag:"<mark>",highlightPostTag:"</mark>",hitsPerPage:20,clickAnalytics:p},c)}]).catch((function(e){throw"RetryError"===e.name&&s("error"),e})).then((function(e){var o=e.results,c=o[0],s=c.hits,m=c.nbHits,v=Kr(s,(function(e){return Yr(e)}),a);i.context.searchSuggestions.length<Object.keys(v).length&&u({searchSuggestions:Object.keys(v)}),u({nbHits:m});var d={};return p&&(d={__autocomplete_indexName:r,__autocomplete_queryID:o[0].queryID,__autocomplete_algoliaCredentials:{appId:t,apiKey:n}}),Object.values(v).map((function(e,t){return{sourceId:"hits".concat(t),onSelect:function(e){var t=e.item,n=e.event;J(t),Jr(n)||l()},getItemUrl:function(e){return e.item.url},getItems:function(){return Object.values(Kr(e,(function(e){return e.hierarchy.lvl1}),a)).map(f).map((function(e){return e.map((function(t){var n=null,r=e.find((function(e){return"lvl1"===e.type&&e.hierarchy.lvl1===t.hierarchy.lvl1}));return"lvl1"!==t.type&&r&&(n=r),oi(oi({},t),{},{__docsearch_parent:n},d)}))})).flat()}}}))}))}})}),[r,c,a,W,l,z,K,J,V,i,h,f,S,k,t,n]),Z=Q.getEnvironmentProps,Y=Q.getRootProps,G=Q.refresh;return function(e){var t=e.getEnvironmentProps,n=e.panelElement,r=e.formElement,o=e.inputElement;Be.useEffect((function(){if(n&&r&&o){var e=t({panelElement:n,formElement:r,inputElement:o}),i=e.onTouchStart,c=e.onTouchMove;return window.addEventListener("touchstart",i),window.addEventListener("touchmove",c),function(){window.removeEventListener("touchstart",i),window.removeEventListener("touchmove",c)}}}),[t,n,r,o])}({getEnvironmentProps:Z,panelElement:H.current,formElement:M.current,inputElement:U.current}),function(e){var t=e.container;Be.useEffect((function(){if(t){var e=t.querySelectorAll("a[href]:not([disabled]), button:not([disabled]), input:not([disabled])"),n=e[0],r=e[e.length-1];return t.addEventListener("keydown",o),function(){t.removeEventListener("keydown",o)}}function o(e){"Tab"===e.key&&(e.shiftKey?document.activeElement===n&&(e.preventDefault(),r.focus()):document.activeElement===r&&(e.preventDefault(),n.focus()))}}),[t])}({container:q.current}),Be.useEffect((function(){return document.body.classList.add("DocSearch--active"),function(){var e,t;document.body.classList.remove("DocSearch--active"),null===(e=(t=window).scrollTo)||void 0===e||e.call(t,0,b)}}),[]),Be.useEffect((function(){window.matchMedia("(max-width: 768px)").matches&&(F.current=5)}),[]),Be.useEffect((function(){H.current&&(H.current.scrollTop=0)}),[T.query]),Be.useEffect((function(){V.length>0&&(G(),U.current&&U.current.focus())}),[V,G]),Be.useEffect((function(){function e(){if(L.current){var e=.01*window.innerHeight;L.current.style.setProperty("--docsearch-vh","".concat(e,"px"))}}return e(),window.addEventListener("resize",e),function(){window.removeEventListener("resize",e)}}),[]),Be.createElement("div",ni({ref:q},Y({"aria-expanded":!0}),{className:["DocSearch","DocSearch-Container","stalled"===T.status&&"DocSearch-Container--Stalled","error"===T.status&&"DocSearch-Container--Errored"].filter(Boolean).join(" "),role:"button",tabIndex:0,onMouseDown:function(e){e.target===e.currentTarget&&l()}}),Be.createElement("div",{className:"DocSearch-Modal",ref:L},Be.createElement("header",{className:"DocSearch-SearchBar",ref:M},Be.createElement(fo,ni({},Q,{state:T,autoFocus:0===V.length,inputRef:U,isFromSelection:Boolean(V)&&V===B,translations:C,onClose:l}))),Be.createElement("div",{className:"DocSearch-Dropdown",ref:H},Be.createElement(ao,ni({},Q,{indexName:r,state:T,hitComponent:m,resultsFooterComponent:d,disableUserPersonalization:S,recentSearches:z,favoriteSearches:K,inputRef:U,translations:x,getMissingResultsUrl:I,onItemClick:function(e,t){$(e),J(e),Jr(t)||l()}}))),Be.createElement("footer",{className:"DocSearch-Footer"},Be.createElement(vr,{translations:A}))))}function si(){return si=Object.assign||function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},si.apply(this,arguments)}function fi(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){var n=null==e?null:"undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(null==n)return;var r,o,i=[],c=!0,a=!1;try{for(n=n.call(e);!(c=(r=n.next()).done)&&(i.push(r.value),!t||i.length!==t);c=!0);}catch(e){a=!0,o=e}finally{try{c||null==n.return||n.return()}finally{if(a)throw o}}return i}(e,t)||function(e,t){if(!e)return;if("string"==typeof e)return pi(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);"Object"===n&&e.constructor&&(n=e.constructor.name);if("Map"===n||"Set"===n)return Array.from(e);if("Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n))return pi(e,t)}(e,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function pi(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n<t;n++)r[n]=e[n];return r}function mi(e){var t,n,r=Be.useRef(null),o=fi(Be.useState(!1),2),i=o[0],c=o[1],a=fi(Be.useState((null==e?void 0:e.initialQuery)||void 0),2),u=a[0],l=a[1],s=Be.useCallback((function(){c(!0)}),[c]),f=Be.useCallback((function(){c(!1)}),[c]);return function(e){var t=e.isOpen,n=e.onOpen,r=e.onClose,o=e.onInput,i=e.searchButtonRef;Be.useEffect((function(){function e(e){var c;(27===e.keyCode&&t||"k"===(null===(c=e.key)||void 0===c?void 0:c.toLowerCase())&&(e.metaKey||e.ctrlKey)||!function(e){var t=e.target,n=t.tagName;return t.isContentEditable||"INPUT"===n||"SELECT"===n||"TEXTAREA"===n}(e)&&"/"===e.key&&!t)&&(e.preventDefault(),t?r():document.body.classList.contains("DocSearch--active")||document.body.classList.contains("DocSearch--active")||n()),i&&i.current===document.activeElement&&o&&/[a-zA-Z0-9]/.test(String.fromCharCode(e.keyCode))&&o(e)}return window.addEventListener("keydown",e),function(){window.removeEventListener("keydown",e)}}),[t,n,r,o,i])}({isOpen:i,onOpen:s,onClose:f,onInput:Be.useCallback((function(e){c(!0),l(e.key)}),[c,l]),searchButtonRef:r}),Be.createElement(Be.Fragment,null,Be.createElement(Ye,{ref:r,translations:null==e||null===(t=e.translations)||void 0===t?void 0:t.button,onClick:s}),i&&Ie(Be.createElement(li,si({},e,{initialScrollY:window.scrollY,initialQuery:u,translations:null==e||null===(n=e.translations)||void 0===n?void 0:n.modal,onClose:f})),document.body))}return function(e){Ce(Be.createElement(mi,o({},e,{transformSearchClient:function(t){return t.addAlgoliaAgent("docsearch.js",ei),e.transformSearchClient?e.transformSearchClient(t):t}})),function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:window;return"string"==typeof e?t.document.querySelector(e):e}(e.container,e.environment))}})); +/*! @docsearch/js 3.6.1 | MIT License | © Algolia, Inc. and contributors | https://docsearch.algolia.com */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e=e||self).docsearch=t()}(this,(function(){"use strict";function e(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function t(t){for(var n=1;n<arguments.length;n++){var o=null!=arguments[n]?arguments[n]:{};n%2?e(Object(o),!0).forEach((function(e){r(t,e,o[e])})):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(o)):e(Object(o)).forEach((function(e){Object.defineProperty(t,e,Object.getOwnPropertyDescriptor(o,e))}))}return t}function n(e){return n="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},n(e)}function r(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function o(){return o=Object.assign||function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},o.apply(this,arguments)}function i(e,t){if(null==e)return{};var n,r,o=function(e,t){if(null==e)return{};var n,r,o={},i=Object.keys(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}function c(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){var n=null==e?null:"undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(null==n)return;var r,o,i=[],c=!0,a=!1;try{for(n=n.call(e);!(c=(r=n.next()).done)&&(i.push(r.value),!t||i.length!==t);c=!0);}catch(e){a=!0,o=e}finally{try{c||null==n.return||n.return()}finally{if(a)throw o}}return i}(e,t)||u(e,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function a(e){return function(e){if(Array.isArray(e))return l(e)}(e)||function(e){if("undefined"!=typeof Symbol&&null!=e[Symbol.iterator]||null!=e["@@iterator"])return Array.from(e)}(e)||u(e)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function u(e,t){if(e){if("string"==typeof e)return l(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);return"Object"===n&&e.constructor&&(n=e.constructor.name),"Map"===n||"Set"===n?Array.from(e):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?l(e,t):void 0}}function l(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n<t;n++)r[n]=e[n];return r}var s,f,p,m,d,v={},h=[],y=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i;function _(e,t){for(var n in t)e[n]=t[n];return e}function b(e){var t=e.parentNode;t&&t.removeChild(e)}function g(e,t,n){var r,o,i,c=arguments,a={};for(i in t)"key"==i?r=t[i]:"ref"==i?o=t[i]:a[i]=t[i];if(arguments.length>3)for(n=[n],i=3;i<arguments.length;i++)n.push(c[i]);if(null!=n&&(a.children=n),"function"==typeof e&&null!=e.defaultProps)for(i in e.defaultProps)void 0===a[i]&&(a[i]=e.defaultProps[i]);return S(e,a,r,o,null)}function S(e,t,n,r,o){var i={type:e,props:t,key:n,ref:r,__k:null,__:null,__b:0,__e:null,__d:void 0,__c:null,__h:null,constructor:void 0,__v:null==o?++s.__v:o};return null!=s.vnode&&s.vnode(i),i}function O(e){return e.children}function w(e,t){this.props=e,this.context=t}function E(e,t){if(null==t)return e.__?E(e.__,e.__.__k.indexOf(e)+1):null;for(var n;t<e.__k.length;t++)if(null!=(n=e.__k[t])&&null!=n.__e)return n.__e;return"function"==typeof e.type?E(e):null}function j(e){var t,n;if(null!=(e=e.__)&&null!=e.__c){for(e.__e=e.__c.base=null,t=0;t<e.__k.length;t++)if(null!=(n=e.__k[t])&&null!=n.__e){e.__e=e.__c.base=n.__e;break}return j(e)}}function P(e){(!e.__d&&(e.__d=!0)&&f.push(e)&&!I.__r++||m!==s.debounceRendering)&&((m=s.debounceRendering)||p)(I)}function I(){for(var e;I.__r=f.length;)e=f.sort((function(e,t){return e.__v.__b-t.__v.__b})),f=[],e.some((function(e){var t,n,r,o,i,c;e.__d&&(i=(o=(t=e).__v).__e,(c=t.__P)&&(n=[],(r=_({},o)).__v=o.__v+1,q(c,o,r,t.__n,void 0!==c.ownerSVGElement,null!=o.__h?[i]:null,n,null==i?E(o):i,o.__h),L(n,o),o.__e!=i&&j(o)))}))}function D(e,t,n,r,o,i,c,a,u,l){var s,f,p,m,d,y,_,b=r&&r.__k||h,g=b.length;for(n.__k=[],s=0;s<t.length;s++)if(null!=(m=n.__k[s]=null==(m=t[s])||"boolean"==typeof m?null:"string"==typeof m||"number"==typeof m?S(null,m,null,null,m):Array.isArray(m)?S(O,{children:m},null,null,null):m.__b>0?S(m.type,m.props,m.key,null,m.__v):m)){if(m.__=n,m.__b=n.__b+1,null===(p=b[s])||p&&m.key==p.key&&m.type===p.type)b[s]=void 0;else for(f=0;f<g;f++){if((p=b[f])&&m.key==p.key&&m.type===p.type){b[f]=void 0;break}p=null}q(e,m,p=p||v,o,i,c,a,u,l),d=m.__e,(f=m.ref)&&p.ref!=f&&(_||(_=[]),p.ref&&_.push(p.ref,null,m),_.push(f,m.__c||d,m)),null!=d?(null==y&&(y=d),"function"==typeof m.type&&null!=m.__k&&m.__k===p.__k?m.__d=u=k(m,u,e):u=A(e,m,p,b,d,u),l||"option"!==n.type?"function"==typeof n.type&&(n.__d=u):e.value=""):u&&p.__e==u&&u.parentNode!=e&&(u=E(p))}for(n.__e=y,s=g;s--;)null!=b[s]&&("function"==typeof n.type&&null!=b[s].__e&&b[s].__e==n.__d&&(n.__d=E(r,s+1)),U(b[s],b[s]));if(_)for(s=0;s<_.length;s++)H(_[s],_[++s],_[++s])}function k(e,t,n){var r,o;for(r=0;r<e.__k.length;r++)(o=e.__k[r])&&(o.__=e,t="function"==typeof o.type?k(o,t,n):A(n,o,o,e.__k,o.__e,t));return t}function C(e,t){return t=t||[],null==e||"boolean"==typeof e||(Array.isArray(e)?e.some((function(e){C(e,t)})):t.push(e)),t}function A(e,t,n,r,o,i){var c,a,u;if(void 0!==t.__d)c=t.__d,t.__d=void 0;else if(null==n||o!=i||null==o.parentNode)e:if(null==i||i.parentNode!==e)e.appendChild(o),c=null;else{for(a=i,u=0;(a=a.nextSibling)&&u<r.length;u+=2)if(a==o)break e;e.insertBefore(o,i),c=i}return void 0!==c?c:o.nextSibling}function x(e,t,n){"-"===t[0]?e.setProperty(t,n):e[t]=null==n?"":"number"!=typeof n||y.test(t)?n:n+"px"}function N(e,t,n,r,o){var i;e:if("style"===t)if("string"==typeof n)e.style.cssText=n;else{if("string"==typeof r&&(e.style.cssText=r=""),r)for(t in r)n&&t in n||x(e.style,t,"");if(n)for(t in n)r&&n[t]===r[t]||x(e.style,t,n[t])}else if("o"===t[0]&&"n"===t[1])i=t!==(t=t.replace(/Capture$/,"")),t=t.toLowerCase()in e?t.toLowerCase().slice(2):t.slice(2),e.l||(e.l={}),e.l[t+i]=n,n?r||e.addEventListener(t,i?R:T,i):e.removeEventListener(t,i?R:T,i);else if("dangerouslySetInnerHTML"!==t){if(o)t=t.replace(/xlink[H:h]/,"h").replace(/sName$/,"s");else if("href"!==t&&"list"!==t&&"form"!==t&&"download"!==t&&t in e)try{e[t]=null==n?"":n;break e}catch(e){}"function"==typeof n||(null!=n&&(!1!==n||"a"===t[0]&&"r"===t[1])?e.setAttribute(t,n):e.removeAttribute(t))}}function T(e){this.l[e.type+!1](s.event?s.event(e):e)}function R(e){this.l[e.type+!0](s.event?s.event(e):e)}function q(e,t,n,r,o,i,c,a,u){var l,f,p,m,d,v,h,y,b,g,S,E=t.type;if(void 0!==t.constructor)return null;null!=n.__h&&(u=n.__h,a=t.__e=n.__e,t.__h=null,i=[a]),(l=s.__b)&&l(t);try{e:if("function"==typeof E){if(y=t.props,b=(l=E.contextType)&&r[l.__c],g=l?b?b.props.value:l.__:r,n.__c?h=(f=t.__c=n.__c).__=f.__E:("prototype"in E&&E.prototype.render?t.__c=f=new E(y,g):(t.__c=f=new w(y,g),f.constructor=E,f.render=F),b&&b.sub(f),f.props=y,f.state||(f.state={}),f.context=g,f.__n=r,p=f.__d=!0,f.__h=[]),null==f.__s&&(f.__s=f.state),null!=E.getDerivedStateFromProps&&(f.__s==f.state&&(f.__s=_({},f.__s)),_(f.__s,E.getDerivedStateFromProps(y,f.__s))),m=f.props,d=f.state,p)null==E.getDerivedStateFromProps&&null!=f.componentWillMount&&f.componentWillMount(),null!=f.componentDidMount&&f.__h.push(f.componentDidMount);else{if(null==E.getDerivedStateFromProps&&y!==m&&null!=f.componentWillReceiveProps&&f.componentWillReceiveProps(y,g),!f.__e&&null!=f.shouldComponentUpdate&&!1===f.shouldComponentUpdate(y,f.__s,g)||t.__v===n.__v){f.props=y,f.state=f.__s,t.__v!==n.__v&&(f.__d=!1),f.__v=t,t.__e=n.__e,t.__k=n.__k,f.__h.length&&c.push(f);break e}null!=f.componentWillUpdate&&f.componentWillUpdate(y,f.__s,g),null!=f.componentDidUpdate&&f.__h.push((function(){f.componentDidUpdate(m,d,v)}))}f.context=g,f.props=y,f.state=f.__s,(l=s.__r)&&l(t),f.__d=!1,f.__v=t,f.__P=e,l=f.render(f.props,f.state,f.context),f.state=f.__s,null!=f.getChildContext&&(r=_(_({},r),f.getChildContext())),p||null==f.getSnapshotBeforeUpdate||(v=f.getSnapshotBeforeUpdate(m,d)),S=null!=l&&l.type===O&&null==l.key?l.props.children:l,D(e,Array.isArray(S)?S:[S],t,n,r,o,i,c,a,u),f.base=t.__e,t.__h=null,f.__h.length&&c.push(f),h&&(f.__E=f.__=null),f.__e=!1}else null==i&&t.__v===n.__v?(t.__k=n.__k,t.__e=n.__e):t.__e=M(n.__e,t,n,r,o,i,c,u);(l=s.diffed)&&l(t)}catch(e){t.__v=null,(u||null!=i)&&(t.__e=a,t.__h=!!u,i[i.indexOf(a)]=null),s.__e(e,t,n)}}function L(e,t){s.__c&&s.__c(t,e),e.some((function(t){try{e=t.__h,t.__h=[],e.some((function(e){e.call(t)}))}catch(e){s.__e(e,t.__v)}}))}function M(e,t,n,r,o,i,c,a){var u,l,s,f,p=n.props,m=t.props,d=t.type,y=0;if("svg"===d&&(o=!0),null!=i)for(;y<i.length;y++)if((u=i[y])&&(u===e||(d?u.localName==d:3==u.nodeType))){e=u,i[y]=null;break}if(null==e){if(null===d)return document.createTextNode(m);e=o?document.createElementNS("http://www.w3.org/2000/svg",d):document.createElement(d,m.is&&m),i=null,a=!1}if(null===d)p===m||a&&e.data===m||(e.data=m);else{if(i=i&&h.slice.call(e.childNodes),l=(p=n.props||v).dangerouslySetInnerHTML,s=m.dangerouslySetInnerHTML,!a){if(null!=i)for(p={},f=0;f<e.attributes.length;f++)p[e.attributes[f].name]=e.attributes[f].value;(s||l)&&(s&&(l&&s.__html==l.__html||s.__html===e.innerHTML)||(e.innerHTML=s&&s.__html||""))}if(function(e,t,n,r,o){var i;for(i in n)"children"===i||"key"===i||i in t||N(e,i,null,n[i],r);for(i in t)o&&"function"!=typeof t[i]||"children"===i||"key"===i||"value"===i||"checked"===i||n[i]===t[i]||N(e,i,t[i],n[i],r)}(e,m,p,o,a),s)t.__k=[];else if(y=t.props.children,D(e,Array.isArray(y)?y:[y],t,n,r,o&&"foreignObject"!==d,i,c,e.firstChild,a),null!=i)for(y=i.length;y--;)null!=i[y]&&b(i[y]);a||("value"in m&&void 0!==(y=m.value)&&(y!==e.value||"progress"===d&&!y)&&N(e,"value",y,p.value,!1),"checked"in m&&void 0!==(y=m.checked)&&y!==e.checked&&N(e,"checked",y,p.checked,!1))}return e}function H(e,t,n){try{"function"==typeof e?e(t):e.current=t}catch(e){s.__e(e,n)}}function U(e,t,n){var r,o,i;if(s.unmount&&s.unmount(e),(r=e.ref)&&(r.current&&r.current!==e.__e||H(r,null,t)),n||"function"==typeof e.type||(n=null!=(o=e.__e)),e.__e=e.__d=void 0,null!=(r=e.__c)){if(r.componentWillUnmount)try{r.componentWillUnmount()}catch(e){s.__e(e,t)}r.base=r.__P=null}if(r=e.__k)for(i=0;i<r.length;i++)r[i]&&U(r[i],t,n);null!=o&&b(o)}function F(e,t,n){return this.constructor(e,n)}function B(e,t,n){var r,o,i;s.__&&s.__(e,t),o=(r="function"==typeof n)?null:n&&n.__k||t.__k,i=[],q(t,e=(!r&&n||t).__k=g(O,null,[e]),o||v,v,void 0!==t.ownerSVGElement,!r&&n?[n]:o?null:t.firstChild?h.slice.call(t.childNodes):null,i,!r&&n?n:o?o.__e:t.firstChild,r),L(i,e)}function V(e,t){B(e,t,V)}function K(e,t,n){var r,o,i,c=arguments,a=_({},e.props);for(i in t)"key"==i?r=t[i]:"ref"==i?o=t[i]:a[i]=t[i];if(arguments.length>3)for(n=[n],i=3;i<arguments.length;i++)n.push(c[i]);return null!=n&&(a.children=n),S(e.type,a,r||e.key,o||e.ref,null)}s={__e:function(e,t){for(var n,r,o;t=t.__;)if((n=t.__c)&&!n.__)try{if((r=n.constructor)&&null!=r.getDerivedStateFromError&&(n.setState(r.getDerivedStateFromError(e)),o=n.__d),null!=n.componentDidCatch&&(n.componentDidCatch(e),o=n.__d),o)return n.__E=n}catch(t){e=t}throw e},__v:0},w.prototype.setState=function(e,t){var n;n=null!=this.__s&&this.__s!==this.state?this.__s:this.__s=_({},this.state),"function"==typeof e&&(e=e(_({},n),this.props)),e&&_(n,e),null!=e&&this.__v&&(t&&this.__h.push(t),P(this))},w.prototype.forceUpdate=function(e){this.__v&&(this.__e=!0,e&&this.__h.push(e),P(this))},w.prototype.render=O,f=[],p="function"==typeof Promise?Promise.prototype.then.bind(Promise.resolve()):setTimeout,I.__r=0,d=0;var W,z,J,$=0,Z=[],Q=s.__b,Y=s.__r,G=s.diffed,X=s.__c,ee=s.unmount;function te(e,t){s.__h&&s.__h(z,e,$||t),$=0;var n=z.__H||(z.__H={__:[],__h:[]});return e>=n.__.length&&n.__.push({}),n.__[e]}function ne(e){return $=1,re(pe,e)}function re(e,t,n){var r=te(W++,2);return r.t=e,r.__c||(r.__=[n?n(t):pe(void 0,t),function(e){var t=r.t(r.__[0],e);r.__[0]!==t&&(r.__=[t,r.__[1]],r.__c.setState({}))}],r.__c=z),r.__}function oe(e,t){var n=te(W++,3);!s.__s&&fe(n.__H,t)&&(n.__=e,n.__H=t,z.__H.__h.push(n))}function ie(e,t){var n=te(W++,4);!s.__s&&fe(n.__H,t)&&(n.__=e,n.__H=t,z.__h.push(n))}function ce(e,t){var n=te(W++,7);return fe(n.__H,t)&&(n.__=e(),n.__H=t,n.__h=e),n.__}function ae(){Z.forEach((function(e){if(e.__P)try{e.__H.__h.forEach(le),e.__H.__h.forEach(se),e.__H.__h=[]}catch(t){e.__H.__h=[],s.__e(t,e.__v)}})),Z=[]}s.__b=function(e){z=null,Q&&Q(e)},s.__r=function(e){Y&&Y(e),W=0;var t=(z=e.__c).__H;t&&(t.__h.forEach(le),t.__h.forEach(se),t.__h=[])},s.diffed=function(e){G&&G(e);var t=e.__c;t&&t.__H&&t.__H.__h.length&&(1!==Z.push(t)&&J===s.requestAnimationFrame||((J=s.requestAnimationFrame)||function(e){var t,n=function(){clearTimeout(r),ue&&cancelAnimationFrame(t),setTimeout(e)},r=setTimeout(n,100);ue&&(t=requestAnimationFrame(n))})(ae)),z=void 0},s.__c=function(e,t){t.some((function(e){try{e.__h.forEach(le),e.__h=e.__h.filter((function(e){return!e.__||se(e)}))}catch(n){t.some((function(e){e.__h&&(e.__h=[])})),t=[],s.__e(n,e.__v)}})),X&&X(e,t)},s.unmount=function(e){ee&&ee(e);var t=e.__c;if(t&&t.__H)try{t.__H.__.forEach(le)}catch(e){s.__e(e,t.__v)}};var ue="function"==typeof requestAnimationFrame;function le(e){var t=z;"function"==typeof e.__c&&e.__c(),z=t}function se(e){var t=z;e.__c=e.__(),z=t}function fe(e,t){return!e||e.length!==t.length||t.some((function(t,n){return t!==e[n]}))}function pe(e,t){return"function"==typeof t?t(e):t}function me(e,t){for(var n in t)e[n]=t[n];return e}function de(e,t){for(var n in e)if("__source"!==n&&!(n in t))return!0;for(var r in t)if("__source"!==r&&e[r]!==t[r])return!0;return!1}function ve(e){this.props=e}(ve.prototype=new w).isPureReactComponent=!0,ve.prototype.shouldComponentUpdate=function(e,t){return de(this.props,e)||de(this.state,t)};var he=s.__b;s.__b=function(e){e.type&&e.type.__f&&e.ref&&(e.props.ref=e.ref,e.ref=null),he&&he(e)};var ye="undefined"!=typeof Symbol&&Symbol.for&&Symbol.for("react.forward_ref")||3911;var _e=function(e,t){return null==e?null:C(C(e).map(t))},be={map:_e,forEach:_e,count:function(e){return e?C(e).length:0},only:function(e){var t=C(e);if(1!==t.length)throw"Children.only";return t[0]},toArray:C},ge=s.__e;function Se(){this.__u=0,this.t=null,this.__b=null}function Oe(e){var t=e.__.__c;return t&&t.__e&&t.__e(e)}function we(){this.u=null,this.o=null}s.__e=function(e,t,n){if(e.then)for(var r,o=t;o=o.__;)if((r=o.__c)&&r.__c)return null==t.__e&&(t.__e=n.__e,t.__k=n.__k),r.__c(e,t);ge(e,t,n)},(Se.prototype=new w).__c=function(e,t){var n=t.__c,r=this;null==r.t&&(r.t=[]),r.t.push(n);var o=Oe(r.__v),i=!1,c=function(){i||(i=!0,n.componentWillUnmount=n.__c,o?o(a):a())};n.__c=n.componentWillUnmount,n.componentWillUnmount=function(){c(),n.__c&&n.__c()};var a=function(){if(!--r.__u){if(r.state.__e){var e=r.state.__e;r.__v.__k[0]=function e(t,n,r){return t&&(t.__v=null,t.__k=t.__k&&t.__k.map((function(t){return e(t,n,r)})),t.__c&&t.__c.__P===n&&(t.__e&&r.insertBefore(t.__e,t.__d),t.__c.__e=!0,t.__c.__P=r)),t}(e,e.__c.__P,e.__c.__O)}var t;for(r.setState({__e:r.__b=null});t=r.t.pop();)t.forceUpdate()}},u=!0===t.__h;r.__u++||u||r.setState({__e:r.__b=r.__v.__k[0]}),e.then(c,c)},Se.prototype.componentWillUnmount=function(){this.t=[]},Se.prototype.render=function(e,t){if(this.__b){if(this.__v.__k){var n=document.createElement("div"),r=this.__v.__k[0].__c;this.__v.__k[0]=function e(t,n,r){return t&&(t.__c&&t.__c.__H&&(t.__c.__H.__.forEach((function(e){"function"==typeof e.__c&&e.__c()})),t.__c.__H=null),null!=(t=me({},t)).__c&&(t.__c.__P===r&&(t.__c.__P=n),t.__c=null),t.__k=t.__k&&t.__k.map((function(t){return e(t,n,r)}))),t}(this.__b,n,r.__O=r.__P)}this.__b=null}var o=t.__e&&g(O,null,e.fallback);return o&&(o.__h=null),[g(O,null,t.__e?null:e.children),o]};var Ee=function(e,t,n){if(++n[1]===n[0]&&e.o.delete(t),e.props.revealOrder&&("t"!==e.props.revealOrder[0]||!e.o.size))for(n=e.u;n;){for(;n.length>3;)n.pop()();if(n[1]<n[0])break;e.u=n=n[2]}};function je(e){return this.getChildContext=function(){return e.context},e.children}function Pe(e){var t=this,n=e.i;t.componentWillUnmount=function(){B(null,t.l),t.l=null,t.i=null},t.i&&t.i!==n&&t.componentWillUnmount(),e.__v?(t.l||(t.i=n,t.l={nodeType:1,parentNode:n,childNodes:[],appendChild:function(e){this.childNodes.push(e),t.i.appendChild(e)},insertBefore:function(e,n){this.childNodes.push(e),t.i.appendChild(e)},removeChild:function(e){this.childNodes.splice(this.childNodes.indexOf(e)>>>1,1),t.i.removeChild(e)}}),B(g(je,{context:t.context},e.__v),t.l)):t.l&&t.componentWillUnmount()}function Ie(e,t){return g(Pe,{__v:e,i:t})}(we.prototype=new w).__e=function(e){var t=this,n=Oe(t.__v),r=t.o.get(e);return r[0]++,function(o){var i=function(){t.props.revealOrder?(r.push(o),Ee(t,e,r)):o()};n?n(i):i()}},we.prototype.render=function(e){this.u=null,this.o=new Map;var t=C(e.children);e.revealOrder&&"b"===e.revealOrder[0]&&t.reverse();for(var n=t.length;n--;)this.o.set(t[n],this.u=[1,0,this.u]);return e.children},we.prototype.componentDidUpdate=we.prototype.componentDidMount=function(){var e=this;this.o.forEach((function(t,n){Ee(e,n,t)}))};var De="undefined"!=typeof Symbol&&Symbol.for&&Symbol.for("react.element")||60103,ke=/^(?:accent|alignment|arabic|baseline|cap|clip(?!PathU)|color|fill|flood|font|glyph(?!R)|horiz|marker(?!H|W|U)|overline|paint|stop|strikethrough|stroke|text(?!L)|underline|unicode|units|v|vector|vert|word|writing|x(?!C))[A-Z]/,Ce=function(e){return("undefined"!=typeof Symbol&&"symbol"==n(Symbol())?/fil|che|rad/i:/fil|che|ra/i).test(e)};function Ae(e,t,n){return null==t.__k&&(t.textContent=""),B(e,t),"function"==typeof n&&n(),e?e.__c:null}w.prototype.isReactComponent={},["componentWillMount","componentWillReceiveProps","componentWillUpdate"].forEach((function(e){Object.defineProperty(w.prototype,e,{configurable:!0,get:function(){return this["UNSAFE_"+e]},set:function(t){Object.defineProperty(this,e,{configurable:!0,writable:!0,value:t})}})}));var xe=s.event;function Ne(){}function Te(){return this.cancelBubble}function Re(){return this.defaultPrevented}s.event=function(e){return xe&&(e=xe(e)),e.persist=Ne,e.isPropagationStopped=Te,e.isDefaultPrevented=Re,e.nativeEvent=e};var qe,Le={configurable:!0,get:function(){return this.class}},Me=s.vnode;s.vnode=function(e){var t=e.type,n=e.props,r=n;if("string"==typeof t){for(var o in r={},n){var i=n[o];"value"===o&&"defaultValue"in n&&null==i||("defaultValue"===o&&"value"in n&&null==n.value?o="value":"download"===o&&!0===i?i="":/ondoubleclick/i.test(o)?o="ondblclick":/^onchange(textarea|input)/i.test(o+t)&&!Ce(n.type)?o="oninput":/^on(Ani|Tra|Tou|BeforeInp)/.test(o)?o=o.toLowerCase():ke.test(o)?o=o.replace(/[A-Z0-9]/,"-$&").toLowerCase():null===i&&(i=void 0),r[o]=i)}"select"==t&&r.multiple&&Array.isArray(r.value)&&(r.value=C(n.children).forEach((function(e){e.props.selected=-1!=r.value.indexOf(e.props.value)}))),"select"==t&&null!=r.defaultValue&&(r.value=C(n.children).forEach((function(e){e.props.selected=r.multiple?-1!=r.defaultValue.indexOf(e.props.value):r.defaultValue==e.props.value}))),e.props=r}t&&n.class!=n.className&&(Le.enumerable="className"in n,null!=n.className&&(r.class=n.className),Object.defineProperty(r,"className",Le)),e.$$typeof=De,Me&&Me(e)};var He=s.__r;s.__r=function(e){He&&He(e),qe=e.__c};var Ue={ReactCurrentDispatcher:{current:{readContext:function(e){return qe.__n[e.__c].props.value}}}};"object"==("undefined"==typeof performance?"undefined":n(performance))&&"function"==typeof performance.now&&performance.now.bind(performance);function Fe(e){return!!e&&e.$$typeof===De}var Be={useState:ne,useReducer:re,useEffect:oe,useLayoutEffect:ie,useRef:function(e){return $=5,ce((function(){return{current:e}}),[])},useImperativeHandle:function(e,t,n){$=6,ie((function(){"function"==typeof e?e(t()):e&&(e.current=t())}),null==n?n:n.concat(e))},useMemo:ce,useCallback:function(e,t){return $=8,ce((function(){return e}),t)},useContext:function(e){var t=z.context[e.__c],n=te(W++,9);return n.__c=e,t?(null==n.__&&(n.__=!0,t.sub(z)),t.props.value):e.__},useDebugValue:function(e,t){s.useDebugValue&&s.useDebugValue(t?t(e):e)},version:"16.8.0",Children:be,render:Ae,hydrate:function(e,t,n){return V(e,t),"function"==typeof n&&n(),e?e.__c:null},unmountComponentAtNode:function(e){return!!e.__k&&(B(null,e),!0)},createPortal:Ie,createElement:g,createContext:function(e,t){var n={__c:t="__cC"+d++,__:e,Consumer:function(e,t){return e.children(t)},Provider:function(e){var n,r;return this.getChildContext||(n=[],(r={})[t]=this,this.getChildContext=function(){return r},this.shouldComponentUpdate=function(e){this.props.value!==e.value&&n.some(P)},this.sub=function(e){n.push(e);var t=e.componentWillUnmount;e.componentWillUnmount=function(){n.splice(n.indexOf(e),1),t&&t.call(e)}}),e.children}};return n.Provider.__=n.Consumer.contextType=n},createFactory:function(e){return g.bind(null,e)},cloneElement:function(e){return Fe(e)?K.apply(null,arguments):e},createRef:function(){return{current:null}},Fragment:O,isValidElement:Fe,findDOMNode:function(e){return e&&(e.base||1===e.nodeType&&e)||null},Component:w,PureComponent:ve,memo:function(e,t){function n(e){var n=this.props.ref,r=n==e.ref;return!r&&n&&(n.call?n(null):n.current=null),t?!t(this.props,e)||!r:de(this.props,e)}function r(t){return this.shouldComponentUpdate=n,g(e,t)}return r.displayName="Memo("+(e.displayName||e.name)+")",r.prototype.isReactComponent=!0,r.__f=!0,r},forwardRef:function(e){function t(t,r){var o=me({},t);return delete o.ref,e(o,(r=t.ref||r)&&("object"!=n(r)||"current"in r)?r:null)}return t.$$typeof=ye,t.render=t,t.prototype.isReactComponent=t.__f=!0,t.displayName="ForwardRef("+(e.displayName||e.name)+")",t},unstable_batchedUpdates:function(e,t){return e(t)},StrictMode:O,Suspense:Se,SuspenseList:we,lazy:function(e){var t,n,r;function o(o){if(t||(t=e()).then((function(e){n=e.default||e}),(function(e){r=e})),r)throw r;if(!n)throw t;return g(n,o)}return o.displayName="Lazy",o.__f=!0,o},__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED:Ue},Ve=["facetName","facetQuery"];function Ke(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function We(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?Ke(Object(n),!0).forEach((function(t){ze(e,t,n[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):Ke(Object(n)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))}))}return e}function ze(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function Je(){return Je=Object.assign||function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},Je.apply(this,arguments)}function $e(e,t){if(null==e)return{};var n,r,o=function(e,t){if(null==e)return{};var n,r,o={},i=Object.keys(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}function Ze(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){var n=null==e?null:"undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(null!=n){var r,o,i=[],c=!0,a=!1;try{for(n=n.call(e);!(c=(r=n.next()).done)&&(i.push(r.value),!t||i.length!==t);c=!0);}catch(e){a=!0,o=e}finally{try{c||null==n.return||n.return()}finally{if(a)throw o}}return i}}(e,t)||Qe(e,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function Qe(e,t){if(e){if("string"==typeof e)return Ye(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);return"Object"===n&&e.constructor&&(n=e.constructor.name),"Map"===n||"Set"===n?Array.from(e):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?Ye(e,t):void 0}}function Ye(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n<t;n++)r[n]=e[n];return r}function Ge(){return Be.createElement("svg",{width:"15",height:"15",className:"DocSearch-Control-Key-Icon"},Be.createElement("path",{d:"M4.505 4.496h2M5.505 5.496v5M8.216 4.496l.055 5.993M10 7.5c.333.333.5.667.5 1v2M12.326 4.5v5.996M8.384 4.496c1.674 0 2.116 0 2.116 1.5s-.442 1.5-2.116 1.5M3.205 9.303c-.09.448-.277 1.21-1.241 1.203C1 10.5.5 9.513.5 8V7c0-1.57.5-2.5 1.464-2.494.964.006 1.134.598 1.24 1.342M12.553 10.5h1.953",strokeWidth:"1.2",stroke:"currentColor",fill:"none",strokeLinecap:"square"}))}function Xe(){return Be.createElement("svg",{width:"20",height:"20",className:"DocSearch-Search-Icon",viewBox:"0 0 20 20","aria-hidden":"true"},Be.createElement("path",{d:"M14.386 14.386l4.0877 4.0877-4.0877-4.0877c-2.9418 2.9419-7.7115 2.9419-10.6533 0-2.9419-2.9418-2.9419-7.7115 0-10.6533 2.9418-2.9419 7.7115-2.9419 10.6533 0 2.9419 2.9418 2.9419 7.7115 0 10.6533z",stroke:"currentColor",fill:"none",fillRule:"evenodd",strokeLinecap:"round",strokeLinejoin:"round"}))}var et=["translations"],tt=Be.forwardRef((function(e,t){var n=e.translations,r=void 0===n?{}:n,o=$e(e,et),i=r.buttonText,c=void 0===i?"Search":i,a=r.buttonAriaLabel,u=void 0===a?"Search":a,l=Ze(ne(null),2),s=l[0],f=l[1];return oe((function(){"undefined"!=typeof navigator&&(/(Mac|iPhone|iPod|iPad)/i.test(navigator.platform)?f("⌘"):f("Ctrl"))}),[]),Be.createElement("button",Je({type:"button",className:"DocSearch DocSearch-Button","aria-label":u},o,{ref:t}),Be.createElement("span",{className:"DocSearch-Button-Container"},Be.createElement(Xe,null),Be.createElement("span",{className:"DocSearch-Button-Placeholder"},c)),Be.createElement("span",{className:"DocSearch-Button-Keys"},null!==s&&Be.createElement(Be.Fragment,null,Be.createElement(nt,{reactsToKey:"Ctrl"===s?"Ctrl":"Meta"},"Ctrl"===s?Be.createElement(Ge,null):s),Be.createElement(nt,{reactsToKey:"k"},"K"))))}));function nt(e){var t=e.reactsToKey,n=e.children,r=Ze(ne(!1),2),o=r[0],i=r[1];return oe((function(){if(t)return window.addEventListener("keydown",e),window.addEventListener("keyup",n),function(){window.removeEventListener("keydown",e),window.removeEventListener("keyup",n)};function e(e){e.key===t&&i(!0)}function n(e){e.key!==t&&"Meta"!==e.key||i(!1)}}),[t]),Be.createElement("kbd",{className:o?"DocSearch-Button-Key DocSearch-Button-Key--pressed":"DocSearch-Button-Key"},n)}function rt(e,t){var n=void 0;return function(){for(var r=arguments.length,o=new Array(r),i=0;i<r;i++)o[i]=arguments[i];n&&clearTimeout(n),n=setTimeout((function(){return e.apply(void 0,o)}),t)}}function ot(e){return e.reduce((function(e,t){return e.concat(t)}),[])}var it=0;function ct(e){return 0===e.collections.length?0:e.collections.reduce((function(e,t){return e+t.items.length}),0)}function at(e){return e!==Object(e)}function ut(e,t){if(e===t)return!0;if(at(e)||at(t)||"function"==typeof e||"function"==typeof t)return e===t;if(Object.keys(e).length!==Object.keys(t).length)return!1;for(var n=0,r=Object.keys(e);n<r.length;n++){var o=r[n];if(!(o in t))return!1;if(!ut(e[o],t[o]))return!1}return!0}var lt=function(){},st=[{segment:"autocomplete-core",version:"1.9.3"}];function ft(e){var t=e.item,n=e.items;return{index:t.__autocomplete_indexName,items:[t],positions:[1+n.findIndex((function(e){return e.objectID===t.objectID}))],queryID:t.__autocomplete_queryID,algoliaSource:["autocomplete"]}}function pt(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n<t;n++)r[n]=e[n];return r}var mt=["items"],dt=["items"];function vt(e){return vt="function"==typeof Symbol&&"symbol"==n(Symbol.iterator)?function(e){return n(e)}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":n(e)},vt(e)}function ht(e){return function(e){if(Array.isArray(e))return yt(e)}(e)||function(e){if("undefined"!=typeof Symbol&&null!=e[Symbol.iterator]||null!=e["@@iterator"])return Array.from(e)}(e)||function(e,t){if(e){if("string"==typeof e)return yt(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);return"Object"===n&&e.constructor&&(n=e.constructor.name),"Map"===n||"Set"===n?Array.from(e):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?yt(e,t):void 0}}(e)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function yt(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n<t;n++)r[n]=e[n];return r}function _t(e,t){if(null==e)return{};var n,r,o=function(e,t){if(null==e)return{};var n,r,o={},i=Object.keys(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}function bt(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function gt(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?bt(Object(n),!0).forEach((function(t){St(e,t,n[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):bt(Object(n)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))}))}return e}function St(e,t,n){return(t=function(e){var t=function(e,t){if("object"!==vt(e)||null===e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var r=n.call(e,t);if("object"!==vt(r))return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return String(e)}(e,"string");return"symbol"===vt(t)?t:String(t)}(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function Ot(e){for(var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:20,n=[],r=0;r<e.objectIDs.length;r+=t)n.push(gt(gt({},e),{},{objectIDs:e.objectIDs.slice(r,r+t)}));return n}function wt(e){return e.map((function(e){var t=e.items,n=_t(e,mt);return gt(gt({},n),{},{objectIDs:(null==t?void 0:t.map((function(e){return e.objectID})))||n.objectIDs})}))}function Et(e){var t,n,r,o=(t=function(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){var n=null==e?null:"undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(null!=n){var r,o,i,c,a=[],u=!0,l=!1;try{if(i=(n=n.call(e)).next,0===t){if(Object(n)!==n)return;u=!1}else for(;!(u=(r=i.call(n)).done)&&(a.push(r.value),a.length!==t);u=!0);}catch(e){l=!0,o=e}finally{try{if(!u&&null!=n.return&&(c=n.return(),Object(c)!==c))return}finally{if(l)throw o}}return a}}(e,t)||function(e,t){if(e){if("string"==typeof e)return pt(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);return"Object"===n&&e.constructor&&(n=e.constructor.name),"Map"===n||"Set"===n?Array.from(e):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?pt(e,t):void 0}}(e,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}((e.version||"").split(".").map(Number),2),n=t[0],r=t[1],n>=3||2===n&&r>=4||1===n&&r>=10);function i(t,n,r){if(o&&void 0!==r){var i=r[0].__autocomplete_algoliaCredentials,c={"X-Algolia-Application-Id":i.appId,"X-Algolia-API-Key":i.apiKey};e.apply(void 0,[t].concat(ht(n),[{headers:c}]))}else e.apply(void 0,[t].concat(ht(n)))}return{init:function(t,n){e("init",{appId:t,apiKey:n})},setUserToken:function(t){e("setUserToken",t)},clickedObjectIDsAfterSearch:function(){for(var e=arguments.length,t=new Array(e),n=0;n<e;n++)t[n]=arguments[n];t.length>0&&i("clickedObjectIDsAfterSearch",wt(t),t[0].items)},clickedObjectIDs:function(){for(var e=arguments.length,t=new Array(e),n=0;n<e;n++)t[n]=arguments[n];t.length>0&&i("clickedObjectIDs",wt(t),t[0].items)},clickedFilters:function(){for(var t=arguments.length,n=new Array(t),r=0;r<t;r++)n[r]=arguments[r];n.length>0&&e.apply(void 0,["clickedFilters"].concat(n))},convertedObjectIDsAfterSearch:function(){for(var e=arguments.length,t=new Array(e),n=0;n<e;n++)t[n]=arguments[n];t.length>0&&i("convertedObjectIDsAfterSearch",wt(t),t[0].items)},convertedObjectIDs:function(){for(var e=arguments.length,t=new Array(e),n=0;n<e;n++)t[n]=arguments[n];t.length>0&&i("convertedObjectIDs",wt(t),t[0].items)},convertedFilters:function(){for(var t=arguments.length,n=new Array(t),r=0;r<t;r++)n[r]=arguments[r];n.length>0&&e.apply(void 0,["convertedFilters"].concat(n))},viewedObjectIDs:function(){for(var e=arguments.length,t=new Array(e),n=0;n<e;n++)t[n]=arguments[n];t.length>0&&t.reduce((function(e,t){var n=t.items,r=_t(t,dt);return[].concat(ht(e),ht(Ot(gt(gt({},r),{},{objectIDs:(null==n?void 0:n.map((function(e){return e.objectID})))||r.objectIDs})).map((function(e){return{items:n,payload:e}}))))}),[]).forEach((function(e){var t=e.items;return i("viewedObjectIDs",[e.payload],t)}))},viewedFilters:function(){for(var t=arguments.length,n=new Array(t),r=0;r<t;r++)n[r]=arguments[r];n.length>0&&e.apply(void 0,["viewedFilters"].concat(n))}}}function jt(e){var t=e.items.reduce((function(e,t){var n;return e[t.__autocomplete_indexName]=(null!==(n=e[t.__autocomplete_indexName])&&void 0!==n?n:[]).concat(t),e}),{});return Object.keys(t).map((function(e){return{index:e,items:t[e],algoliaSource:["autocomplete"]}}))}function Pt(e){return e.objectID&&e.__autocomplete_indexName&&e.__autocomplete_queryID}function It(e){return It="function"==typeof Symbol&&"symbol"==n(Symbol.iterator)?function(e){return n(e)}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":n(e)},It(e)}function Dt(e){return function(e){if(Array.isArray(e))return kt(e)}(e)||function(e){if("undefined"!=typeof Symbol&&null!=e[Symbol.iterator]||null!=e["@@iterator"])return Array.from(e)}(e)||function(e,t){if(e){if("string"==typeof e)return kt(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);return"Object"===n&&e.constructor&&(n=e.constructor.name),"Map"===n||"Set"===n?Array.from(e):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?kt(e,t):void 0}}(e)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function kt(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n<t;n++)r[n]=e[n];return r}function Ct(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function At(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?Ct(Object(n),!0).forEach((function(t){xt(e,t,n[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):Ct(Object(n)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))}))}return e}function xt(e,t,n){return(t=function(e){var t=function(e,t){if("object"!==It(e)||null===e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var r=n.call(e,t);if("object"!==It(r))return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return String(e)}(e,"string");return"symbol"===It(t)?t:String(t)}(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var Nt="https://cdn.jsdelivr.net/npm/search-insights@".concat("2.6.0","/dist/search-insights.min.js"),Tt=rt((function(e){var t=e.onItemsChange,n=e.items,r=e.insights,o=e.state;t({insights:r,insightsEvents:jt({items:n}).map((function(e){return At({eventName:"Items Viewed"},e)})),state:o})}),400);function Rt(e){var t=function(e){return At({onItemsChange:function(e){var t=e.insights,n=e.insightsEvents;t.viewedObjectIDs.apply(t,Dt(n.map((function(e){return At(At({},e),{},{algoliaSource:[].concat(Dt(e.algoliaSource||[]),["autocomplete-internal"])})}))))},onSelect:function(e){var t=e.insights,n=e.insightsEvents;t.clickedObjectIDsAfterSearch.apply(t,Dt(n.map((function(e){return At(At({},e),{},{algoliaSource:[].concat(Dt(e.algoliaSource||[]),["autocomplete-internal"])})}))))},onActive:lt},e)}(e),n=t.insightsClient,r=t.onItemsChange,o=t.onSelect,i=t.onActive,c=n;n||"undefined"!=typeof window&&function(e){var t=e.window,n=t.AlgoliaAnalyticsObject||"aa";"string"==typeof n&&(c=t[n]),c||(t.AlgoliaAnalyticsObject=n,t[n]||(t[n]=function(){t[n].queue||(t[n].queue=[]);for(var e=arguments.length,r=new Array(e),o=0;o<e;o++)r[o]=arguments[o];t[n].queue.push(r)}),t[n].version="2.6.0",c=t[n],function(e){var t="[Autocomplete]: Could not load search-insights.js. Please load it manually following https://alg.li/insights-autocomplete";try{var n=e.document.createElement("script");n.async=!0,n.src=Nt,n.onerror=function(){console.error(t)},document.body.appendChild(n)}catch(e){console.error(t)}}(t))}({window:window});var a=Et(c),u={current:[]},l=rt((function(e){var t=e.state;if(t.isOpen){var n=t.collections.reduce((function(e,t){return[].concat(Dt(e),Dt(t.items))}),[]).filter(Pt);ut(u.current.map((function(e){return e.objectID})),n.map((function(e){return e.objectID})))||(u.current=n,n.length>0&&Tt({onItemsChange:r,items:n,insights:a,state:t}))}}),0);return{name:"aa.algoliaInsightsPlugin",subscribe:function(e){var t=e.setContext,n=e.onSelect,r=e.onActive;c("addAlgoliaAgent","insights-plugin"),t({algoliaInsightsPlugin:{__algoliaSearchParameters:{clickAnalytics:!0},insights:a}}),n((function(e){var t=e.item,n=e.state,r=e.event;Pt(t)&&o({state:n,event:r,insights:a,item:t,insightsEvents:[At({eventName:"Item Selected"},ft({item:t,items:u.current}))]})})),r((function(e){var t=e.item,n=e.state,r=e.event;Pt(t)&&i({state:n,event:r,insights:a,item:t,insightsEvents:[At({eventName:"Item Active"},ft({item:t,items:u.current}))]})}))},onStateChange:function(e){var t=e.state;l({state:t})},__autocomplete_pluginOptions:e}}function qt(e,t){var n=t;return{then:function(t,r){return qt(e.then(Mt(t,n,e),Mt(r,n,e)),n)},catch:function(t){return qt(e.catch(Mt(t,n,e)),n)},finally:function(t){return t&&n.onCancelList.push(t),qt(e.finally(Mt(t&&function(){return n.onCancelList=[],t()},n,e)),n)},cancel:function(){n.isCanceled=!0;var e=n.onCancelList;n.onCancelList=[],e.forEach((function(e){e()}))},isCanceled:function(){return!0===n.isCanceled}}}function Lt(e){return qt(e,{isCanceled:!1,onCancelList:[]})}function Mt(e,t,n){return e?function(n){return t.isCanceled?n:e(n)}:n}function Ht(e,t,n,r){if(!n)return null;if(e<0&&(null===t||null!==r&&0===t))return n+e;var o=(null===t?-1:t)+e;return o<=-1||o>=n?null===r?null:0:o}function Ut(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function Ft(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?Ut(Object(n),!0).forEach((function(t){Bt(e,t,n[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):Ut(Object(n)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))}))}return e}function Bt(e,t,n){return(t=function(e){var t=function(e,t){if("object"!==Vt(e)||null===e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var r=n.call(e,t);if("object"!==Vt(r))return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return String(e)}(e,"string");return"symbol"===Vt(t)?t:String(t)}(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function Vt(e){return Vt="function"==typeof Symbol&&"symbol"==n(Symbol.iterator)?function(e){return n(e)}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":n(e)},Vt(e)}function Kt(e){var t=function(e){var t=e.collections.map((function(e){return e.items.length})).reduce((function(e,t,n){var r=(e[n-1]||0)+t;return e.push(r),e}),[]).reduce((function(t,n){return n<=e.activeItemId?t+1:t}),0);return e.collections[t]}(e);if(!t)return null;var n=t.items[function(e){for(var t=e.state,n=e.collection,r=!1,o=0,i=0;!1===r;){var c=t.collections[o];if(c===n){r=!0;break}i+=c.items.length,o++}return t.activeItemId-i}({state:e,collection:t})],r=t.source;return{item:n,itemInputValue:r.getItemInputValue({item:n,state:e}),itemUrl:r.getItemUrl({item:n,state:e}),source:r}}var Wt=/((gt|sm)-|galaxy nexus)|samsung[- ]|samsungbrowser/i;function zt(e){return zt="function"==typeof Symbol&&"symbol"==n(Symbol.iterator)?function(e){return n(e)}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":n(e)},zt(e)}function Jt(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function $t(e,t,n){return(t=function(e){var t=function(e,t){if("object"!==zt(e)||null===e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var r=n.call(e,t);if("object"!==zt(r))return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return String(e)}(e,"string");return"symbol"===zt(t)?t:String(t)}(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function Zt(e){return Zt="function"==typeof Symbol&&"symbol"==n(Symbol.iterator)?function(e){return n(e)}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":n(e)},Zt(e)}function Qt(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function Yt(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?Qt(Object(n),!0).forEach((function(t){Gt(e,t,n[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):Qt(Object(n)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))}))}return e}function Gt(e,t,n){return(t=function(e){var t=function(e,t){if("object"!==Zt(e)||null===e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var r=n.call(e,t);if("object"!==Zt(r))return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return String(e)}(e,"string");return"symbol"===Zt(t)?t:String(t)}(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function Xt(e){return Xt="function"==typeof Symbol&&"symbol"==n(Symbol.iterator)?function(e){return n(e)}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":n(e)},Xt(e)}function en(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n<t;n++)r[n]=e[n];return r}function tn(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function nn(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?tn(Object(n),!0).forEach((function(t){rn(e,t,n[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):tn(Object(n)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))}))}return e}function rn(e,t,n){return(t=function(e){var t=function(e,t){if("object"!==Xt(e)||null===e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var r=n.call(e,t);if("object"!==Xt(r))return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return String(e)}(e,"string");return"symbol"===Xt(t)?t:String(t)}(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function on(e,t){var n,r="undefined"!=typeof window?window:{},o=e.plugins||[];return nn(nn({debug:!1,openOnFocus:!1,placeholder:"",autoFocus:!1,defaultActiveItemId:null,stallThreshold:300,insights:!1,environment:r,shouldPanelOpen:function(e){return ct(e.state)>0},reshape:function(e){return e.sources}},e),{},{id:null!==(n=e.id)&&void 0!==n?n:"autocomplete-".concat(it++),plugins:o,initialState:nn({activeItemId:null,query:"",completion:null,collections:[],isOpen:!1,status:"idle",context:{}},e.initialState),onStateChange:function(t){var n;null===(n=e.onStateChange)||void 0===n||n.call(e,t),o.forEach((function(e){var n;return null===(n=e.onStateChange)||void 0===n?void 0:n.call(e,t)}))},onSubmit:function(t){var n;null===(n=e.onSubmit)||void 0===n||n.call(e,t),o.forEach((function(e){var n;return null===(n=e.onSubmit)||void 0===n?void 0:n.call(e,t)}))},onReset:function(t){var n;null===(n=e.onReset)||void 0===n||n.call(e,t),o.forEach((function(e){var n;return null===(n=e.onReset)||void 0===n?void 0:n.call(e,t)}))},getSources:function(n){return Promise.all([].concat(function(e){return function(e){if(Array.isArray(e))return en(e)}(e)||function(e){if("undefined"!=typeof Symbol&&null!=e[Symbol.iterator]||null!=e["@@iterator"])return Array.from(e)}(e)||function(e,t){if(e){if("string"==typeof e)return en(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);return"Object"===n&&e.constructor&&(n=e.constructor.name),"Map"===n||"Set"===n?Array.from(e):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?en(e,t):void 0}}(e)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}(o.map((function(e){return e.getSources}))),[e.getSources]).filter(Boolean).map((function(e){return function(e,t){var n=[];return Promise.resolve(e(t)).then((function(e){return Promise.all(e.filter((function(e){return Boolean(e)})).map((function(e){if(e.sourceId,n.includes(e.sourceId))throw new Error("[Autocomplete] The `sourceId` ".concat(JSON.stringify(e.sourceId)," is not unique."));n.push(e.sourceId);var t={getItemInputValue:function(e){return e.state.query},getItemUrl:function(){},onSelect:function(e){(0,e.setIsOpen)(!1)},onActive:lt,onResolve:lt};Object.keys(t).forEach((function(e){t[e].__default=!0}));var r=Ft(Ft({},t),e);return Promise.resolve(r)})))}))}(e,n)}))).then((function(e){return ot(e)})).then((function(e){return e.map((function(e){return nn(nn({},e),{},{onSelect:function(n){e.onSelect(n),t.forEach((function(e){var t;return null===(t=e.onSelect)||void 0===t?void 0:t.call(e,n)}))},onActive:function(n){e.onActive(n),t.forEach((function(e){var t;return null===(t=e.onActive)||void 0===t?void 0:t.call(e,n)}))},onResolve:function(n){e.onResolve(n),t.forEach((function(e){var t;return null===(t=e.onResolve)||void 0===t?void 0:t.call(e,n)}))}})}))}))},navigator:nn({navigate:function(e){var t=e.itemUrl;r.location.assign(t)},navigateNewTab:function(e){var t=e.itemUrl,n=r.open(t,"_blank","noopener");null==n||n.focus()},navigateNewWindow:function(e){var t=e.itemUrl;r.open(t,"_blank","noopener")}},e.navigator)})}function cn(e){return cn="function"==typeof Symbol&&"symbol"==n(Symbol.iterator)?function(e){return n(e)}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":n(e)},cn(e)}function an(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function un(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?an(Object(n),!0).forEach((function(t){ln(e,t,n[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):an(Object(n)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))}))}return e}function ln(e,t,n){return(t=function(e){var t=function(e,t){if("object"!==cn(e)||null===e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var r=n.call(e,t);if("object"!==cn(r))return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return String(e)}(e,"string");return"symbol"===cn(t)?t:String(t)}(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function sn(e){return sn="function"==typeof Symbol&&"symbol"==n(Symbol.iterator)?function(e){return n(e)}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":n(e)},sn(e)}function fn(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function pn(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?fn(Object(n),!0).forEach((function(t){mn(e,t,n[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):fn(Object(n)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))}))}return e}function mn(e,t,n){return(t=function(e){var t=function(e,t){if("object"!==sn(e)||null===e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var r=n.call(e,t);if("object"!==sn(r))return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return String(e)}(e,"string");return"symbol"===sn(t)?t:String(t)}(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function dn(e){return function(e){if(Array.isArray(e))return vn(e)}(e)||function(e){if("undefined"!=typeof Symbol&&null!=e[Symbol.iterator]||null!=e["@@iterator"])return Array.from(e)}(e)||function(e,t){if(e){if("string"==typeof e)return vn(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);return"Object"===n&&e.constructor&&(n=e.constructor.name),"Map"===n||"Set"===n?Array.from(e):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?vn(e,t):void 0}}(e)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function vn(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n<t;n++)r[n]=e[n];return r}function hn(e){return Boolean(e.execute)}function yn(e){var t=e.reduce((function(e,t){if(!hn(t))return e.push(t),e;var n=t.searchClient,r=t.execute,o=t.requesterId,i=t.requests,c=e.find((function(e){return hn(t)&&hn(e)&&e.searchClient===n&&Boolean(o)&&e.requesterId===o}));if(c){var a;(a=c.items).push.apply(a,dn(i))}else{var u={execute:r,requesterId:o,items:i,searchClient:n};e.push(u)}return e}),[]).map((function(e){if(!hn(e))return Promise.resolve(e);var t=e,n=t.execute,r=t.items;return n({searchClient:t.searchClient,requests:r})}));return Promise.all(t).then((function(e){return ot(e)}))}function _n(e){return _n="function"==typeof Symbol&&"symbol"==n(Symbol.iterator)?function(e){return n(e)}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":n(e)},_n(e)}var bn=["event","nextState","props","query","refresh","store"];function gn(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function Sn(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?gn(Object(n),!0).forEach((function(t){On(e,t,n[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):gn(Object(n)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))}))}return e}function On(e,t,n){return(t=function(e){var t=function(e,t){if("object"!==_n(e)||null===e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var r=n.call(e,t);if("object"!==_n(r))return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return String(e)}(e,"string");return"symbol"===_n(t)?t:String(t)}(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var wn,En,jn,Pn=null,In=(wn=-1,En=-1,jn=void 0,function(e){var t=++wn;return Promise.resolve(e).then((function(e){return jn&&t<En?jn:(En=t,jn=e,e)}))});function Dn(e){var t=e.event,n=e.nextState,r=void 0===n?{}:n,o=e.props,i=e.query,c=e.refresh,a=e.store,u=function(e,t){if(null==e)return{};var n,r,o=function(e,t){if(null==e)return{};var n,r,o={},i=Object.keys(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}(e,bn);Pn&&o.environment.clearTimeout(Pn);var l=u.setCollections,s=u.setIsOpen,f=u.setQuery,p=u.setActiveItemId,m=u.setStatus;if(f(i),p(o.defaultActiveItemId),!i&&!1===o.openOnFocus){var d,v=a.getState().collections.map((function(e){return Sn(Sn({},e),{},{items:[]})}));m("idle"),l(v),s(null!==(d=r.isOpen)&&void 0!==d?d:o.shouldPanelOpen({state:a.getState()}));var h=Lt(In(v).then((function(){return Promise.resolve()})));return a.pendingRequests.add(h)}m("loading"),Pn=o.environment.setTimeout((function(){m("stalled")}),o.stallThreshold);var y=Lt(In(o.getSources(Sn({query:i,refresh:c,state:a.getState()},u)).then((function(e){return Promise.all(e.map((function(e){return Promise.resolve(e.getItems(Sn({query:i,refresh:c,state:a.getState()},u))).then((function(t){return function(e,t,n){if(o=e,Boolean(null==o?void 0:o.execute)){var r="algolia"===e.requesterId?Object.assign.apply(Object,[{}].concat(dn(Object.keys(n.context).map((function(e){var t;return null===(t=n.context[e])||void 0===t?void 0:t.__algoliaSearchParameters}))))):{};return pn(pn({},e),{},{requests:e.queries.map((function(n){return{query:"algolia"===e.requesterId?pn(pn({},n),{},{params:pn(pn({},r),n.params)}):n,sourceId:t,transformResponse:e.transformResponse}}))})}var o;return{items:e,sourceId:t}}(t,e.sourceId,a.getState())}))}))).then(yn).then((function(t){return function(e,t,n){return t.map((function(t){var r,o=e.filter((function(e){return e.sourceId===t.sourceId})),i=o.map((function(e){return e.items})),c=o[0].transformResponse,a=c?c({results:r=i,hits:r.map((function(e){return e.hits})).filter(Boolean),facetHits:r.map((function(e){var t;return null===(t=e.facetHits)||void 0===t?void 0:t.map((function(e){return{label:e.value,count:e.count,_highlightResult:{label:{value:e.highlighted}}}}))})).filter(Boolean)}):i;return t.onResolve({source:t,results:i,items:a,state:n.getState()}),a.every(Boolean),'The `getItems` function from source "'.concat(t.sourceId,'" must return an array of items but returned ').concat(JSON.stringify(void 0),".\n\nDid you forget to return items?\n\nSee: https://www.algolia.com/doc/ui-libraries/autocomplete/core-concepts/sources/#param-getitems"),{source:t,items:a}}))}(t,e,a)})).then((function(e){return function(e){var t=e.props,n=e.state,r=e.collections.reduce((function(e,t){return un(un({},e),{},ln({},t.source.sourceId,un(un({},t.source),{},{getItems:function(){return ot(t.items)}})))}),{}),o=t.plugins.reduce((function(e,t){return t.reshape?t.reshape(e):e}),{sourcesBySourceId:r,state:n}).sourcesBySourceId;return ot(t.reshape({sourcesBySourceId:o,sources:Object.values(o),state:n})).filter(Boolean).map((function(e){return{source:e,items:e.getItems()}}))}({collections:e,props:o,state:a.getState()})}))})))).then((function(e){var n;m("idle"),l(e);var f=o.shouldPanelOpen({state:a.getState()});s(null!==(n=r.isOpen)&&void 0!==n?n:o.openOnFocus&&!i&&f||f);var p=Kt(a.getState());if(null!==a.getState().activeItemId&&p){var d=p.item,v=p.itemInputValue,h=p.itemUrl,y=p.source;y.onActive(Sn({event:t,item:d,itemInputValue:v,itemUrl:h,refresh:c,source:y,state:a.getState()},u))}})).finally((function(){m("idle"),Pn&&o.environment.clearTimeout(Pn)}));return a.pendingRequests.add(y)}function kn(e){return kn="function"==typeof Symbol&&"symbol"==n(Symbol.iterator)?function(e){return n(e)}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":n(e)},kn(e)}var Cn=["event","props","refresh","store"];function An(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function xn(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?An(Object(n),!0).forEach((function(t){Nn(e,t,n[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):An(Object(n)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))}))}return e}function Nn(e,t,n){return(t=function(e){var t=function(e,t){if("object"!==kn(e)||null===e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var r=n.call(e,t);if("object"!==kn(r))return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return String(e)}(e,"string");return"symbol"===kn(t)?t:String(t)}(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function Tn(e){return Tn="function"==typeof Symbol&&"symbol"==n(Symbol.iterator)?function(e){return n(e)}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":n(e)},Tn(e)}var Rn=["props","refresh","store"],qn=["inputElement","formElement","panelElement"],Ln=["inputElement"],Mn=["inputElement","maxLength"],Hn=["sourceIndex"],Un=["sourceIndex"],Fn=["item","source","sourceIndex"];function Bn(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function Vn(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?Bn(Object(n),!0).forEach((function(t){Kn(e,t,n[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):Bn(Object(n)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))}))}return e}function Kn(e,t,n){return(t=function(e){var t=function(e,t){if("object"!==Tn(e)||null===e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var r=n.call(e,t);if("object"!==Tn(r))return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return String(e)}(e,"string");return"symbol"===Tn(t)?t:String(t)}(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function Wn(e,t){if(null==e)return{};var n,r,o=function(e,t){if(null==e)return{};var n,r,o={},i=Object.keys(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}function zn(e){var t=e.props,n=e.refresh,r=e.store,o=Wn(e,Rn),i=function(e,t){return void 0!==t?"".concat(e,"-").concat(t):e};return{getEnvironmentProps:function(e){var n=e.inputElement,o=e.formElement,i=e.panelElement;function c(e){!r.getState().isOpen&&r.pendingRequests.isEmpty()||e.target===n||!1===[o,i].some((function(t){return(n=t)===(r=e.target)||n.contains(r);var n,r}))&&(r.dispatch("blur",null),t.debug||r.pendingRequests.cancelAll())}return Vn({onTouchStart:c,onMouseDown:c,onTouchMove:function(e){!1!==r.getState().isOpen&&n===t.environment.document.activeElement&&e.target!==n&&n.blur()}},Wn(e,qn))},getRootProps:function(e){return Vn({role:"combobox","aria-expanded":r.getState().isOpen,"aria-haspopup":"listbox","aria-owns":r.getState().isOpen?"".concat(t.id,"-list"):void 0,"aria-labelledby":"".concat(t.id,"-label")},e)},getFormProps:function(e){return e.inputElement,Vn({action:"",noValidate:!0,role:"search",onSubmit:function(i){var c;i.preventDefault(),t.onSubmit(Vn({event:i,refresh:n,state:r.getState()},o)),r.dispatch("submit",null),null===(c=e.inputElement)||void 0===c||c.blur()},onReset:function(i){var c;i.preventDefault(),t.onReset(Vn({event:i,refresh:n,state:r.getState()},o)),r.dispatch("reset",null),null===(c=e.inputElement)||void 0===c||c.focus()}},Wn(e,Ln))},getLabelProps:function(e){var n=e||{},r=n.sourceIndex,o=Wn(n,Hn);return Vn({htmlFor:"".concat(i(t.id,r),"-input"),id:"".concat(i(t.id,r),"-label")},o)},getInputProps:function(e){var i;function c(e){(t.openOnFocus||Boolean(r.getState().query))&&Dn(Vn({event:e,props:t,query:r.getState().completion||r.getState().query,refresh:n,store:r},o)),r.dispatch("focus",null)}var a=e||{},u=(a.inputElement,a.maxLength),l=void 0===u?512:u,s=Wn(a,Mn),f=Kt(r.getState()),p=function(e){return Boolean(e&&e.match(Wt))}((null===(i=t.environment.navigator)||void 0===i?void 0:i.userAgent)||""),m=null!=f&&f.itemUrl&&!p?"go":"search";return Vn({"aria-autocomplete":"both","aria-activedescendant":r.getState().isOpen&&null!==r.getState().activeItemId?"".concat(t.id,"-item-").concat(r.getState().activeItemId):void 0,"aria-controls":r.getState().isOpen?"".concat(t.id,"-list"):void 0,"aria-labelledby":"".concat(t.id,"-label"),value:r.getState().completion||r.getState().query,id:"".concat(t.id,"-input"),autoComplete:"off",autoCorrect:"off",autoCapitalize:"off",enterKeyHint:m,spellCheck:"false",autoFocus:t.autoFocus,placeholder:t.placeholder,maxLength:l,type:"search",onChange:function(e){Dn(Vn({event:e,props:t,query:e.currentTarget.value.slice(0,l),refresh:n,store:r},o))},onKeyDown:function(e){!function(e){var t=e.event,n=e.props,r=e.refresh,o=e.store,i=function(e,t){if(null==e)return{};var n,r,o=function(e,t){if(null==e)return{};var n,r,o={},i=Object.keys(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||(o[n]=e[n]);return o}(e,t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);for(r=0;r<i.length;r++)n=i[r],t.indexOf(n)>=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(o[n]=e[n])}return o}(e,Cn);if("ArrowUp"===t.key||"ArrowDown"===t.key){var c=function(){var e=n.environment.document.getElementById("".concat(n.id,"-item-").concat(o.getState().activeItemId));e&&(e.scrollIntoViewIfNeeded?e.scrollIntoViewIfNeeded(!1):e.scrollIntoView(!1))},a=function(){var e=Kt(o.getState());if(null!==o.getState().activeItemId&&e){var n=e.item,c=e.itemInputValue,a=e.itemUrl,u=e.source;u.onActive(xn({event:t,item:n,itemInputValue:c,itemUrl:a,refresh:r,source:u,state:o.getState()},i))}};t.preventDefault(),!1===o.getState().isOpen&&(n.openOnFocus||Boolean(o.getState().query))?Dn(xn({event:t,props:n,query:o.getState().query,refresh:r,store:o},i)).then((function(){o.dispatch(t.key,{nextActiveItemId:n.defaultActiveItemId}),a(),setTimeout(c,0)})):(o.dispatch(t.key,{}),a(),c())}else if("Escape"===t.key)t.preventDefault(),o.dispatch(t.key,null),o.pendingRequests.cancelAll();else if("Tab"===t.key)o.dispatch("blur",null),o.pendingRequests.cancelAll();else if("Enter"===t.key){if(null===o.getState().activeItemId||o.getState().collections.every((function(e){return 0===e.items.length})))return void(n.debug||o.pendingRequests.cancelAll());t.preventDefault();var u=Kt(o.getState()),l=u.item,s=u.itemInputValue,f=u.itemUrl,p=u.source;if(t.metaKey||t.ctrlKey)void 0!==f&&(p.onSelect(xn({event:t,item:l,itemInputValue:s,itemUrl:f,refresh:r,source:p,state:o.getState()},i)),n.navigator.navigateNewTab({itemUrl:f,item:l,state:o.getState()}));else if(t.shiftKey)void 0!==f&&(p.onSelect(xn({event:t,item:l,itemInputValue:s,itemUrl:f,refresh:r,source:p,state:o.getState()},i)),n.navigator.navigateNewWindow({itemUrl:f,item:l,state:o.getState()}));else if(t.altKey);else{if(void 0!==f)return p.onSelect(xn({event:t,item:l,itemInputValue:s,itemUrl:f,refresh:r,source:p,state:o.getState()},i)),void n.navigator.navigate({itemUrl:f,item:l,state:o.getState()});Dn(xn({event:t,nextState:{isOpen:!1},props:n,query:s,refresh:r,store:o},i)).then((function(){p.onSelect(xn({event:t,item:l,itemInputValue:s,itemUrl:f,refresh:r,source:p,state:o.getState()},i))}))}}}(Vn({event:e,props:t,refresh:n,store:r},o))},onFocus:c,onBlur:lt,onClick:function(n){e.inputElement!==t.environment.document.activeElement||r.getState().isOpen||c(n)}},s)},getPanelProps:function(e){return Vn({onMouseDown:function(e){e.preventDefault()},onMouseLeave:function(){r.dispatch("mouseleave",null)}},e)},getListProps:function(e){var n=e||{},r=n.sourceIndex,o=Wn(n,Un);return Vn({role:"listbox","aria-labelledby":"".concat(i(t.id,r),"-label"),id:"".concat(i(t.id,r),"-list")},o)},getItemProps:function(e){var c=e.item,a=e.source,u=e.sourceIndex,l=Wn(e,Fn);return Vn({id:"".concat(i(t.id,u),"-item-").concat(c.__autocomplete_id),role:"option","aria-selected":r.getState().activeItemId===c.__autocomplete_id,onMouseMove:function(e){if(c.__autocomplete_id!==r.getState().activeItemId){r.dispatch("mousemove",c.__autocomplete_id);var t=Kt(r.getState());if(null!==r.getState().activeItemId&&t){var i=t.item,a=t.itemInputValue,u=t.itemUrl,l=t.source;l.onActive(Vn({event:e,item:i,itemInputValue:a,itemUrl:u,refresh:n,source:l,state:r.getState()},o))}}},onMouseDown:function(e){e.preventDefault()},onClick:function(e){var i=a.getItemInputValue({item:c,state:r.getState()}),u=a.getItemUrl({item:c,state:r.getState()});(u?Promise.resolve():Dn(Vn({event:e,nextState:{isOpen:!1},props:t,query:i,refresh:n,store:r},o))).then((function(){a.onSelect(Vn({event:e,item:c,itemInputValue:i,itemUrl:u,refresh:n,source:a,state:r.getState()},o))}))}},l)}}}function Jn(e){return Jn="function"==typeof Symbol&&"symbol"==n(Symbol.iterator)?function(e){return n(e)}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":n(e)},Jn(e)}function $n(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function Zn(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?$n(Object(n),!0).forEach((function(t){Qn(e,t,n[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):$n(Object(n)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))}))}return e}function Qn(e,t,n){return(t=function(e){var t=function(e,t){if("object"!==Jn(e)||null===e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var r=n.call(e,t);if("object"!==Jn(r))return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return String(e)}(e,"string");return"symbol"===Jn(t)?t:String(t)}(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function Yn(e){var t,n,r,o,i=e.plugins,c=e.options,a=null===(t=((null===(n=c.__autocomplete_metadata)||void 0===n?void 0:n.userAgents)||[])[0])||void 0===t?void 0:t.segment,u=a?Qn({},a,Object.keys((null===(r=c.__autocomplete_metadata)||void 0===r?void 0:r.options)||{})):{};return{plugins:i.map((function(e){return{name:e.name,options:Object.keys(e.__autocomplete_pluginOptions||[])}})),options:Zn({"autocomplete-core":Object.keys(c)},u),ua:st.concat((null===(o=c.__autocomplete_metadata)||void 0===o?void 0:o.userAgents)||[])}}function Gn(e){var t,n=e.state;return!1===n.isOpen||null===n.activeItemId?null:(null===(t=Kt(n))||void 0===t?void 0:t.itemInputValue)||null}function Xn(e){return Xn="function"==typeof Symbol&&"symbol"==n(Symbol.iterator)?function(e){return n(e)}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":n(e)},Xn(e)}function er(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function tr(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?er(Object(n),!0).forEach((function(t){nr(e,t,n[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):er(Object(n)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))}))}return e}function nr(e,t,n){return(t=function(e){var t=function(e,t){if("object"!==Xn(e)||null===e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var r=n.call(e,t);if("object"!==Xn(r))return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return String(e)}(e,"string");return"symbol"===Xn(t)?t:String(t)}(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var rr=function(e,t){switch(t.type){case"setActiveItemId":case"mousemove":return tr(tr({},e),{},{activeItemId:t.payload});case"setQuery":return tr(tr({},e),{},{query:t.payload,completion:null});case"setCollections":return tr(tr({},e),{},{collections:t.payload});case"setIsOpen":return tr(tr({},e),{},{isOpen:t.payload});case"setStatus":return tr(tr({},e),{},{status:t.payload});case"setContext":return tr(tr({},e),{},{context:tr(tr({},e.context),t.payload)});case"ArrowDown":var n=tr(tr({},e),{},{activeItemId:t.payload.hasOwnProperty("nextActiveItemId")?t.payload.nextActiveItemId:Ht(1,e.activeItemId,ct(e),t.props.defaultActiveItemId)});return tr(tr({},n),{},{completion:Gn({state:n})});case"ArrowUp":var r=tr(tr({},e),{},{activeItemId:Ht(-1,e.activeItemId,ct(e),t.props.defaultActiveItemId)});return tr(tr({},r),{},{completion:Gn({state:r})});case"Escape":return e.isOpen?tr(tr({},e),{},{activeItemId:null,isOpen:!1,completion:null}):tr(tr({},e),{},{activeItemId:null,query:"",status:"idle",collections:[]});case"submit":return tr(tr({},e),{},{activeItemId:null,isOpen:!1,status:"idle"});case"reset":return tr(tr({},e),{},{activeItemId:!0===t.props.openOnFocus?t.props.defaultActiveItemId:null,status:"idle",query:""});case"focus":return tr(tr({},e),{},{activeItemId:t.props.defaultActiveItemId,isOpen:(t.props.openOnFocus||Boolean(e.query))&&t.props.shouldPanelOpen({state:e})});case"blur":return t.props.debug?e:tr(tr({},e),{},{isOpen:!1,activeItemId:null});case"mouseleave":return tr(tr({},e),{},{activeItemId:t.props.defaultActiveItemId});default:return"The reducer action ".concat(JSON.stringify(t.type)," is not supported."),e}};function or(e){return or="function"==typeof Symbol&&"symbol"==n(Symbol.iterator)?function(e){return n(e)}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":n(e)},or(e)}function ir(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function cr(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?ir(Object(n),!0).forEach((function(t){ar(e,t,n[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):ir(Object(n)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))}))}return e}function ar(e,t,n){return(t=function(e){var t=function(e,t){if("object"!==or(e)||null===e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var r=n.call(e,t);if("object"!==or(r))return r;throw new TypeError("@@toPrimitive must return a primitive value.")}return String(e)}(e,"string");return"symbol"===or(t)?t:String(t)}(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function ur(e){var t=[],n=on(e,t),r=function(e,t,n){var r,o=t.initialState;return{getState:function(){return o},dispatch:function(r,i){var c=function(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?Jt(Object(n),!0).forEach((function(t){$t(e,t,n[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):Jt(Object(n)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))}))}return e}({},o);o=e(o,{type:r,props:t,payload:i}),n({state:o,prevState:c})},pendingRequests:(r=[],{add:function(e){return r.push(e),e.finally((function(){r=r.filter((function(t){return t!==e}))}))},cancelAll:function(){r.forEach((function(e){return e.cancel()}))},isEmpty:function(){return 0===r.length}})}}(rr,n,(function(e){var t=e.prevState,r=e.state;n.onStateChange(cr({prevState:t,state:r,refresh:c,navigator:n.navigator},o))})),o=function(e){var t=e.store;return{setActiveItemId:function(e){t.dispatch("setActiveItemId",e)},setQuery:function(e){t.dispatch("setQuery",e)},setCollections:function(e){var n=0,r=e.map((function(e){return Yt(Yt({},e),{},{items:ot(e.items).map((function(e){return Yt(Yt({},e),{},{__autocomplete_id:n++})}))})}));t.dispatch("setCollections",r)},setIsOpen:function(e){t.dispatch("setIsOpen",e)},setStatus:function(e){t.dispatch("setStatus",e)},setContext:function(e){t.dispatch("setContext",e)}}}({store:r}),i=zn(cr({props:n,refresh:c,store:r,navigator:n.navigator},o));function c(){return Dn(cr({event:new Event("input"),nextState:{isOpen:r.getState().isOpen},props:n,navigator:n.navigator,query:r.getState().query,refresh:c,store:r},o))}if(e.insights&&!n.plugins.some((function(e){return"aa.algoliaInsightsPlugin"===e.name}))){var a="boolean"==typeof e.insights?{}:e.insights;n.plugins.push(Rt(a))}return n.plugins.forEach((function(e){var r;return null===(r=e.subscribe)||void 0===r?void 0:r.call(e,cr(cr({},o),{},{navigator:n.navigator,refresh:c,onSelect:function(e){t.push({onSelect:e})},onActive:function(e){t.push({onActive:e})},onResolve:function(e){t.push({onResolve:e})}}))})),function(e){var t,n,r=e.metadata,o=e.environment;if(null===(t=o.navigator)||void 0===t||null===(n=t.userAgent)||void 0===n?void 0:n.includes("Algolia Crawler")){var i=o.document.createElement("meta"),c=o.document.querySelector("head");i.name="algolia:metadata",setTimeout((function(){i.content=JSON.stringify(r),c.appendChild(i)}),0)}}({metadata:Yn({plugins:n.plugins,options:e}),environment:n.environment}),cr(cr({refresh:c,navigator:n.navigator},i),o)}function lr(e){var t=e.translations,n=(void 0===t?{}:t).searchByText,r=void 0===n?"Search by":n;return Be.createElement("a",{href:"https://www.algolia.com/ref/docsearch/?utm_source=".concat(window.location.hostname,"&utm_medium=referral&utm_content=powered_by&utm_campaign=docsearch"),target:"_blank",rel:"noopener noreferrer"},Be.createElement("span",{className:"DocSearch-Label"},r),Be.createElement("svg",{width:"77",height:"19","aria-label":"Algolia",role:"img",id:"Layer_1",xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 2196.2 500"},Be.createElement("defs",null,Be.createElement("style",null,".cls-1,.cls-2{fill:#003dff;}.cls-2{fill-rule:evenodd;}")),Be.createElement("path",{className:"cls-2",d:"M1070.38,275.3V5.91c0-3.63-3.24-6.39-6.82-5.83l-50.46,7.94c-2.87,.45-4.99,2.93-4.99,5.84l.17,273.22c0,12.92,0,92.7,95.97,95.49,3.33,.1,6.09-2.58,6.09-5.91v-40.78c0-2.96-2.19-5.51-5.12-5.84-34.85-4.01-34.85-47.57-34.85-54.72Z"}),Be.createElement("rect",{className:"cls-1",x:"1845.88",y:"104.73",width:"62.58",height:"277.9",rx:"5.9",ry:"5.9"}),Be.createElement("path",{className:"cls-2",d:"M1851.78,71.38h50.77c3.26,0,5.9-2.64,5.9-5.9V5.9c0-3.62-3.24-6.39-6.82-5.83l-50.77,7.95c-2.87,.45-4.99,2.92-4.99,5.83v51.62c0,3.26,2.64,5.9,5.9,5.9Z"}),Be.createElement("path",{className:"cls-2",d:"M1764.03,275.3V5.91c0-3.63-3.24-6.39-6.82-5.83l-50.46,7.94c-2.87,.45-4.99,2.93-4.99,5.84l.17,273.22c0,12.92,0,92.7,95.97,95.49,3.33,.1,6.09-2.58,6.09-5.91v-40.78c0-2.96-2.19-5.51-5.12-5.84-34.85-4.01-34.85-47.57-34.85-54.72Z"}),Be.createElement("path",{className:"cls-2",d:"M1631.95,142.72c-11.14-12.25-24.83-21.65-40.78-28.31-15.92-6.53-33.26-9.85-52.07-9.85-18.78,0-36.15,3.17-51.92,9.85-15.59,6.66-29.29,16.05-40.76,28.31-11.47,12.23-20.38,26.87-26.76,44.03-6.38,17.17-9.24,37.37-9.24,58.36,0,20.99,3.19,36.87,9.55,54.21,6.38,17.32,15.14,32.11,26.45,44.36,11.29,12.23,24.83,21.62,40.6,28.46,15.77,6.83,40.12,10.33,52.4,10.48,12.25,0,36.78-3.82,52.7-10.48,15.92-6.68,29.46-16.23,40.78-28.46,11.29-12.25,20.05-27.04,26.25-44.36,6.22-17.34,9.24-33.22,9.24-54.21,0-20.99-3.34-41.19-10.03-58.36-6.38-17.17-15.14-31.8-26.43-44.03Zm-44.43,163.75c-11.47,15.75-27.56,23.7-48.09,23.7-20.55,0-36.63-7.8-48.1-23.7-11.47-15.75-17.21-34.01-17.21-61.2,0-26.89,5.59-49.14,17.06-64.87,11.45-15.75,27.54-23.52,48.07-23.52,20.55,0,36.63,7.78,48.09,23.52,11.47,15.57,17.36,37.98,17.36,64.87,0,27.19-5.72,45.3-17.19,61.2Z"}),Be.createElement("path",{className:"cls-2",d:"M894.42,104.73h-49.33c-48.36,0-90.91,25.48-115.75,64.1-14.52,22.58-22.99,49.63-22.99,78.73,0,44.89,20.13,84.92,51.59,111.1,2.93,2.6,6.05,4.98,9.31,7.14,12.86,8.49,28.11,13.47,44.52,13.47,1.23,0,2.46-.03,3.68-.09,.36-.02,.71-.05,1.07-.07,.87-.05,1.75-.11,2.62-.2,.34-.03,.68-.08,1.02-.12,.91-.1,1.82-.21,2.73-.34,.21-.03,.42-.07,.63-.1,32.89-5.07,61.56-30.82,70.9-62.81v57.83c0,3.26,2.64,5.9,5.9,5.9h50.42c3.26,0,5.9-2.64,5.9-5.9V110.63c0-3.26-2.64-5.9-5.9-5.9h-56.32Zm0,206.92c-12.2,10.16-27.97,13.98-44.84,15.12-.16,.01-.33,.03-.49,.04-1.12,.07-2.24,.1-3.36,.1-42.24,0-77.12-35.89-77.12-79.37,0-10.25,1.96-20.01,5.42-28.98,11.22-29.12,38.77-49.74,71.06-49.74h49.33v142.83Z"}),Be.createElement("path",{className:"cls-2",d:"M2133.97,104.73h-49.33c-48.36,0-90.91,25.48-115.75,64.1-14.52,22.58-22.99,49.63-22.99,78.73,0,44.89,20.13,84.92,51.59,111.1,2.93,2.6,6.05,4.98,9.31,7.14,12.86,8.49,28.11,13.47,44.52,13.47,1.23,0,2.46-.03,3.68-.09,.36-.02,.71-.05,1.07-.07,.87-.05,1.75-.11,2.62-.2,.34-.03,.68-.08,1.02-.12,.91-.1,1.82-.21,2.73-.34,.21-.03,.42-.07,.63-.1,32.89-5.07,61.56-30.82,70.9-62.81v57.83c0,3.26,2.64,5.9,5.9,5.9h50.42c3.26,0,5.9-2.64,5.9-5.9V110.63c0-3.26-2.64-5.9-5.9-5.9h-56.32Zm0,206.92c-12.2,10.16-27.97,13.98-44.84,15.12-.16,.01-.33,.03-.49,.04-1.12,.07-2.24,.1-3.36,.1-42.24,0-77.12-35.89-77.12-79.37,0-10.25,1.96-20.01,5.42-28.98,11.22-29.12,38.77-49.74,71.06-49.74h49.33v142.83Z"}),Be.createElement("path",{className:"cls-2",d:"M1314.05,104.73h-49.33c-48.36,0-90.91,25.48-115.75,64.1-11.79,18.34-19.6,39.64-22.11,62.59-.58,5.3-.88,10.68-.88,16.14s.31,11.15,.93,16.59c4.28,38.09,23.14,71.61,50.66,94.52,2.93,2.6,6.05,4.98,9.31,7.14,12.86,8.49,28.11,13.47,44.52,13.47h0c17.99,0,34.61-5.93,48.16-15.97,16.29-11.58,28.88-28.54,34.48-47.75v50.26h-.11v11.08c0,21.84-5.71,38.27-17.34,49.36-11.61,11.08-31.04,16.63-58.25,16.63-11.12,0-28.79-.59-46.6-2.41-2.83-.29-5.46,1.5-6.27,4.22l-12.78,43.11c-1.02,3.46,1.27,7.02,4.83,7.53,21.52,3.08,42.52,4.68,54.65,4.68,48.91,0,85.16-10.75,108.89-32.21,21.48-19.41,33.15-48.89,35.2-88.52V110.63c0-3.26-2.64-5.9-5.9-5.9h-56.32Zm0,64.1s.65,139.13,0,143.36c-12.08,9.77-27.11,13.59-43.49,14.7-.16,.01-.33,.03-.49,.04-1.12,.07-2.24,.1-3.36,.1-1.32,0-2.63-.03-3.94-.1-40.41-2.11-74.52-37.26-74.52-79.38,0-10.25,1.96-20.01,5.42-28.98,11.22-29.12,38.77-49.74,71.06-49.74h49.33Z"}),Be.createElement("path",{className:"cls-1",d:"M249.83,0C113.3,0,2,110.09,.03,246.16c-2,138.19,110.12,252.7,248.33,253.5,42.68,.25,83.79-10.19,120.3-30.03,3.56-1.93,4.11-6.83,1.08-9.51l-23.38-20.72c-4.75-4.21-11.51-5.4-17.36-2.92-25.48,10.84-53.17,16.38-81.71,16.03-111.68-1.37-201.91-94.29-200.13-205.96,1.76-110.26,92-199.41,202.67-199.41h202.69V407.41l-115-102.18c-3.72-3.31-9.42-2.66-12.42,1.31-18.46,24.44-48.53,39.64-81.93,37.34-46.33-3.2-83.87-40.5-87.34-86.81-4.15-55.24,39.63-101.52,94-101.52,49.18,0,89.68,37.85,93.91,85.95,.38,4.28,2.31,8.27,5.52,11.12l29.95,26.55c3.4,3.01,8.79,1.17,9.63-3.3,2.16-11.55,2.92-23.58,2.07-35.92-4.82-70.34-61.8-126.93-132.17-131.26-80.68-4.97-148.13,58.14-150.27,137.25-2.09,77.1,61.08,143.56,138.19,145.26,32.19,.71,62.03-9.41,86.14-26.95l150.26,133.2c6.44,5.71,16.61,1.14,16.61-7.47V9.48C499.66,4.25,495.42,0,490.18,0H249.83Z"})))}function sr(e){return Be.createElement("svg",{width:"15",height:"15","aria-label":e.ariaLabel,role:"img"},Be.createElement("g",{fill:"none",stroke:"currentColor",strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:"1.2"},e.children))}function fr(e){var t=e.translations,n=void 0===t?{}:t,r=n.selectText,o=void 0===r?"to select":r,i=n.selectKeyAriaLabel,c=void 0===i?"Enter key":i,a=n.navigateText,u=void 0===a?"to navigate":a,l=n.navigateUpKeyAriaLabel,s=void 0===l?"Arrow up":l,f=n.navigateDownKeyAriaLabel,p=void 0===f?"Arrow down":f,m=n.closeText,d=void 0===m?"to close":m,v=n.closeKeyAriaLabel,h=void 0===v?"Escape key":v,y=n.searchByText,_=void 0===y?"Search by":y;return Be.createElement(Be.Fragment,null,Be.createElement("div",{className:"DocSearch-Logo"},Be.createElement(lr,{translations:{searchByText:_}})),Be.createElement("ul",{className:"DocSearch-Commands"},Be.createElement("li",null,Be.createElement("kbd",{className:"DocSearch-Commands-Key"},Be.createElement(sr,{ariaLabel:c},Be.createElement("path",{d:"M12 3.53088v3c0 1-1 2-2 2H4M7 11.53088l-3-3 3-3"}))),Be.createElement("span",{className:"DocSearch-Label"},o)),Be.createElement("li",null,Be.createElement("kbd",{className:"DocSearch-Commands-Key"},Be.createElement(sr,{ariaLabel:p},Be.createElement("path",{d:"M7.5 3.5v8M10.5 8.5l-3 3-3-3"}))),Be.createElement("kbd",{className:"DocSearch-Commands-Key"},Be.createElement(sr,{ariaLabel:s},Be.createElement("path",{d:"M7.5 11.5v-8M10.5 6.5l-3-3-3 3"}))),Be.createElement("span",{className:"DocSearch-Label"},u)),Be.createElement("li",null,Be.createElement("kbd",{className:"DocSearch-Commands-Key"},Be.createElement(sr,{ariaLabel:h},Be.createElement("path",{d:"M13.6167 8.936c-.1065.3583-.6883.962-1.4875.962-.7993 0-1.653-.9165-1.653-2.1258v-.5678c0-1.2548.7896-2.1016 1.653-2.1016.8634 0 1.3601.4778 1.4875 1.0724M9 6c-.1352-.4735-.7506-.9219-1.46-.8972-.7092.0246-1.344.57-1.344 1.2166s.4198.8812 1.3445.9805C8.465 7.3992 8.968 7.9337 9 8.5c.032.5663-.454 1.398-1.4595 1.398C6.6593 9.898 6 9 5.963 8.4851m-1.4748.5368c-.2635.5941-.8099.876-1.5443.876s-1.7073-.6248-1.7073-2.204v-.4603c0-1.0416.721-2.131 1.7073-2.131.9864 0 1.6425 1.031 1.5443 2.2492h-2.956"}))),Be.createElement("span",{className:"DocSearch-Label"},d))))}function pr(e){var t=e.hit,n=e.children;return Be.createElement("a",{href:t.url},n)}function mr(){return Be.createElement("svg",{viewBox:"0 0 38 38",stroke:"currentColor",strokeOpacity:".5"},Be.createElement("g",{fill:"none",fillRule:"evenodd"},Be.createElement("g",{transform:"translate(1 1)",strokeWidth:"2"},Be.createElement("circle",{strokeOpacity:".3",cx:"18",cy:"18",r:"18"}),Be.createElement("path",{d:"M36 18c0-9.94-8.06-18-18-18"},Be.createElement("animateTransform",{attributeName:"transform",type:"rotate",from:"0 18 18",to:"360 18 18",dur:"1s",repeatCount:"indefinite"})))))}function dr(){return Be.createElement("svg",{width:"20",height:"20",viewBox:"0 0 20 20"},Be.createElement("g",{stroke:"currentColor",fill:"none",fillRule:"evenodd",strokeLinecap:"round",strokeLinejoin:"round"},Be.createElement("path",{d:"M3.18 6.6a8.23 8.23 0 1112.93 9.94h0a8.23 8.23 0 01-11.63 0"}),Be.createElement("path",{d:"M6.44 7.25H2.55V3.36M10.45 6v5.6M10.45 11.6L13 13"})))}function vr(){return Be.createElement("svg",{width:"20",height:"20",viewBox:"0 0 20 20"},Be.createElement("path",{d:"M10 10l5.09-5.09L10 10l5.09 5.09L10 10zm0 0L4.91 4.91 10 10l-5.09 5.09L10 10z",stroke:"currentColor",fill:"none",fillRule:"evenodd",strokeLinecap:"round",strokeLinejoin:"round"}))}function hr(){return Be.createElement("svg",{className:"DocSearch-Hit-Select-Icon",width:"20",height:"20",viewBox:"0 0 20 20"},Be.createElement("g",{stroke:"currentColor",fill:"none",fillRule:"evenodd",strokeLinecap:"round",strokeLinejoin:"round"},Be.createElement("path",{d:"M18 3v4c0 2-2 4-4 4H2"}),Be.createElement("path",{d:"M8 17l-6-6 6-6"})))}var yr=function(){return Be.createElement("svg",{width:"20",height:"20",viewBox:"0 0 20 20"},Be.createElement("path",{d:"M17 6v12c0 .52-.2 1-1 1H4c-.7 0-1-.33-1-1V2c0-.55.42-1 1-1h8l5 5zM14 8h-3.13c-.51 0-.87-.34-.87-.87V4",stroke:"currentColor",fill:"none",fillRule:"evenodd",strokeLinejoin:"round"}))};function _r(e){switch(e.type){case"lvl1":return Be.createElement(yr,null);case"content":return Be.createElement(gr,null);default:return Be.createElement(br,null)}}function br(){return Be.createElement("svg",{width:"20",height:"20",viewBox:"0 0 20 20"},Be.createElement("path",{d:"M13 13h4-4V8H7v5h6v4-4H7V8H3h4V3v5h6V3v5h4-4v5zm-6 0v4-4H3h4z",stroke:"currentColor",fill:"none",fillRule:"evenodd",strokeLinecap:"round",strokeLinejoin:"round"}))}function gr(){return Be.createElement("svg",{width:"20",height:"20",viewBox:"0 0 20 20"},Be.createElement("path",{d:"M17 5H3h14zm0 5H3h14zm0 5H3h14z",stroke:"currentColor",fill:"none",fillRule:"evenodd",strokeLinejoin:"round"}))}function Sr(){return Be.createElement("svg",{width:"20",height:"20",viewBox:"0 0 20 20"},Be.createElement("path",{d:"M10 14.2L5 17l1-5.6-4-4 5.5-.7 2.5-5 2.5 5 5.6.8-4 4 .9 5.5z",stroke:"currentColor",fill:"none",fillRule:"evenodd",strokeLinejoin:"round"}))}function Or(){return Be.createElement("svg",{width:"40",height:"40",viewBox:"0 0 20 20",fill:"none",fillRule:"evenodd",stroke:"currentColor",strokeLinecap:"round",strokeLinejoin:"round"},Be.createElement("path",{d:"M19 4.8a16 16 0 00-2-1.2m-3.3-1.2A16 16 0 001.1 4.7M16.7 8a12 12 0 00-2.8-1.4M10 6a12 12 0 00-6.7 2M12.3 14.7a4 4 0 00-4.5 0M14.5 11.4A8 8 0 0010 10M3 16L18 2M10 18h0"}))}function wr(){return Be.createElement("svg",{width:"40",height:"40",viewBox:"0 0 20 20",fill:"none",fillRule:"evenodd",stroke:"currentColor",strokeLinecap:"round",strokeLinejoin:"round"},Be.createElement("path",{d:"M15.5 4.8c2 3 1.7 7-1 9.7h0l4.3 4.3-4.3-4.3a7.8 7.8 0 01-9.8 1m-2.2-2.2A7.8 7.8 0 0113.2 2.4M2 18L18 2"}))}function Er(e){var t=e.translations,n=void 0===t?{}:t,r=n.titleText,o=void 0===r?"Unable to fetch results":r,i=n.helpText,c=void 0===i?"You might want to check your network connection.":i;return Be.createElement("div",{className:"DocSearch-ErrorScreen"},Be.createElement("div",{className:"DocSearch-Screen-Icon"},Be.createElement(Or,null)),Be.createElement("p",{className:"DocSearch-Title"},o),Be.createElement("p",{className:"DocSearch-Help"},c))}var jr=["translations"];function Pr(e){var t=e.translations,n=void 0===t?{}:t,r=$e(e,jr),o=n.noResultsText,i=void 0===o?"No results for":o,c=n.suggestedQueryText,a=void 0===c?"Try searching for":c,u=n.reportMissingResultsText,l=void 0===u?"Believe this query should return results?":u,s=n.reportMissingResultsLinkText,f=void 0===s?"Let us know.":s,p=r.state.context.searchSuggestions;return Be.createElement("div",{className:"DocSearch-NoResults"},Be.createElement("div",{className:"DocSearch-Screen-Icon"},Be.createElement(wr,null)),Be.createElement("p",{className:"DocSearch-Title"},i,' "',Be.createElement("strong",null,r.state.query),'"'),p&&p.length>0&&Be.createElement("div",{className:"DocSearch-NoResults-Prefill-List"},Be.createElement("p",{className:"DocSearch-Help"},a,":"),Be.createElement("ul",null,p.slice(0,3).reduce((function(e,t){return[].concat(function(e){return function(e){if(Array.isArray(e))return Ye(e)}(e)||function(e){if("undefined"!=typeof Symbol&&null!=e[Symbol.iterator]||null!=e["@@iterator"])return Array.from(e)}(e)||Qe(e)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}(e),[Be.createElement("li",{key:t},Be.createElement("button",{className:"DocSearch-Prefill",key:t,type:"button",onClick:function(){r.setQuery(t.toLowerCase()+" "),r.refresh(),r.inputRef.current.focus()}},t))])}),[]))),r.getMissingResultsUrl&&Be.createElement("p",{className:"DocSearch-Help"},"".concat(l," "),Be.createElement("a",{href:r.getMissingResultsUrl({query:r.state.query}),target:"_blank",rel:"noopener noreferrer"},f)))}var Ir=["hit","attribute","tagName"];function Dr(e,t){return t.split(".").reduce((function(e,t){return null!=e&&e[t]?e[t]:null}),e)}function kr(e){var t=e.hit,n=e.attribute,r=e.tagName;return g(void 0===r?"span":r,We(We({},$e(e,Ir)),{},{dangerouslySetInnerHTML:{__html:Dr(t,"_snippetResult.".concat(n,".value"))||Dr(t,n)}}))}function Cr(e){return e.collection&&0!==e.collection.items.length?Be.createElement("section",{className:"DocSearch-Hits"},Be.createElement("div",{className:"DocSearch-Hit-source"},e.title),Be.createElement("ul",e.getListProps(),e.collection.items.map((function(t,n){return Be.createElement(Ar,Je({key:[e.title,t.objectID].join(":"),item:t,index:n},e))})))):null}function Ar(e){var t=e.item,n=e.index,r=e.renderIcon,o=e.renderAction,i=e.getItemProps,c=e.onItemClick,a=e.collection,u=e.hitComponent,l=Ze(Be.useState(!1),2),s=l[0],f=l[1],p=Ze(Be.useState(!1),2),m=p[0],d=p[1],v=Be.useRef(null),h=u;return Be.createElement("li",Je({className:["DocSearch-Hit",t.__docsearch_parent&&"DocSearch-Hit--Child",s&&"DocSearch-Hit--deleting",m&&"DocSearch-Hit--favoriting"].filter(Boolean).join(" "),onTransitionEnd:function(){v.current&&v.current()}},i({item:t,source:a.source,onClick:function(e){c(t,e)}})),Be.createElement(h,{hit:t},Be.createElement("div",{className:"DocSearch-Hit-Container"},r({item:t,index:n}),t.hierarchy[t.type]&&"lvl1"===t.type&&Be.createElement("div",{className:"DocSearch-Hit-content-wrapper"},Be.createElement(kr,{className:"DocSearch-Hit-title",hit:t,attribute:"hierarchy.lvl1"}),t.content&&Be.createElement(kr,{className:"DocSearch-Hit-path",hit:t,attribute:"content"})),t.hierarchy[t.type]&&("lvl2"===t.type||"lvl3"===t.type||"lvl4"===t.type||"lvl5"===t.type||"lvl6"===t.type)&&Be.createElement("div",{className:"DocSearch-Hit-content-wrapper"},Be.createElement(kr,{className:"DocSearch-Hit-title",hit:t,attribute:"hierarchy.".concat(t.type)}),Be.createElement(kr,{className:"DocSearch-Hit-path",hit:t,attribute:"hierarchy.lvl1"})),"content"===t.type&&Be.createElement("div",{className:"DocSearch-Hit-content-wrapper"},Be.createElement(kr,{className:"DocSearch-Hit-title",hit:t,attribute:"content"}),Be.createElement(kr,{className:"DocSearch-Hit-path",hit:t,attribute:"hierarchy.lvl1"})),o({item:t,runDeleteTransition:function(e){f(!0),v.current=e},runFavoriteTransition:function(e){d(!0),v.current=e}}))))}function xr(e,t,n){return e.reduce((function(e,r){var o=t(r);return e.hasOwnProperty(o)||(e[o]=[]),e[o].length<(n||5)&&e[o].push(r),e}),{})}function Nr(e){return e}function Tr(e){return 1===e.button||e.altKey||e.ctrlKey||e.metaKey||e.shiftKey}function Rr(){}var qr=/(<mark>|<\/mark>)/g,Lr=RegExp(qr.source);function Mr(e){var t,n,r=e;if(!r.__docsearch_parent&&!e._highlightResult)return e.hierarchy.lvl0;var o=((r.__docsearch_parent?null===(t=r.__docsearch_parent)||void 0===t||null===(t=t._highlightResult)||void 0===t||null===(t=t.hierarchy)||void 0===t?void 0:t.lvl0:null===(n=e._highlightResult)||void 0===n||null===(n=n.hierarchy)||void 0===n?void 0:n.lvl0)||{}).value;return o&&Lr.test(o)?o.replace(qr,""):o}function Hr(e){return Be.createElement("div",{className:"DocSearch-Dropdown-Container"},e.state.collections.map((function(t){if(0===t.items.length)return null;var n=Mr(t.items[0]);return Be.createElement(Cr,Je({},e,{key:t.source.sourceId,title:n,collection:t,renderIcon:function(e){var n,r=e.item,o=e.index;return Be.createElement(Be.Fragment,null,r.__docsearch_parent&&Be.createElement("svg",{className:"DocSearch-Hit-Tree",viewBox:"0 0 24 54"},Be.createElement("g",{stroke:"currentColor",fill:"none",fillRule:"evenodd",strokeLinecap:"round",strokeLinejoin:"round"},r.__docsearch_parent!==(null===(n=t.items[o+1])||void 0===n?void 0:n.__docsearch_parent)?Be.createElement("path",{d:"M8 6v21M20 27H8.3"}):Be.createElement("path",{d:"M8 6v42M20 27H8.3"}))),Be.createElement("div",{className:"DocSearch-Hit-icon"},Be.createElement(_r,{type:r.type})))},renderAction:function(){return Be.createElement("div",{className:"DocSearch-Hit-action"},Be.createElement(hr,null))}}))})),e.resultsFooterComponent&&Be.createElement("section",{className:"DocSearch-HitsFooter"},Be.createElement(e.resultsFooterComponent,{state:e.state})))}var Ur=["translations"];function Fr(e){var t=e.translations,n=void 0===t?{}:t,r=$e(e,Ur),o=n.recentSearchesTitle,i=void 0===o?"Recent":o,c=n.noRecentSearchesText,a=void 0===c?"No recent searches":c,u=n.saveRecentSearchButtonTitle,l=void 0===u?"Save this search":u,s=n.removeRecentSearchButtonTitle,f=void 0===s?"Remove this search from history":s,p=n.favoriteSearchesTitle,m=void 0===p?"Favorite":p,d=n.removeFavoriteSearchButtonTitle,v=void 0===d?"Remove this search from favorites":d;return"idle"===r.state.status&&!1===r.hasCollections?r.disableUserPersonalization?null:Be.createElement("div",{className:"DocSearch-StartScreen"},Be.createElement("p",{className:"DocSearch-Help"},a)):!1===r.hasCollections?null:Be.createElement("div",{className:"DocSearch-Dropdown-Container"},Be.createElement(Cr,Je({},r,{title:i,collection:r.state.collections[0],renderIcon:function(){return Be.createElement("div",{className:"DocSearch-Hit-icon"},Be.createElement(dr,null))},renderAction:function(e){var t=e.item,n=e.runFavoriteTransition,o=e.runDeleteTransition;return Be.createElement(Be.Fragment,null,Be.createElement("div",{className:"DocSearch-Hit-action"},Be.createElement("button",{className:"DocSearch-Hit-action-button",title:l,type:"submit",onClick:function(e){e.preventDefault(),e.stopPropagation(),n((function(){r.favoriteSearches.add(t),r.recentSearches.remove(t),r.refresh()}))}},Be.createElement(Sr,null))),Be.createElement("div",{className:"DocSearch-Hit-action"},Be.createElement("button",{className:"DocSearch-Hit-action-button",title:f,type:"submit",onClick:function(e){e.preventDefault(),e.stopPropagation(),o((function(){r.recentSearches.remove(t),r.refresh()}))}},Be.createElement(vr,null))))}})),Be.createElement(Cr,Je({},r,{title:m,collection:r.state.collections[1],renderIcon:function(){return Be.createElement("div",{className:"DocSearch-Hit-icon"},Be.createElement(Sr,null))},renderAction:function(e){var t=e.item,n=e.runDeleteTransition;return Be.createElement("div",{className:"DocSearch-Hit-action"},Be.createElement("button",{className:"DocSearch-Hit-action-button",title:v,type:"submit",onClick:function(e){e.preventDefault(),e.stopPropagation(),n((function(){r.favoriteSearches.remove(t),r.refresh()}))}},Be.createElement(vr,null)))}})))}var Br=["translations"],Vr=Be.memo((function(e){var t=e.translations,n=void 0===t?{}:t,r=$e(e,Br);if("error"===r.state.status)return Be.createElement(Er,{translations:null==n?void 0:n.errorScreen});var o=r.state.collections.some((function(e){return e.items.length>0}));return r.state.query?!1===o?Be.createElement(Pr,Je({},r,{translations:null==n?void 0:n.noResultsScreen})):Be.createElement(Hr,r):Be.createElement(Fr,Je({},r,{hasCollections:o,translations:null==n?void 0:n.startScreen}))}),(function(e,t){return"loading"===t.state.status||"stalled"===t.state.status})),Kr=["translations"];function Wr(e){var t=e.translations,n=void 0===t?{}:t,r=$e(e,Kr),o=n.resetButtonTitle,i=void 0===o?"Clear the query":o,c=n.resetButtonAriaLabel,a=void 0===c?"Clear the query":c,u=n.cancelButtonText,l=void 0===u?"Cancel":u,s=n.cancelButtonAriaLabel,f=void 0===s?"Cancel":s,p=n.searchInputLabel,m=void 0===p?"Search":p,d=r.getFormProps({inputElement:r.inputRef.current}).onReset;return Be.useEffect((function(){r.autoFocus&&r.inputRef.current&&r.inputRef.current.focus()}),[r.autoFocus,r.inputRef]),Be.useEffect((function(){r.isFromSelection&&r.inputRef.current&&r.inputRef.current.select()}),[r.isFromSelection,r.inputRef]),Be.createElement(Be.Fragment,null,Be.createElement("form",{className:"DocSearch-Form",onSubmit:function(e){e.preventDefault()},onReset:d},Be.createElement("label",Je({className:"DocSearch-MagnifierLabel"},r.getLabelProps()),Be.createElement(Xe,null),Be.createElement("span",{className:"DocSearch-VisuallyHiddenForAccessibility"},m)),Be.createElement("div",{className:"DocSearch-LoadingIndicator"},Be.createElement(mr,null)),Be.createElement("input",Je({className:"DocSearch-Input",ref:r.inputRef},r.getInputProps({inputElement:r.inputRef.current,autoFocus:r.autoFocus,maxLength:64}))),Be.createElement("button",{type:"reset",title:i,className:"DocSearch-Reset","aria-label":a,hidden:!r.state.query},Be.createElement(vr,null))),Be.createElement("button",{className:"DocSearch-Cancel",type:"reset","aria-label":f,onClick:r.onClose},l))}var zr=["_highlightResult","_snippetResult"];function Jr(e){var t=e.key,n=e.limit,r=void 0===n?5:n,o=function(e){return!1===function(){var e="__TEST_KEY__";try{return localStorage.setItem(e,""),localStorage.removeItem(e),!0}catch(e){return!1}}()?{setItem:function(){},getItem:function(){return[]}}:{setItem:function(t){return window.localStorage.setItem(e,JSON.stringify(t))},getItem:function(){var t=window.localStorage.getItem(e);return t?JSON.parse(t):[]}}}(t),i=o.getItem().slice(0,r);return{add:function(e){var t=e,n=(t._highlightResult,t._snippetResult,$e(t,zr)),c=i.findIndex((function(e){return e.objectID===n.objectID}));c>-1&&i.splice(c,1),i.unshift(n),i=i.slice(0,r),o.setItem(i)},remove:function(e){i=i.filter((function(t){return t.objectID!==e.objectID})),o.setItem(i)},getAll:function(){return i}}}function $r(e){var t,n="algoliasearch-client-js-".concat(e.key),r=function(){return void 0===t&&(t=e.localStorage||window.localStorage),t},o=function(){return JSON.parse(r().getItem(n)||"{}")},i=function(e){r().setItem(n,JSON.stringify(e))};return{get:function(t,n){var r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{miss:function(){return Promise.resolve()}};return Promise.resolve().then((function(){!function(){var t=e.timeToLive?1e3*e.timeToLive:null,n=o(),r=Object.fromEntries(Object.entries(n).filter((function(e){return void 0!==c(e,2)[1].timestamp})));if(i(r),t){var a=Object.fromEntries(Object.entries(r).filter((function(e){var n=c(e,2)[1],r=(new Date).getTime();return!(n.timestamp+t<r)})));i(a)}}();var n=JSON.stringify(t);return o()[n]})).then((function(e){return Promise.all([e?e.value:n(),void 0!==e])})).then((function(e){var t=c(e,2),n=t[0],o=t[1];return Promise.all([n,o||r.miss(n)])})).then((function(e){return c(e,1)[0]}))},set:function(e,t){return Promise.resolve().then((function(){var i=o();return i[JSON.stringify(e)]={timestamp:(new Date).getTime(),value:t},r().setItem(n,JSON.stringify(i)),t}))},delete:function(e){return Promise.resolve().then((function(){var t=o();delete t[JSON.stringify(e)],r().setItem(n,JSON.stringify(t))}))},clear:function(){return Promise.resolve().then((function(){r().removeItem(n)}))}}}function Zr(e){var t=a(e.caches),n=t.shift();return void 0===n?{get:function(e,t){var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{miss:function(){return Promise.resolve()}};return t().then((function(e){return Promise.all([e,n.miss(e)])})).then((function(e){return c(e,1)[0]}))},set:function(e,t){return Promise.resolve(t)},delete:function(e){return Promise.resolve()},clear:function(){return Promise.resolve()}}:{get:function(e,r){var o=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{miss:function(){return Promise.resolve()}};return n.get(e,r,o).catch((function(){return Zr({caches:t}).get(e,r,o)}))},set:function(e,r){return n.set(e,r).catch((function(){return Zr({caches:t}).set(e,r)}))},delete:function(e){return n.delete(e).catch((function(){return Zr({caches:t}).delete(e)}))},clear:function(){return n.clear().catch((function(){return Zr({caches:t}).clear()}))}}}function Qr(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{serializable:!0},t={};return{get:function(n,r){var o=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{miss:function(){return Promise.resolve()}},i=JSON.stringify(n);if(i in t)return Promise.resolve(e.serializable?JSON.parse(t[i]):t[i]);var c=r(),a=o&&o.miss||function(){return Promise.resolve()};return c.then((function(e){return a(e)})).then((function(){return c}))},set:function(n,r){return t[JSON.stringify(n)]=e.serializable?JSON.stringify(r):r,Promise.resolve(r)},delete:function(e){return delete t[JSON.stringify(e)],Promise.resolve()},clear:function(){return t={},Promise.resolve()}}}function Yr(e){for(var t=e.length-1;t>0;t--){var n=Math.floor(Math.random()*(t+1)),r=e[t];e[t]=e[n],e[n]=r}return e}function Gr(e,t){return t?(Object.keys(t).forEach((function(n){e[n]=t[n](e)})),e):e}function Xr(e){for(var t=arguments.length,n=new Array(t>1?t-1:0),r=1;r<t;r++)n[r-1]=arguments[r];var o=0;return e.replace(/%s/g,(function(){return encodeURIComponent(n[o++])}))}var eo=0,to=1;function no(e,t){var n=e||{},r=n.data||{};return Object.keys(n).forEach((function(e){-1===["timeout","headers","queryParameters","data","cacheable"].indexOf(e)&&(r[e]=n[e])})),{data:Object.entries(r).length>0?r:void 0,timeout:n.timeout||t,headers:n.headers||{},queryParameters:n.queryParameters||{},cacheable:n.cacheable}}var ro={Read:1,Write:2,Any:3};function oo(e){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:1;return t(t({},e),{},{status:n,lastUpdate:Date.now()})}function io(e){return"string"==typeof e?{protocol:"https",url:e,accept:ro.Any}:{protocol:e.protocol||"https",url:e.url,accept:e.accept||ro.Any}}var co="GET",ao="POST";function uo(e,n,r,o){var i=[],c=function(e,n){if(e.method!==co&&(void 0!==e.data||void 0!==n.data)){var r=Array.isArray(e.data)?e.data:t(t({},e.data),n.data);return JSON.stringify(r)}}(r,o),u=function(e,n){var r=t(t({},e.headers),n.headers),o={};return Object.keys(r).forEach((function(e){var t=r[e];o[e.toLowerCase()]=t})),o}(e,o),l=r.method,s=r.method!==co?{}:t(t({},r.data),o.data),f=t(t(t({"x-algolia-agent":e.userAgent.value},e.queryParameters),s),o.queryParameters),p=0,m=function t(n,a){var s=n.pop();if(void 0===s)throw{name:"RetryError",message:"Unreachable hosts - your application id may be incorrect. If the error persists, contact support@algolia.com.",transporterStackTrace:po(i)};var m={data:c,headers:u,method:l,url:so(s,r.path,f),connectTimeout:a(p,e.timeouts.connect),responseTimeout:a(p,o.timeout)},d=function(e){var t={request:m,response:e,host:s,triesLeft:n.length};return i.push(t),t},v={onSuccess:function(e){return function(e){try{return JSON.parse(e.content)}catch(t){throw function(e,t){return{name:"DeserializationError",message:e,response:t}}(t.message,e)}}(e)},onRetry:function(r){var o=d(r);return r.isTimedOut&&p++,Promise.all([e.logger.info("Retryable failure",mo(o)),e.hostsCache.set(s,oo(s,r.isTimedOut?3:2))]).then((function(){return t(n,a)}))},onFail:function(e){throw d(e),function(e,t){var n=e.content,r=e.status,o=n;try{o=JSON.parse(n).message}catch(n){}return function(e,t,n){return{name:"ApiError",message:e,status:t,transporterStackTrace:n}}(o,r,t)}(e,po(i))}};return e.requester.send(m).then((function(e){return function(e,t){return function(e){var t=e.status;return e.isTimedOut||function(e){var t=e.isTimedOut,n=e.status;return!t&&0==~~n}(e)||2!=~~(t/100)&&4!=~~(t/100)}(e)?t.onRetry(e):(n=e,2==~~(n.status/100)?t.onSuccess(e):t.onFail(e));var n}(e,v)}))};return function(e,t){return Promise.all(t.map((function(t){return e.get(t,(function(){return Promise.resolve(oo(t))}))}))).then((function(e){var n=e.filter((function(e){return function(e){return 1===e.status||Date.now()-e.lastUpdate>12e4}(e)})),r=e.filter((function(e){return function(e){return 3===e.status&&Date.now()-e.lastUpdate<=12e4}(e)})),o=[].concat(a(n),a(r));return{getTimeout:function(e,t){return(0===r.length&&0===e?1:r.length+3+e)*t},statelessHosts:o.length>0?o.map((function(e){return io(e)})):t}}))}(e.hostsCache,n).then((function(e){return m(a(e.statelessHosts).reverse(),e.getTimeout)}))}function lo(e){var t={value:"Algolia for JavaScript (".concat(e,")"),add:function(e){var n="; ".concat(e.segment).concat(void 0!==e.version?" (".concat(e.version,")"):"");return-1===t.value.indexOf(n)&&(t.value="".concat(t.value).concat(n)),t}};return t}function so(e,t,n){var r=fo(n),o="".concat(e.protocol,"://").concat(e.url,"/").concat("/"===t.charAt(0)?t.substr(1):t);return r.length&&(o+="?".concat(r)),o}function fo(e){return Object.keys(e).map((function(t){return Xr("%s=%s",t,(n=e[t],"[object Object]"===Object.prototype.toString.call(n)||"[object Array]"===Object.prototype.toString.call(n)?JSON.stringify(e[t]):e[t]));var n})).join("&")}function po(e){return e.map((function(e){return mo(e)}))}function mo(e){var n=e.request.headers["x-algolia-api-key"]?{"x-algolia-api-key":"*****"}:{};return t(t({},e),{},{request:t(t({},e.request),{},{headers:t(t({},e.request.headers),n)})})}var vo=function(e){return function(t,n){return t.method===co?e.transporter.read(t,n):e.transporter.write(t,n)}},ho=function(e){return function(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};return Gr({transporter:e.transporter,appId:e.appId,indexName:t},n.methods)}},yo=function(e){return function(n,r){var o=n.map((function(e){return t(t({},e),{},{params:fo(e.params||{})})}));return e.transporter.read({method:ao,path:"1/indexes/*/queries",data:{requests:o},cacheable:!0},r)}},_o=function(e){return function(n,r){return Promise.all(n.map((function(n){var o=n.params,c=o.facetName,a=o.facetQuery,u=i(o,Ve);return ho(e)(n.indexName,{methods:{searchForFacetValues:So}}).searchForFacetValues(c,a,t(t({},r),u))})))}},bo=function(e){return function(t,n,r){return e.transporter.read({method:ao,path:Xr("1/answers/%s/prediction",e.indexName),data:{query:t,queryLanguages:n},cacheable:!0},r)}},go=function(e){return function(t,n){return e.transporter.read({method:ao,path:Xr("1/indexes/%s/query",e.indexName),data:{query:t},cacheable:!0},n)}},So=function(e){return function(t,n,r){return e.transporter.read({method:ao,path:Xr("1/indexes/%s/facets/%s/query",e.indexName,t),data:{facetQuery:n},cacheable:!0},r)}};function Oo(e,n,r){var o={appId:e,apiKey:n,timeouts:{connect:1,read:2,write:30},requester:{send:function(e){return new Promise((function(t){var n=new XMLHttpRequest;n.open(e.method,e.url,!0),Object.keys(e.headers).forEach((function(t){return n.setRequestHeader(t,e.headers[t])}));var r,o=function(e,r){return setTimeout((function(){n.abort(),t({status:0,content:r,isTimedOut:!0})}),1e3*e)},i=o(e.connectTimeout,"Connection timeout");n.onreadystatechange=function(){n.readyState>n.OPENED&&void 0===r&&(clearTimeout(i),r=o(e.responseTimeout,"Socket timeout"))},n.onerror=function(){0===n.status&&(clearTimeout(i),clearTimeout(r),t({content:n.responseText||"Network request failed",status:n.status,isTimedOut:!1}))},n.onload=function(){clearTimeout(i),clearTimeout(r),t({content:n.responseText,status:n.status,isTimedOut:!1})},n.send(e.data)}))}},logger:(3,{debug:function(e,t){return Promise.resolve()},info:function(e,t){return Promise.resolve()},error:function(e,t){return console.error(e,t),Promise.resolve()}}),responsesCache:Qr(),requestsCache:Qr({serializable:!1}),hostsCache:Zr({caches:[$r({key:"4.19.1-".concat(e)}),Qr()]}),userAgent:lo("4.19.1").add({segment:"Browser",version:"lite"}),authMode:eo};return function(e){var n=e.appId,r=function(e,t,n){var r={"x-algolia-api-key":n,"x-algolia-application-id":t};return{headers:function(){return e===to?r:{}},queryParameters:function(){return e===eo?r:{}}}}(void 0!==e.authMode?e.authMode:to,n,e.apiKey),o=function(e){var t=e.hostsCache,n=e.logger,r=e.requester,o=e.requestsCache,i=e.responsesCache,a=e.timeouts,u=e.userAgent,l=e.hosts,s=e.queryParameters,f={hostsCache:t,logger:n,requester:r,requestsCache:o,responsesCache:i,timeouts:a,userAgent:u,headers:e.headers,queryParameters:s,hosts:l.map((function(e){return io(e)})),read:function(e,t){var n=no(t,f.timeouts.read),r=function(){return uo(f,f.hosts.filter((function(e){return 0!=(e.accept&ro.Read)})),e,n)};if(!0!==(void 0!==n.cacheable?n.cacheable:e.cacheable))return r();var o={request:e,mappedRequestOptions:n,transporter:{queryParameters:f.queryParameters,headers:f.headers}};return f.responsesCache.get(o,(function(){return f.requestsCache.get(o,(function(){return f.requestsCache.set(o,r()).then((function(e){return Promise.all([f.requestsCache.delete(o),e])}),(function(e){return Promise.all([f.requestsCache.delete(o),Promise.reject(e)])})).then((function(e){var t=c(e,2);return t[0],t[1]}))}))}),{miss:function(e){return f.responsesCache.set(o,e)}})},write:function(e,t){return uo(f,f.hosts.filter((function(e){return 0!=(e.accept&ro.Write)})),e,no(t,f.timeouts.write))}};return f}(t(t({hosts:[{url:"".concat(n,"-dsn.algolia.net"),accept:ro.Read},{url:"".concat(n,".algolia.net"),accept:ro.Write}].concat(Yr([{url:"".concat(n,"-1.algolianet.com")},{url:"".concat(n,"-2.algolianet.com")},{url:"".concat(n,"-3.algolianet.com")}]))},e),{},{headers:t(t({},r.headers()),{},{"content-type":"application/x-www-form-urlencoded"},e.headers),queryParameters:t(t({},r.queryParameters()),e.queryParameters)})),i={transporter:o,appId:n,addAlgoliaAgent:function(e,t){o.userAgent.add({segment:e,version:t})},clearCache:function(){return Promise.all([o.requestsCache.clear(),o.responsesCache.clear()]).then((function(){}))}};return Gr(i,e.methods)}(t(t(t({},o),r),{},{methods:{search:yo,searchForFacetValues:_o,multipleQueries:yo,multipleSearchForFacetValues:_o,customRequest:vo,initIndex:function(e){return function(t){return ho(e)(t,{methods:{search:go,searchForFacetValues:So,findAnswers:bo}})}}}}))}Oo.version="4.19.1";var wo=["footer","searchBox"];function Eo(e){var t=e.appId,n=e.apiKey,r=e.indexName,o=e.placeholder,i=void 0===o?"Search docs":o,c=e.searchParameters,a=e.maxResultsPerGroup,u=e.onClose,l=void 0===u?Rr:u,s=e.transformItems,f=void 0===s?Nr:s,p=e.hitComponent,m=void 0===p?pr:p,d=e.resultsFooterComponent,v=void 0===d?function(){return null}:d,h=e.navigator,y=e.initialScrollY,_=void 0===y?0:y,b=e.transformSearchClient,g=void 0===b?Nr:b,S=e.disableUserPersonalization,O=void 0!==S&&S,w=e.initialQuery,E=void 0===w?"":w,j=e.translations,P=void 0===j?{}:j,I=e.getMissingResultsUrl,D=e.insights,k=void 0!==D&&D,C=P.footer,A=P.searchBox,x=$e(P,wo),N=Ze(Be.useState({query:"",collections:[],completion:null,context:{},isOpen:!1,activeItemId:null,status:"idle"}),2),T=N[0],R=N[1],q=Be.useRef(null),L=Be.useRef(null),M=Be.useRef(null),H=Be.useRef(null),U=Be.useRef(null),F=Be.useRef(10),B=Be.useRef("undefined"!=typeof window?window.getSelection().toString().slice(0,64):"").current,V=Be.useRef(E||B).current,K=function(e,t,n){return Be.useMemo((function(){var r=Oo(e,t);return r.addAlgoliaAgent("docsearch","3.6.1"),!1===/docsearch.js \(.*\)/.test(r.transporter.userAgent.value)&&r.addAlgoliaAgent("docsearch-react","3.6.1"),n(r)}),[e,t,n])}(t,n,g),W=Be.useRef(Jr({key:"__DOCSEARCH_FAVORITE_SEARCHES__".concat(r),limit:10})).current,z=Be.useRef(Jr({key:"__DOCSEARCH_RECENT_SEARCHES__".concat(r),limit:0===W.getAll().length?7:4})).current,J=Be.useCallback((function(e){if(!O){var t="content"===e.type?e.__docsearch_parent:e;t&&-1===W.getAll().findIndex((function(e){return e.objectID===t.objectID}))&&z.add(t)}}),[W,z,O]),$=Be.useCallback((function(e){if(T.context.algoliaInsightsPlugin&&e.__autocomplete_id){var t=e,n={eventName:"Item Selected",index:t.__autocomplete_indexName,items:[t],positions:[e.__autocomplete_id],queryID:t.__autocomplete_queryID};T.context.algoliaInsightsPlugin.insights.clickedObjectIDsAfterSearch(n)}}),[T.context.algoliaInsightsPlugin]),Z=Be.useMemo((function(){return ur({id:"docsearch",defaultActiveItemId:0,placeholder:i,openOnFocus:!0,initialState:{query:V,context:{searchSuggestions:[]}},insights:k,navigator:h,onStateChange:function(e){R(e.state)},getSources:function(e){var o=e.query,i=e.state,u=e.setContext,s=e.setStatus;if(!o)return O?[]:[{sourceId:"recentSearches",onSelect:function(e){var t=e.item,n=e.event;J(t),Tr(n)||l()},getItemUrl:function(e){return e.item.url},getItems:function(){return z.getAll()}},{sourceId:"favoriteSearches",onSelect:function(e){var t=e.item,n=e.event;J(t),Tr(n)||l()},getItemUrl:function(e){return e.item.url},getItems:function(){return W.getAll()}}];var p=Boolean(k);return K.search([{query:o,indexName:r,params:We({attributesToRetrieve:["hierarchy.lvl0","hierarchy.lvl1","hierarchy.lvl2","hierarchy.lvl3","hierarchy.lvl4","hierarchy.lvl5","hierarchy.lvl6","content","type","url"],attributesToSnippet:["hierarchy.lvl1:".concat(F.current),"hierarchy.lvl2:".concat(F.current),"hierarchy.lvl3:".concat(F.current),"hierarchy.lvl4:".concat(F.current),"hierarchy.lvl5:".concat(F.current),"hierarchy.lvl6:".concat(F.current),"content:".concat(F.current)],snippetEllipsisText:"…",highlightPreTag:"<mark>",highlightPostTag:"</mark>",hitsPerPage:20,clickAnalytics:p},c)}]).catch((function(e){throw"RetryError"===e.name&&s("error"),e})).then((function(e){var o=e.results[0],c=o.hits,s=o.nbHits,m=xr(c,(function(e){return Mr(e)}),a);i.context.searchSuggestions.length<Object.keys(m).length&&u({searchSuggestions:Object.keys(m)}),u({nbHits:s});var d={};return p&&(d={__autocomplete_indexName:r,__autocomplete_queryID:o.queryID,__autocomplete_algoliaCredentials:{appId:t,apiKey:n}}),Object.values(m).map((function(e,t){return{sourceId:"hits".concat(t),onSelect:function(e){var t=e.item,n=e.event;J(t),Tr(n)||l()},getItemUrl:function(e){return e.item.url},getItems:function(){return Object.values(xr(e,(function(e){return e.hierarchy.lvl1}),a)).map(f).map((function(e){return e.map((function(t){var n=null,r=e.find((function(e){return"lvl1"===e.type&&e.hierarchy.lvl1===t.hierarchy.lvl1}));return"lvl1"!==t.type&&r&&(n=r),We(We({},t),{},{__docsearch_parent:n},d)}))})).flat()}}}))}))}})}),[r,c,a,K,l,z,W,J,V,i,h,f,O,k,t,n]),Q=Z.getEnvironmentProps,Y=Z.getRootProps,G=Z.refresh;return function(e){var t=e.getEnvironmentProps,n=e.panelElement,r=e.formElement,o=e.inputElement;Be.useEffect((function(){if(n&&r&&o){var e=t({panelElement:n,formElement:r,inputElement:o}),i=e.onTouchStart,c=e.onTouchMove;return window.addEventListener("touchstart",i),window.addEventListener("touchmove",c),function(){window.removeEventListener("touchstart",i),window.removeEventListener("touchmove",c)}}}),[t,n,r,o])}({getEnvironmentProps:Q,panelElement:H.current,formElement:M.current,inputElement:U.current}),function(e){var t=e.container;Be.useEffect((function(){if(t){var e=t.querySelectorAll("a[href]:not([disabled]), button:not([disabled]), input:not([disabled])"),n=e[0],r=e[e.length-1];return t.addEventListener("keydown",o),function(){t.removeEventListener("keydown",o)}}function o(e){"Tab"===e.key&&(e.shiftKey?document.activeElement===n&&(e.preventDefault(),r.focus()):document.activeElement===r&&(e.preventDefault(),n.focus()))}}),[t])}({container:q.current}),Be.useEffect((function(){return document.body.classList.add("DocSearch--active"),function(){var e,t;document.body.classList.remove("DocSearch--active"),null===(e=(t=window).scrollTo)||void 0===e||e.call(t,0,_)}}),[]),Be.useEffect((function(){window.matchMedia("(max-width: 768px)").matches&&(F.current=5)}),[]),Be.useEffect((function(){H.current&&(H.current.scrollTop=0)}),[T.query]),Be.useEffect((function(){V.length>0&&(G(),U.current&&U.current.focus())}),[V,G]),Be.useEffect((function(){function e(){if(L.current){var e=.01*window.innerHeight;L.current.style.setProperty("--docsearch-vh","".concat(e,"px"))}}return e(),window.addEventListener("resize",e),function(){window.removeEventListener("resize",e)}}),[]),Be.createElement("div",Je({ref:q},Y({"aria-expanded":!0}),{className:["DocSearch","DocSearch-Container","stalled"===T.status&&"DocSearch-Container--Stalled","error"===T.status&&"DocSearch-Container--Errored"].filter(Boolean).join(" "),role:"button",tabIndex:0,onMouseDown:function(e){e.target===e.currentTarget&&l()}}),Be.createElement("div",{className:"DocSearch-Modal",ref:L},Be.createElement("header",{className:"DocSearch-SearchBar",ref:M},Be.createElement(Wr,Je({},Z,{state:T,autoFocus:0===V.length,inputRef:U,isFromSelection:Boolean(V)&&V===B,translations:A,onClose:l}))),Be.createElement("div",{className:"DocSearch-Dropdown",ref:H},Be.createElement(Vr,Je({},Z,{indexName:r,state:T,hitComponent:m,resultsFooterComponent:v,disableUserPersonalization:O,recentSearches:z,favoriteSearches:W,inputRef:U,translations:x,getMissingResultsUrl:I,onItemClick:function(e,t){$(e),J(e),Tr(t)||l()}}))),Be.createElement("footer",{className:"DocSearch-Footer"},Be.createElement(fr,{translations:C}))))}function jo(e){var t,n,r=Be.useRef(null),o=Ze(Be.useState(!1),2),i=o[0],c=o[1],a=Ze(Be.useState((null==e?void 0:e.initialQuery)||void 0),2),u=a[0],l=a[1],s=Be.useCallback((function(){c(!0)}),[c]),f=Be.useCallback((function(){c(!1)}),[c]);return function(e){var t=e.isOpen,n=e.onOpen,r=e.onClose,o=e.onInput,i=e.searchButtonRef;Be.useEffect((function(){function e(e){var c;(27===e.keyCode&&t||"k"===(null===(c=e.key)||void 0===c?void 0:c.toLowerCase())&&(e.metaKey||e.ctrlKey)||!function(e){var t=e.target,n=t.tagName;return t.isContentEditable||"INPUT"===n||"SELECT"===n||"TEXTAREA"===n}(e)&&"/"===e.key&&!t)&&(e.preventDefault(),t?r():document.body.classList.contains("DocSearch--active")||document.body.classList.contains("DocSearch--active")||n()),i&&i.current===document.activeElement&&o&&/[a-zA-Z0-9]/.test(String.fromCharCode(e.keyCode))&&o(e)}return window.addEventListener("keydown",e),function(){window.removeEventListener("keydown",e)}}),[t,n,r,o,i])}({isOpen:i,onOpen:s,onClose:f,onInput:Be.useCallback((function(e){c(!0),l(e.key)}),[c,l]),searchButtonRef:r}),Be.createElement(Be.Fragment,null,Be.createElement(tt,{ref:r,translations:null==e||null===(t=e.translations)||void 0===t?void 0:t.button,onClick:s}),i&&Ie(Be.createElement(Eo,Je({},e,{initialScrollY:window.scrollY,initialQuery:u,translations:null==e||null===(n=e.translations)||void 0===n?void 0:n.modal,onClose:f})),document.body))}return function(e){Ae(Be.createElement(jo,o({},e,{transformSearchClient:function(t){return t.addAlgoliaAgent("docsearch.js","3.6.1"),e.transformSearchClient?e.transformSearchClient(t):t}})),function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:window;return"string"==typeof e?t.document.querySelector(e):e}(e.container,e.environment))}})); //# sourceMappingURL=index.js.map diff --git a/website/assets/images/articles/discovering-chrome-ai-using-fleet-1-1472x370@2x.png b/website/assets/images/articles/discovering-chrome-ai-using-fleet-1-1472x370@2x.png new file mode 100644 index 000000000000..6ff5d63033a6 Binary files /dev/null and b/website/assets/images/articles/discovering-chrome-ai-using-fleet-1-1472x370@2x.png differ diff --git a/website/assets/images/articles/discovering-chrome-ai-using-fleet-1600x900@2x.jpg b/website/assets/images/articles/discovering-chrome-ai-using-fleet-1600x900@2x.jpg new file mode 100644 index 000000000000..e86a679ec569 Binary files /dev/null and b/website/assets/images/articles/discovering-chrome-ai-using-fleet-1600x900@2x.jpg differ diff --git a/website/assets/images/articles/discovering-chrome-ai-using-fleet-2-1474x276@2x.png b/website/assets/images/articles/discovering-chrome-ai-using-fleet-2-1474x276@2x.png new file mode 100644 index 000000000000..bb0a530d3e33 Binary files /dev/null and b/website/assets/images/articles/discovering-chrome-ai-using-fleet-2-1474x276@2x.png differ diff --git a/website/assets/images/articles/fleet-4.56.0-1600x900@2x.png b/website/assets/images/articles/fleet-4.56.0-1600x900@2x.png new file mode 100644 index 000000000000..697877bc6c02 Binary files /dev/null and b/website/assets/images/articles/fleet-4.56.0-1600x900@2x.png differ diff --git a/website/assets/js/pages/articles/articles.page.js b/website/assets/js/pages/articles/articles.page.js index b4034386e996..8aa5dc260067 100644 --- a/website/assets/js/pages/articles/articles.page.js +++ b/website/assets/js/pages/articles/articles.page.js @@ -62,7 +62,22 @@ parasails.registerPage('articles', { }, mounted: async function() { - //… + if(this.category === 'guides') { + if(this.algoliaPublicKey) {// Note: Docsearch will only be enabled if sails.config.custom.algoliaPublicKey is set. If the value is undefined, the handbook search will be disabled. + docsearch({ + appId: 'NZXAYZXDGH', + apiKey: this.algoliaPublicKey, + indexName: 'fleetdm', + container: '#docsearch-query', + placeholder: 'Search', + debug: false, + clickAnalytics: true, + searchParameters: { + facetFilters: ['section:guides'] + }, + }); + } + } }, // ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗ diff --git a/website/assets/styles/layout.less b/website/assets/styles/layout.less index 94e56945a871..6eefec24a939 100644 --- a/website/assets/styles/layout.less +++ b/website/assets/styles/layout.less @@ -444,8 +444,9 @@ html, body { } } [purpose='gh-button'] { - margin-left: 20px; - margin-right: 20px; + padding: 0px 20px; + min-width: 140px; + width: 140px; } [purpose='header-dropdown'] { box-shadow: 0px 4px 40px rgba(0, 0, 0, 0.4); diff --git a/website/assets/styles/pages/articles/articles.less b/website/assets/styles/pages/articles/articles.less index 552b110da0bc..770943f00c35 100644 --- a/website/assets/styles/pages/articles/articles.less +++ b/website/assets/styles/pages/articles/articles.less @@ -1,6 +1,7 @@ #articles { - padding-left: 24px; - padding-right: 24px; + [purpose='page-container'] { + padding: 64px; + } [purpose='categories-and-search'] { padding-top: 40px; @@ -30,9 +31,15 @@ } } [purpose='category-title'] { - padding-top: 80px; padding-bottom: 40px; } + + [purpose='guides-category-page'] { + [purpose='category-title'] { + margin-left: 12px; + margin-right: 6px; + } + } [purpose='rss-button'] { padding: 4px 8px; cursor: pointer; @@ -49,7 +56,6 @@ font-size: 16px; } } - [purpose='rss-button'].copied::after { content: 'Link copied'; display: flex; @@ -74,11 +80,100 @@ 100% {opacity: 0;} } + [purpose='search'] { + // Note: We're using classes here to override the default Docsearch styles; + button { + width: 100%; + cursor: text; + margin: 0; + } + .DocSearch-Button { + border-top-left-radius: 6px; + border-bottom-left-radius: 6px; + border-top-right-radius: 6px; + border-bottom-right-radius: 6px; + border: 1px solid @core-fleet-black-25; + background-color: #FFF; + padding: 6px; + height: 36px; + margin: 0; + width: 256px; + } + .DocSearch-Button:hover { + box-shadow: none; + border: 1px solid @core-fleet-black-25; + color: @core-fleet-black-50; + } + .DocSearch-Search-Icon { + margin-left: 10px; + height: 16px; + width: 16px; + color: @core-fleet-black-50; + stroke-width: 3px; + } + .DocSearch-Button-Keys { + display: none; + } + .input-group:focus-within { + border: 1px solid @core-vibrant-blue; + } + .DocSearch-Button-Placeholder { + font-size: 16px; + font-weight: 400; + padding-left: 12px; + } + [purpose='disabled-search'] { + input { + padding-top: 6px; + padding-bottom: 6px; + border: none; + } &::placeholder { + font-size: 16px; + line-height: 24px; + } + .input-group { + border-top-left-radius: 6px; + border-bottom-left-radius: 6px; + border-top-right-radius: 6px; + border-bottom-right-radius: 6px; + border: 1px solid @core-fleet-black-25; + background: #FFF; + } + .input-group:focus-within { + border: 1px solid @core-vibrant-blue; + } + .docsearch-input { + height: 100%; + width: 98%; + } + .docsearch-input:focus-visible { + outline: none; + } + .ds-input:focus { + outline: rgba(0, 0, 0, 0); + } + .input-group-text { + color: @core-fleet-black-50; + } + .form-control { + height: 36px; + padding: 0px; + font-size: 16px; + } &:focus { + border: none; + } + } + } + [purpose='articles'] { - padding-bottom: 80px; + width: 100%; + max-width: 100%; [purpose='article-card'] { - + a { + text-decoration: none; + color: @core-fleet-black-75; + } margin-left: 10px; margin-right: 10px; margin-bottom: 40px; @@ -123,6 +218,71 @@ } } } + [purpose='guides'] { + column-count: 3; + margin-left: -2px; + margin-right: 14px; + } + [purpose='guide-card'] { + &:hover { + box-shadow: 0px 4px 16px 0px #E2E4EA; + } + max-width: 343px; + margin-left: 12px; + margin-right: 12px; + margin-bottom: 24px; + border-radius: 16px; + box-shadow: none; + border: 1px solid #E2E4EA; + display: inline-block; + img { + border-top-left-radius: 16px; + border-top-right-radius: 16px; + } + a { + text-decoration: none; + color: @core-fleet-black-75; + } + [purpose='article-card-body'] { + padding: 32px; + [purpose='category-name'] { + text-transform: uppercase; + color: @core-fleet-black-50; + font-size: 12px; + font-weight: 700; + line-height: 20px; + text-decoration: none; + } + p { + color: #515774; + font-size: 14px; + font-weight: 400; + line-height: 150%; + } + [purpose='article-title'] { + + display: block; + text-decoration: none; + color: @core-fleet-black; + margin-bottom: 16px; + h5 { + font-weight: 800; + font-size: 16px; + line-height: 120%; + } + } + [purpose='article-details'] { + font-size: 12px; + line-height: 28px; + span { + color: @core-fleet-black-50; + } + p { + margin-bottom: 0px; + } + } + } + } @media (min-width: 1200px) { @@ -131,8 +291,6 @@ margin-right: 10px; } [purpose='articles'] { - margin-left: -25px; - margin-right: -25px; [purpose='article-card'] { flex: 1 1 350px; margin-left: 20px; @@ -155,8 +313,12 @@ @media (max-width: 991px) { - padding-left: 40px; - padding-right: 40px; + [purpose='page-container'] { + padding: 64px 32px; + } + [purpose='guide-card'] { + max-width: unset; + } [purpose='categories-and-search'] { padding-top: 40px; [purpose='categories'] { @@ -171,9 +333,6 @@ } } } - [purpose='category-title'] { - padding-top: 60px; - } [purpose='articles'] { [purpose='article-card'] { flex: 1 1 330px; @@ -182,27 +341,49 @@ margin-left: -10px; margin-right: -10px; } + [purpose='search'] { + .DocSearch-Button { + margin-left: 24px; + width: 200px; + } + } } @media (max-width: 767px) { + [purpose='categories-and-search'] { [purpose='search'] { width: 100%; } } - + [purpose='guides'] { + column-count: 2; + } [purpose='articles'] { + margin: 0 auto; + column-count: 2; [purpose='article-card'] { max-width: 100%; } } + [purpose='search'] { + .DocSearch-Button { + margin-top: 0px; + margin-left: 0px; + width: 100%; + } + } } @media (max-width: 576px) { - padding-left: 24px; - padding-right: 24px; + [purpose='page-container'] { + padding: 32px 24px; + } + [purpose='guides'] { + column-count: 1; + } [purpose='articles'] { margin-left: -10px; margin-right: -10px; @@ -211,5 +392,15 @@ max-width: 100%; } } + [purpose='guide-card'] { + width: 100%; + } + [purpose='search'] { + .DocSearch-Button { + margin-top: 0px; + margin-left: 0px; + width: 100%; + } + } } } diff --git a/website/assets/styles/pages/entrance/login.less b/website/assets/styles/pages/entrance/login.less index 42cadfb9be0e..46ffa53c306f 100644 --- a/website/assets/styles/pages/entrance/login.less +++ b/website/assets/styles/pages/entrance/login.less @@ -1,45 +1,74 @@ #login { - - padding-top: 80px; - h1 { - font-size: 28px; - line-height: 38px; + font-size: 32px; + line-height: 120%; } a { + line-height: 150%; color: @core-fleet-black-75; text-decoration: underline; text-underline-offset: 2px; - line-height: 150%; } - [purpose='customer-login-container'] { - max-width: 560px; + p { + line-height: 150%; } - [purpose='login-container'] { - max-width: 560px; - [purpose='customer-portal-form'] { - max-width: 560px; - } + [purpose='page-container'] { + padding: 64px 128px 64px 128px; + max-width: 1200px; } + [purpose='page-heading'] { - padding-left: 30px; - padding-right: 30px; - text-align: center; - margin-bottom: 40px; + text-align: left; + margin-bottom: 48px; } - [purpose='register-link'] { - margin-bottom: 8px; - a { - float: right; + + [purpose='quote-and-logos'] { + max-width: 310px; + } + [purpose='quote'] { + margin-top: 8px; + } + [purpose='quote-text'] { + font-size: 14px; + line-height: 150%; + font-style: italic; + } + [purpose='quote-author-info'] { + display: inline-flex; + padding: 4px 16px 4px 4px; + border-radius: 28px; + width: fit-content; + margin-top: 8px; + margin-bottom: 48px; + [purpose='job-title'] { color: @core-fleet-black-75; - text-decoration: underline; - font-size: 14px; + font-size: 12px; + font-weight: 400; + line-height: 18px; + margin-bottom: 0px; + } + [purpose='name'] { + font-size: 12px; + font-weight: 700; + line-height: 18px; + margin-bottom: 0px; + } + [purpose='profile-picture'] { + margin-right: 16px; + img { + height: 32px; + width: 32px; + } } } + [purpose='logos'] { + max-width: 310px; + } + [purpose='login-form'] { + width: 528px; + } [purpose='customer-portal-form'] { - max-width: 560px; border-radius: 16px; - margin-bottom: 40px; padding: 20px 32px 32px 32px; label { color: @core-fleet-black; @@ -50,8 +79,36 @@ height: 40px; border-radius: 6px; } - .card-body { - padding: 2em; + .selectbox { + + position: relative; + } + .selectbox::after { + content: url('/images/chevron-12x8@2x.png'); + right: 14px; + transform: scale(0.5); + top: 14px; + position: absolute; + pointer-events: none; + } + .selectbox select { + border-radius: 6px; + height: 48px; + appearance: none; + -webkit-appearance: none; + } + .small { + font-size: 12px; + } + } + + [purpose='register-link'] { + margin-bottom: 4px; + a { + float: right; + color: @core-fleet-black-75; + text-decoration: underline; + font-size: 14px; } } @@ -74,23 +131,64 @@ font-weight: 700; } } + @media (max-width: 1200px) { + [purpose='page-container'] { + padding: 64px; + } + } + @media (max-width: 991px) { + [purpose='signup-form'] { + max-width: 528px; + } + [purpose='quote-and-logos'] { + max-width: 528px; + margin-top: 48px; + } + [purpose='logos'] { + max-width: 528px; + } + [purpose='page-heading'] { + text-align: center; + margin-left: auto; + margin-right: auto; + margin-bottom: 48px; + max-width: 528px; + } + [purpose='page-container'] { + padding: 64px 32px; + } + + } @media (max-width: 768px) { - padding-top: 60px; [purpose='customer-portal-form'] { max-width: unset; } + [purpose='signup-form'] { + width: 100%; + } + [purpose='quote-and-logos'] { + width: 100%; + } + [purpose='logos'] { + width: 100%; + } } @media (max-width: 576px) { - padding-top: 40px; [purpose='page-heading'] { padding-left: 0px; padding-right: 0px; } + [purpose='page-container'] { + padding: 48px 24px; + } + [purpose='login-link'] { + margin-bottom: 12px; + } [purpose='customer-portal-form'] { .card-body { - padding: 1em; + padding: 1.5em 1em; } } } diff --git a/website/scripts/get-bug-and-pr-report.js b/website/scripts/get-bug-and-pr-report.js index 5894be6ecb41..06791bf10ab5 100644 --- a/website/scripts/get-bug-and-pr-report.js +++ b/website/scripts/get-bug-and-pr-report.js @@ -33,8 +33,10 @@ module.exports = { let daysSinceReleasedBugsWereOpened = []; let allBugsWithUnreleasedLabel = []; let allBugsWithReleasedLabel = []; + let allBugs32DaysOrOlder = []; let allBugsCreatedInPastWeek = []; let allBugsClosedInPastWeek = []; + let allBugsReportedByCustomersInPastWeek = []; let daysSincePullRequestsWereOpened = []; let daysSinceContributorPullRequestsWereOpened = []; let commitToMergeTimesInDays = []; @@ -44,7 +46,7 @@ module.exports = { let allNonPublicOpenPrs = []; let nonPublicPrsClosedInThePastThreeWeeks = []; - // Product group KPIS + // Endpoint operations let allBugsCreatedInPastWeekEndpointOps = []; @@ -103,8 +105,16 @@ module.exports = { let timeOpenInMS = Math.abs(todaysDate - issueOpenedOn); // Convert the miliseconds to days and add the value to the daysSinceBugsWereOpened array let timeOpenInDays = timeOpenInMS / ONE_DAY_IN_MILLISECONDS; + if (timeOpenInDays >= 32) { + allBugs32DaysOrOlder.push(issue); + } if (timeOpenInDays <= 7) { + // All bugs in past week allBugsCreatedInPastWeek.push(issue); + // Customer-reported bugs + if (issue.labels.some(label => label.name.indexOf('customer-') >= 0)) { + allBugsReportedByCustomersInPastWeek.push(issue); + } // Get Endpoint Ops KPIs if (issue.labels.some(label => label.name === '#g-endpoint-ops')) { allBugsCreatedInPastWeekEndpointOps.push(issue); @@ -132,6 +142,7 @@ module.exports = { } } } + daysSinceBugsWereOpened.push(timeOpenInDays); // Send to released or unreleased bugs array if (issue.labels.some(label => label.name === '~unreleased bug')) { @@ -316,8 +327,8 @@ module.exports = { // async()=>{ - // Fetch confidential and classified PRs (current open, and recent closed) - for (let repoName of ['classified', 'confidential']) { + // Fetch confidential PRs (current open, and recent closed) + for (let repoName of ['confidential']) { // [?] https://docs.github.com/en/free-pro-team@latest/rest/pulls/pulls#list-pull-requests let openPrs = await sails.helpers.http.get(`https://api.github.com/repos/fleetdm/${encodeURIComponent(repoName)}/pulls`, { state: 'open', @@ -380,25 +391,12 @@ module.exports = { // NOTE: If order of the KPI sheets columns changes, the order values are pushed into this array needs to change, as well. kpiResults.push( averageDaysContributorPullRequestsAreOpenFor, - daysSinceContributorPullRequestsWereOpened.length, - averageDaysPullRequestsAreOpenFor, - daysSincePullRequestsWereOpened.length, + allBugs32DaysOrOlder.length, + allBugsReportedByCustomersInPastWeek.length, averageNumberOfDaysReleasedBugsAreOpenFor, averageNumberOfDaysUnreleasedBugsAreOpenFor, - allBugsClosedInPastWeek.length, - averageNumberOfDaysBugsAreOpenFor, allBugsCreatedInPastWeek.length, - allBugsCreatedInPastWeekEndpointOps.length, - allBugsCreatedInPastWeekEndpointOpsCustomerImpacting.length, - allBugsCreatedInPastWeekEndpointOpsReleased.length, - allBugsCreatedInPastWeekEndpointOpsUnreleased.length, - allBugsCreatedInPastWeekMobileDeviceManagement.length, - allBugsCreatedInPastWeekMobileDeviceManagementCustomerImpacting.length, - allBugsCreatedInPastWeekMobileDeviceManagementReleased.length, - allBugsCreatedInPastWeekMobileDeviceManagementUnreleased.length, - daysSinceBugsWereOpened.length, - allBugsWithReleasedLabel.length, - allBugsWithUnreleasedLabel.length); + allBugsClosedInPastWeek.length,); // Log the results sails.log(` @@ -407,17 +405,19 @@ module.exports = { --------------------------- ${kpiResults.join(',')} - Note: Copy the values above, then in Google sheets paste them into a cell and select "Split text to columns" to paste the values into separate cells. + Note: Copy the values above, then paste into Google KPI sheet and select "Split text to columns" to split the values into separate columns. Pull requests: --------------------------- Average open time (no bots, no handbook, no ceo): ${averageDaysContributorPullRequestsAreOpenFor} days. + Number of open pull requests in the fleetdm/fleet Github repo (no bots, no handbook, no ceo): ${daysSinceContributorPullRequestsWereOpened.length} Average open time (all PRs): ${averageDaysPullRequestsAreOpenFor} days. + Number of open pull requests in the fleetdm/fleet Github repo: ${daysSincePullRequestsWereOpened.length} - Bugs (part 1): + Bugs: --------------------------- Average open time (released bugs): ${averageNumberOfDaysReleasedBugsAreOpenFor} days. @@ -429,6 +429,12 @@ module.exports = { Number of issues with the "bug" label opened in the past week: ${allBugsCreatedInPastWeek.length} + Number of open issues with the "bug" label in fleetdm/fleet: ${daysSinceBugsWereOpened.length} + + Number of open issues with the "~released bug" label in fleetdm/fleet: ${allBugsWithReleasedLabel.length} + + Number of open issues with the "~unreleased bug" label in fleetdm/fleet: ${allBugsWithUnreleasedLabel.length} + Endpoint Operations: --------------------------- Number of issues with the "#g-endpoint-ops" and "bug" labels opened in the past week: ${allBugsCreatedInPastWeekEndpointOps.length} @@ -449,17 +455,10 @@ module.exports = { Number of issues with the "#g-mdm", "bug", and "~unreleased bug" labels opened in the past week: ${allBugsCreatedInPastWeekMobileDeviceManagementUnreleased.length} - Bugs (part 2): - --------------------------- - Number of open issues with the "bug" label in fleetdm/fleet: ${daysSinceBugsWereOpened.length} - - Number of open issues with the "~released bug" label in fleetdm/fleet: ${allBugsWithReleasedLabel.length} - - Number of open issues with the "~unreleased bug" label in fleetdm/fleet: ${allBugsWithUnreleasedLabel.length} - Pull requests requiring CEO review --------------------------------------- Number of open ~ceo pull requests in the fleetdm Github org: ${ceoDependentOpenPrs.length} + Average open time (~ceo PRs): ${Math.round(ceoDependentPrOpenTime*100)/100} days. `); diff --git a/website/scripts/send-aggregated-metrics-to-datadog.js b/website/scripts/send-aggregated-metrics-to-datadog.js index 959c3625b532..f13826b866ba 100644 --- a/website/scripts/send-aggregated-metrics-to-datadog.js +++ b/website/scripts/send-aggregated-metrics-to-datadog.js @@ -406,6 +406,69 @@ module.exports = { }], tags: [`enabled:false`], }); + // aiFeaturesDisabled + let numberOfInstancesWithAiFeaturesDisabled = _.where(latestStatisticsReportedByReleasedFleetVersions, {aiFeaturesDisabled: true}).length; + let numberOfInstancesWithAiFeaturesEnabled = numberOfInstancesToReport - numberOfInstancesWithAiFeaturesDisabled; + metricsToReport.push({ + metric: 'usage_statistics.ai_features', + type: 3, + points: [{ + timestamp: timestampForTheseMetrics, + value: numberOfInstancesWithAiFeaturesEnabled + }], + tags: [`enabled:true`], + }); + metricsToReport.push({ + metric: 'usage_statistics.ai_features', + type: 3, + points: [{ + timestamp: timestampForTheseMetrics, + value: numberOfInstancesWithAiFeaturesDisabled + }], + tags: [`enabled:false`], + }); + // maintenanceWindowsEnabled + let numberOfInstancesWithMaintenanceWindowsEnabled = _.where(latestStatisticsReportedByReleasedFleetVersions, {maintenanceWindowsEnabled: true}).length; + let numberOfInstancesWithMaintenanceWindowsDisabled = numberOfInstancesToReport - numberOfInstancesWithMaintenanceWindowsEnabled; + metricsToReport.push({ + metric: 'usage_statistics.maintenance_windows', + type: 3, + points: [{ + timestamp: timestampForTheseMetrics, + value: numberOfInstancesWithMaintenanceWindowsEnabled + }], + tags: [`enabled:true`], + }); + metricsToReport.push({ + metric: 'usage_statistics.maintenance_windows', + type: 3, + points: [{ + timestamp: timestampForTheseMetrics, + value: numberOfInstancesWithMaintenanceWindowsDisabled + }], + tags: [`enabled:false`], + }); + // maintenanceWindowsConfigured + let numberOfInstancesWithMaintenanceWindowsConfigured = _.where(latestStatisticsReportedByReleasedFleetVersions, {maintenanceWindowsEnabled: true}).length; + let numberOfInstancesWithoutMaintenanceWindowsConfigured = numberOfInstancesToReport - numberOfInstancesWithMaintenanceWindowsConfigured; + metricsToReport.push({ + metric: 'usage_statistics.maintenance_windows_configured', + type: 3, + points: [{ + timestamp: timestampForTheseMetrics, + value: numberOfInstancesWithMaintenanceWindowsConfigured + }], + tags: [`configured:true`], + }); + metricsToReport.push({ + metric: 'usage_statistics.maintenance_windows_configured', + type: 3, + points: [{ + timestamp: timestampForTheseMetrics, + value: numberOfInstancesWithoutMaintenanceWindowsConfigured + }], + tags: [`configured:false`], + }); // Create two metrics to track total number of hosts reported in the last week. let totalNumberOfHostsReportedByPremiumInstancesInTheLastWeek = _.sum(_.pluck(_.filter(latestStatisticsReportedByReleasedFleetVersions, {licenseTier: 'premium'}), 'numHostsEnrolled')); diff --git a/website/views/pages/articles/articles.ejs b/website/views/pages/articles/articles.ejs index 0f24be22d7ce..4b21814af392 100644 --- a/website/views/pages/articles/articles.ejs +++ b/website/views/pages/articles/articles.ejs @@ -1,51 +1,88 @@ <div id="articles" v-cloak> - <div style="max-width: 1200px;" class="container-fluid p-0"> - <div class="mx-auto"> - <div purpose="categories-and-search" class="d-flex flex-lg-row flex-column justify-content-lg-between justify-content-md-center justify-content-start" v-if="isArticlesLandingPage"> - <div purpose="categories" class="d-flex flex-lg-row flex-column justify-content-start"> - <div purpose="category-filter" :class="[ filter === 'all' ? 'selected' : '']" @click="filterBy('all')">All</div> - <div purpose="category-filter" :class="[ filter === 'engineering' ? 'selected' : '']" @click="filterBy('engineering')">Engineering</div> - <div purpose="category-filter" :class="[ filter === 'security' ? 'selected' : '']" @click="filterBy('security')">Security</div> - <div purpose="category-filter" :class="[ filter === 'announcements' ? 'selected' : '']" @click="filterBy('announcements')">Announcements</div> - <div purpose="category-filter" :class="[ filter === 'guides' ? 'selected' : '']" @click="filterBy('guides')">Guides</div> - <div purpose="category-filter" :class="[ filter === 'success stories' ? 'selected' : '']" @click="filterBy('success stories')">Success stories</div> - <div purpose="category-filter" :class="[ filter === 'podcasts' ? 'selected' : '']" @click="filterBy('podcasts')">Podcasts</div> - <div purpose="category-filter" :class="[ filter === 'releases' ? 'selected' : '']" @click="filterBy('releases')">Releases</div> - </div> + <div style="max-width: 1200px;" class="container-fluid mx-auto" purpose="page-container"> + <div v-if="category !== 'guides'"> + <div purpose="categories-and-search" class="d-flex flex-lg-row flex-column justify-content-lg-between justify-content-md-center justify-content-start" v-if="isArticlesLandingPage"> + <div purpose="categories" class="d-flex flex-lg-row flex-column justify-content-start"> + <div purpose="category-filter" :class="[ filter === 'all' ? 'selected' : '']" @click="filterBy('all')">All</div> + <div purpose="category-filter" :class="[ filter === 'engineering' ? 'selected' : '']" @click="filterBy('engineering')">Engineering</div> + <div purpose="category-filter" :class="[ filter === 'security' ? 'selected' : '']" @click="filterBy('security')">Security</div> + <div purpose="category-filter" :class="[ filter === 'announcements' ? 'selected' : '']" @click="filterBy('announcements')">Announcements</div> + <div purpose="category-filter" :class="[ filter === 'guides' ? 'selected' : '']" @click="filterBy('guides')">Guides</div> + <div purpose="category-filter" :class="[ filter === 'success stories' ? 'selected' : '']" @click="filterBy('success stories')">Success stories</div> + <div purpose="category-filter" :class="[ filter === 'podcasts' ? 'selected' : '']" @click="filterBy('podcasts')">Podcasts</div> + <div purpose="category-filter" :class="[ filter === 'releases' ? 'selected' : '']" @click="filterBy('releases')">Releases</div> </div> - <div purpose="category-title" v-else> - <h1>{{articleCategory}}</h1> - <div class="d-flex flex-sm-row flex-column justify-content-between"> - <p>{{categoryDescription}}</p> - <a purpose="rss-button" class="px-0 px-sm-2 pt-sm-1" @click="clickCopyRssLink(category)" target="_blank"><span>Subscribe</span></a> - </div> + </div> + <div purpose="category-title" v-else> + <h1>{{articleCategory}}</h1> + <div class="d-flex flex-sm-row flex-column justify-content-between"> + + <p>{{categoryDescription}}</p> + <a purpose="rss-button" class="px-0 px-sm-2 pt-sm-1" @click="clickCopyRssLink(category)" target="_blank"><span>Subscribe</span></a> </div> - <div purpose="articles" class="card-deck d-flex justify-content-center" v-if="selectedArticles.length > 0"> - <div purpose="article-card" class="card" v-for="article in selectedArticles"> - <a purpose="article-card-link" :href="article.url"> - <img style="width: 100%; height: auto;" :src="[article.meta.articleImageUrl ? article.meta.articleImageUrl : '/images/blog-fleet-logo-white-background-800x450@2x.png']" alt="Article hero image"> - </a> - <div purpose="article-card-body" class="card-body d-flex flex-column"> - <p purpose="category-name" class="pb-2 mb-0">{{article.meta.category}}</p> - <a purpose="article-title" :href="article.url"><h5>{{article.meta.articleTitle}}</h5></a> - <div purpose="article-details" class="d-flex mt-auto flex-row align-items-center"> - <img alt="The author's GitHub profile picture" style="height: 32px; width: 32px; border-radius: 100%;" :src="'https://github.com/'+article.meta.authorGitHubUsername+'.png?size=200'"> - <p class="pl-2 font-weight-bold">{{article.meta.authorFullName}}</p> - </div> + </div> + <div purpose="articles" class="card-deck d-flex justify-content-center"> + <div purpose="article-card" class="card" v-for="article in selectedArticles"> + <a purpose="article-card-link" :href="article.url"> + <img style="width: 100%; height: auto;" :src="[article.meta.articleImageUrl ? article.meta.articleImageUrl : '/images/blog-fleet-logo-white-background-800x450@2x.png']" alt="Article hero image"> + </a> + <div purpose="article-card-body" class="card-body d-flex flex-column"> + <p purpose="category-name" class="pb-2 mb-0">{{article.meta.category}}</p> + <a purpose="article-title" :href="article.url"><h5>{{article.meta.articleTitle}}</h5></a> + <div purpose="article-details" class="d-flex mt-auto flex-row align-items-center"> + <img alt="The author's GitHub profile picture" style="height: 32px; width: 32px; border-radius: 100%;" :src="'https://github.com/'+article.meta.authorGitHubUsername+'.png?size=200'"> + <p class="pl-2 font-weight-bold">{{article.meta.authorFullName}}</p> </div> </div> - <div purpose="article-card" class="card invisible mb-0 d-none d-sm-flex"> - </div> - <div purpose="article-card" class="card invisible mb-0 d-none d-sm-flex"> + </div> + <div purpose="article-card" class="card invisible mb-0 d-none d-sm-flex"> + </div> + <div purpose="article-card" class="card invisible mb-0 d-none d-sm-flex"> + </div> + <div purpose="article-card" class="card invisible mb-0 d-none d-sm-flex"> + </div> + </div> + </div> + <div purpose="guides-category-page" v-else> + <div purpose="category-title"> + <div class="d-flex flex-md-row flex-column justify-content-between align-items-md-center"> + <div class="d-flex flex-column justify-content-between"> + <h1>Guides</h1> + <p>Learn more about how to use Fleet to accomplish your goals.</p> </div> - <div purpose="article-card" class="card invisible mb-0 d-none d-sm-flex"> + <div purpose="search" id="docsearch-query"> + <div purpose="disabled-search" class="d-flex w-100"> + <div class="input-group"> + <div class="input-group-prepend"> + <span class="input-group-text border-0 bg-transparent pl-3" > + <img style="height: 16px; width: 16px;" class="search" alt="search" src="/images/icon-search-16x16@2x.png"> + </span> + </div> + <div class="form-control border-0 "> + <input class="docsearch-input pr-1" + placeholder="Search" aria-label="Search" + /> + </div> + </div> + </div> </div> </div> - <div v-else> - <h2 class="text-muted mx-auto">No articles to display</h2> + </div> + <div purpose="guides" class="card-columns"> + <div purpose="guide-card" class="card" v-for="article in selectedArticles"> + <a :href="article.url"> + <div purpose="article-card-body" class="card-body d-flex flex-column"> + <div purpose="article-title"><h5>{{article.meta.articleTitle}}</h5></div> + <p class="small" v-if="article.meta.description">{{article.meta.description}}</p> + <div purpose="article-details" class="d-flex mt-auto flex-row align-items-center"> + <js-timestamp :at="article.meta.publishedOn" format="timeago" always-show-year="true"></js-timestamp> + </div> + </div> + </a> </div> </div> </div> + </div> </div> <%- /* Expose server-rendered data as window.SAILS_LOCALS :: */ exposeLocalsToBrowser() %> diff --git a/website/views/pages/contact.ejs b/website/views/pages/contact.ejs index 52bb23bf6f1f..d9045a69e4d8 100644 --- a/website/views/pages/contact.ejs +++ b/website/views/pages/contact.ejs @@ -145,17 +145,17 @@ </div> </div> <div purpose="quote" v-else-if="primaryBuyingSituation === 'eo-it'"> - <div purpose="logo" class="mb-4"><img height="32" alt="Deputy logo" src="/images/logo-deputy-118x28@2x.png"></div> + <div purpose="logo" class="mb-4"><img height="32" alt="Deputy logo" src="/images/social-proof-logo-stripe-67x32@2x.png"></div> <p purpose="quote-text"> - When we look at vendors, we look for ones that are very receptive to feedback, where you’re just part of the family, I guess. Fleet’s really good at that. + Mad props to how easy making a deploy pkg of the agent was. I wish everyone made stuff that easy. </p> <div purpose="quote-author-info" class="d-flex flex-row align-items-center"> <div purpose="profile-picture"> - <img alt="Harrison Ravazzolo" src="/images/testimonial-author-harrison-ravazzolo-48x48@2x.png"> + <img alt="Wes Whetstone" src="/images/testimonial-author-wes-whetstone-48x48@2x.png"> </div> <div class="d-flex flex-column align-self-top"> - <p purpose="name" class="font-weight-bold m-0">Harrison Ravazzolo</p> - <p purpose="job-title" class="m-0">Lead platform and identity engineer</p> + <p purpose="name" class="font-weight-bold m-0">Wes Whetstone</p> + <p purpose="job-title" class="m-0">Staff CPE</p> </div> </div> </div> diff --git a/website/views/pages/endpoint-ops.ejs b/website/views/pages/endpoint-ops.ejs index 18043c869fd8..22168e47a385 100644 --- a/website/views/pages/endpoint-ops.ejs +++ b/website/views/pages/endpoint-ops.ejs @@ -3,22 +3,22 @@ <div purpose="page-content" class="mx-auto"> <div purpose="hero"> <div purpose="page-headline"> - <h4>Endpoint operations <%= ['eo-security', 'vm'].includes(primaryBuyingSituation) ? 'for security' : ['eo-it', 'mdm'].includes(primaryBuyingSituation) ? 'for IT' : '' %></h4> - <h1><%= primaryBuyingSituation==='eo-security'? 'Instrument your endpoints' : 'Understand your computers'%></h1> + <h4>Endpoint operations <%= ['eo-security', 'vm'].includes(pagePersonalization) ? 'for security' : ['eo-it', 'mdm'].includes(pagePersonalization) ? 'for IT' : '' %></h4> + <h1><%= pagePersonalization==='eo-security'? 'Instrument your endpoints' : 'Understand your computers'%></h1> </div> <div purpose="hero-content" class="d-flex flex-md-row flex-column align-items-center justify-content-between"> <div purpose="hero-image"> <img alt="A device verifying compliance for every endpoint" src="/images/endpoint-operations-hero-image-380x380@2x.png"> </div> <div purpose="hero-text"> - <% if(['eo-it', 'mdm'].includes(primaryBuyingSituation)) { %> + <% if(['eo-it', 'mdm'].includes(pagePersonalization)) { %> <strong>Automate anything</strong> <p>Remotely run scripts and prompts to complete tasks on every kind of computer, including Linux.</p> <strong>Pulse check anything</strong> <p>Use a live connection to every endpoint to simplify audit, compliance, and reporting from workstations to data centers.</p> <strong>Ship data to any platform</strong> <p>Ship logs to any platform like Splunk, Snowflake, or <a href="/docs/using-fleet/log-destinations">any streaming infrastructure</a> like AWS Kinesis and Apache Kafka.</p> - <% } else if(['eo-security', 'vm'].includes(primaryBuyingSituation)) { %> + <% } else if(['eo-security', 'vm'].includes(pagePersonalization)) { %> <strong>Osquery on easy mode</strong> <p>Build the agent in "read-only" mode or enable remote scripting to automatically mitigate misconfigurations and vulnerabilities.</p> <strong>Pulse check anything</strong> @@ -41,7 +41,7 @@ </div> </div> <div purpose="testimonials" class="d-flex flex-md-row flex-column align-items-center justify-content-between"> - <% if (['eo-security'].includes(primaryBuyingSituation)) { %> + <% if (['eo-security'].includes(pagePersonalization)) { %> <div purpose="testimonial-quote"> <div purpose="quote"> <img alt="an opening quotation mark" style="width:20px; margin-bottom: 16px;" src="/images/icon-quote-21x17@2x.png"> @@ -57,7 +57,7 @@ </div> </a> </div> - <% } else if (['vm'].includes(primaryBuyingSituation)) { %> + <% } else if (['vm'].includes(pagePersonalization)) { %> <div purpose="testimonial-quote"> <div purpose="quote"> <img alt="an opening quotation mark" style="width:20px; margin-bottom: 16px;" src="/images/icon-quote-21x17@2x.png"> @@ -91,15 +91,15 @@ </div> <% } %> <div purpose="testimonial-videos" class="d-flex"> - <%if(['eo-security'].includes(primaryBuyingSituation)) {%> + <%if(['eo-security'].includes(pagePersonalization)) {%> <div purpose="testimonial-video" class="charles-zaffery mx-auto" @click="clickOpenVideoModal('charles-zaffery')"> <span><img src="/images/icon-play-video-8x9@2x.png" alt="Play">Play video</span> </div> - <%} else if(['vm'].includes(primaryBuyingSituation)){%> + <%} else if(['vm'].includes(pagePersonalization)){%> <div purpose="testimonial-video" class="austin-anderson mx-auto" @click="clickOpenVideoModal('austin-anderson')"> <span><img src="/images/icon-play-video-8x9@2x.png" alt="Play">Play video</span> </div> - <%} else if(['eo-it', 'mdm'].includes(primaryBuyingSituation)) {%> + <%} else if(['eo-it', 'mdm'].includes(pagePersonalization)) {%> <div purpose="testimonial-video" class="nick-fohs mx-auto" @click="clickOpenVideoModal('nick-fohs')"> <span><img src="/images/icon-play-video-8x9@2x.png" alt="Play">Play video</span> </div> @@ -116,7 +116,7 @@ <logo-carousel></logo-carousel> - <% if(!primaryBuyingSituation || ['mdm', 'eo-it'].includes(primaryBuyingSituation)){%> + <% if(!pagePersonalization || ['mdm', 'eo-it'].includes(pagePersonalization)){%> <div purpose="feature" class="d-flex flex-md-row flex-column-reverse justify-content-between mx-auto align-items-center"> <div purpose="feature-text" class="d-flex flex-column"> <h3>Automate anything</h3> @@ -225,7 +225,7 @@ </div> <div purpose="feature-text" class="d-flex flex-column"> <h3>Osquery on easy mode</h3> - <p>Accelerate deployment and get more out of osquery. You don’t need to be an osquery expert to get the answers you need from your <%= ['vm', 'eo-security'].includes(primaryBuyingSituation) ? 'endpoints' : 'devices' %>.</p> + <p>Accelerate deployment and get more out of osquery. You don’t need to be an osquery expert to get the answers you need from your <%= ['vm', 'eo-security'].includes(pagePersonalization) ? 'endpoints' : 'devices' %>.</p> <div purpose="checklist" class="flex-column d-flex"> <p>Remotely disable/enable agent features, choose plugins, and keep osquery up to date.</p> <p>Import community queries from other security teams at top brands like Palantir and Fastly.</p> @@ -235,7 +235,7 @@ </div> </div> - <% if(!primaryBuyingSituation || ['vm', 'eo-security'].includes(primaryBuyingSituation)) {%> + <% if(!pagePersonalization || ['vm', 'eo-security'].includes(pagePersonalization)) {%> <div purpose="feature-headline" class="mr-auto"> <h3>Open security tooling</h3> <p>Consolidate your security tooling on top of open data standards like YAML, SQL, and JSON.</p> @@ -289,7 +289,7 @@ <div purpose="tweets-container" class="container-fluid px-md-0 pb-0 d-flex flex-column justify-content-center"> <div purpose="section-heading" class="mx-auto text-center"> <h4>Who else uses Fleet?</h4> - <h3>Empowering <%= ['mdm'].includes(primaryBuyingSituation) ? 'IT and corporate engineering' : ['eo-it'].includes(primaryBuyingSituation) ? 'IT and client platform' : ['eo-security'].includes(primaryBuyingSituation) ? 'security and platform' : ['vm'].includes(primaryBuyingSituation) ? 'security and IT' : 'IT and security' %> teams, globally</h3> + <h3>Empowering <%= ['mdm'].includes(pagePersonalization) ? 'IT and corporate engineering' : ['eo-it'].includes(pagePersonalization) ? 'IT and client platform' : ['eo-security'].includes(pagePersonalization) ? 'security and platform' : ['vm'].includes(pagePersonalization) ? 'security and IT' : 'IT and security' %> teams, globally</h3> </div> </div> @@ -298,7 +298,7 @@ <div purpose="section-heading" class="text-center"> <h4>Endpoint operations</h4> - <h3><%= primaryBuyingSituation==='eo-security'? 'Instrument your endpoints' : 'Understand your computers'%></h3> + <h3><%= pagePersonalization==='eo-security'? 'Instrument your endpoints' : 'Understand your computers'%></h3> <div purpose="button-row" style="margin-top: 32px;" class="d-flex flex-md-row flex-column justify-content-center align-items-center mx-auto"> <a purpose="cta-button" href="/register">Start now</a> <animated-arrow-button href="/contact">Talk to us</animated-arrow-button> diff --git a/website/views/pages/entrance/login.ejs b/website/views/pages/entrance/login.ejs index 7ed6d7b471ed..4d55f0a28106 100644 --- a/website/views/pages/entrance/login.ejs +++ b/website/views/pages/entrance/login.ejs @@ -1,38 +1,107 @@ <div id="login" v-cloak> - <div :purpose="[showCustomerLogin ? 'customer-login-container' : 'login-container']" class="container-fluid pb-5 px-lg-0 px-3"> - <div purpose="page-heading" v-if="showCustomerLogin"> + <div purpose="page-container" class="container"> + <div purpose="page-heading"> <h1>Welcome to Fleet</h1> <p class="mb-0">We just need a few details in order to get started.</p> </div> - <div purpose="page-heading" v-else> - <h1>Welcome to Fleet</h1> - <p class="mb-0">Sign in to your Fleet account.</p> - </div> - <div purpose="customer-portal-form" class="card card-body mb-5"> - <div purpose="register-link" v-if="showCustomerLogin"> - <a :href="registerSlug">Create an account</a> - </div> - <ajax-form class="customers-login" action="login" :syncing.sync="syncing" :cloud-error.sync="cloudError" :form-data="formData" :form-rules="formRules" :form-errors.sync="formErrors" @submitted="submittedForm()"> - <div class="form-group"> - <label for="email">Email</label> - <input type="email" class="form-control" :class="[formErrors.emailAddress ? 'is-invalid' : '']" v-model.trim="formData.emailAddress" autocomplete="email" focus-first> - <div class="invalid-feedback" v-if="formErrors.emailAddress">Please provide a valid email address.</div> + <div purpose="form-container" class="d-flex flex-lg-row flex-column justify-content-between align-items-start"> + <div purpose="login-form" class="mx-auto mx-lg-0"> + <div purpose="customer-portal-form" class="card card-body mb-5"> + <div purpose="register-link" v-if="showCustomerLogin"> + <a :href="registerSlug">Create an account</a> + </div> + <ajax-form class="customers-login" action="login" :syncing.sync="syncing" :cloud-error.sync="cloudError" :form-data="formData" :form-rules="formRules" :form-errors.sync="formErrors" @submitted="submittedForm()"> + <div class="form-group"> + <label for="email">Email</label> + <input type="email" class="form-control" :class="[formErrors.emailAddress ? 'is-invalid' : '']" v-model.trim="formData.emailAddress" autocomplete="email" focus-first> + <div class="invalid-feedback" v-if="formErrors.emailAddress">Please provide a valid email address.</div> + </div> + <div class="form-group"> + <label for="password">Password</label> + <input type="password" class="form-control" :class="[formErrors.password ? 'is-invalid' : '']" v-model.trim="formData.password" autocomplete="current-password"> + <div class="invalid-feedback" v-if="formErrors.password">Please enter your password.</div> + </div> + <cloud-error v-if="cloudError === 'noUser'">The email address provided doesn't match an existing account. Create an account <a href="/customers/register">here</a>.</cloud-error> + <cloud-error v-else-if="cloudError === 'badCombo'">Something’s not quite right with your email or password.</cloud-error> + <cloud-error v-else-if="cloudError"></cloud-error> + <div class="pb-3"> + <ajax-button :syncing="syncing" spinner="true" purpose="submit-button" class="btn-primary mt-4 btn-lg btn-block">Sign in</ajax-button> + </div> + </ajax-form> + <span class="text-center small"><a href="/customers/forgot-password">Forgot your password?</a></span> </div> - <div class="form-group"> - <label for="password">Password</label> - <input type="password" class="form-control" :class="[formErrors.password ? 'is-invalid' : '']" v-model.trim="formData.password" autocomplete="current-password"> - <div class="invalid-feedback" v-if="formErrors.password">Please enter your password.</div> - </div> - <cloud-error v-if="cloudError === 'noUser'">The email address provided doesn't match an existing account. Create an account <a href="/customers/register">here</a>.</cloud-error> - <cloud-error v-else-if="cloudError === 'badCombo'">Something’s not quite right with your email or password.</cloud-error> - <cloud-error v-else-if="cloudError"></cloud-error> - <div class="pb-3"> - <ajax-button :syncing="syncing" spinner="true" purpose="submit-button" class="btn-primary mt-4 btn-lg btn-block">Sign in</ajax-button> + </div> + <div purpose="quote-and-logos" class="mx-auto mx-lg-0"> + <% if(typeof primaryBuyingSituation === 'undefined' || ['mdm'].includes(primaryBuyingSituation)) { %> + <div purpose="quote"> + <img alt="an opening quotation mark" style="width:20px; margin-bottom: 16px;" src="/images/icon-quote-21x17@2x.png"> + <p purpose="quote-text"> + Exciting. This is a team that listens to feedback. + </p> + <div purpose="quote-author-info" class="d-flex flex-row align-items-center"> + <div purpose="profile-picture"> + <img alt="Erik Gomez" src="/images/testimonial-author-erik-gomez-48x48@2x.png"> + </div> + <div class="d-flex flex-column align-self-top"> + <p purpose="name" class="font-weight-bold m-0">Erik Gomez</p> + <p purpose="job-title" class="m-0">Staff Client Platform Engineer</p> + </div> + </div> + </div> + <% } else if (['eo-it'].includes(primaryBuyingSituation)) { %> + <div purpose="quote"> + <img alt="an opening quotation mark" style="width:20px; margin-bottom: 16px;" src="/images/icon-quote-21x17@2x.png"> + <p purpose="quote-text"> + Mad props to how easy making a deploy pkg of the agent was. I wish everyone made stuff that easy. + </p> + <div purpose="quote-author-info" class="d-flex flex-row align-items-center"> + <div purpose="profile-picture"> + <img alt="Wes Whetstone" src="/images/testimonial-author-wes-whetstone-48x48@2x.png"> + </div> + <div class="d-flex flex-column align-self-top"> + <p purpose="name" class="font-weight-bold m-0">Wes Whetstone</p> + <p purpose="job-title" class="m-0">Staff CPE</p> + </div> + </div> + </div> + <% } else if (['eo-security'].includes(primaryBuyingSituation)) { %> + <div purpose="quote"> + <img alt="an opening quotation mark" style="width:20px; margin-bottom: 16px;" src="/images/icon-quote-21x17@2x.png"> + <p purpose="quote-text"> + Something I really appreciate about working with you guys is that it doesn't feel like I'm talking to a vendor. It actually feels like I'm talking to my team, and I really appreciate it. + </p> + <div purpose="quote-author-info" class="d-flex flex-row align-items-center"> + <div purpose="profile-picture"> + <img alt="Chandra Majumdar" src="/images/testimonial-author-chandra-majumdar-48x48@2x.png"> + </div> + <div class="d-flex flex-column align-self-top"> + <p purpose="name" class="font-weight-bold m-0">Chandra Majumdar</p> + <p purpose="job-title" class="m-0">Partner - Cyber and Strategic Risk</p> + </div> + </div> + </div> + <% } else if (['vm'].includes(primaryBuyingSituation)) { %> + <div purpose="quote"> + <img alt="an opening quotation mark" style="width:20px; margin-bottom: 16px;" src="/images/icon-quote-21x17@2x.png"> + <p purpose="quote-text"> + The visibility down into the assets covered by the agent is phenomenal. Fleet has become the central source for a lot of things. + </p> + <div purpose="quote-author-info" class="d-flex flex-row align-items-center"> + <div purpose="profile-picture"> + <img alt="Andre Shields" src="/images/testimonial-author-andre-shields-48x48@2x.png"> + </div> + <div class="d-flex flex-column align-self-top"> + <p purpose="name" class="font-weight-bold m-0">Andre Shields</p> + <p purpose="job-title" class="m-0">Staff Cybersecurity Engineer, Vulnerability Management</p> + </div> + </div> + </div> + <% } %> + <div purpose="logos" class="flex-column flex-wrap align-items-center w-100"> + <logo-carousel></logo-carousel> </div> - </ajax-form> - <span class="text-center small"><a href="/customers/forgot-password">Forgot your password?</a></span> + </div> </div> - <logo-carousel></logo-carousel> </div> </div> <%- /* Expose locals as `window.SAILS_LOCALS` :: */ exposeLocalsToBrowser() %> diff --git a/website/views/pages/entrance/signup.ejs b/website/views/pages/entrance/signup.ejs index 437deb454ad4..40b71c7c1da4 100644 --- a/website/views/pages/entrance/signup.ejs +++ b/website/views/pages/entrance/signup.ejs @@ -80,15 +80,15 @@ <div purpose="quote"> <img alt="an opening quotation mark" style="width:20px; margin-bottom: 16px;" src="/images/icon-quote-21x17@2x.png"> <p purpose="quote-text"> - When we look at vendors, we look for ones that are very receptive to feedback, where you’re just part of the family, I guess. Fleet’s really good at that. + Mad props to how easy making a deploy pkg of the agent was. I wish everyone made stuff that easy. </p> <div purpose="quote-author-info" class="d-flex flex-row align-items-center"> <div purpose="profile-picture"> - <img alt="Harrison Ravazzolo" src="/images/testimonial-author-harrison-ravazzolo-48x48@2x.png"> + <img alt="Wes Whetstone" src="/images/testimonial-author-wes-whetstone-48x48@2x.png"> </div> <div class="d-flex flex-column align-self-top"> - <p purpose="name" class="font-weight-bold m-0">Harrison Ravazzolo</p> - <p purpose="job-title" class="m-0">Lead platform and identity engineer</p> + <p purpose="name" class="font-weight-bold m-0">Wes Whetstone</p> + <p purpose="job-title" class="m-0">Staff CPE</p> </div> </div> </div> diff --git a/website/views/pages/homepage.ejs b/website/views/pages/homepage.ejs index b95a9d4930ba..d21fd43c3904 100644 --- a/website/views/pages/homepage.ejs +++ b/website/views/pages/homepage.ejs @@ -62,7 +62,7 @@ <strong>Osquery on easy mode</strong> <p>Use "read-only" mode or enable remote scripting to automate anything on every operating system, including Linux.</p> <div> - <a purpose="category-button" class="text-nowrap btn btn-primary" href="/endpoint-ops?utm_content=eo-it">Start with IT engineering</a> + <a purpose="category-button" class="text-nowrap btn btn-primary" href="/endpoint-ops?pageMode=it">Start with IT engineering</a> </div> </div> </div> @@ -80,7 +80,7 @@ <strong>Ship data to any platform</strong> <p>Ship logs to any platform like Splunk, Snowflake, or <a href="/docs/using-fleet/log-destinations">any streaming infrastructure</a> like AWS Kinesis and Apache Kafka.</p> <div> - <a purpose="category-button" class="text-nowrap btn btn-primary" href="/endpoint-ops?utm_content=eo-security">Start with security engineering</a> + <a purpose="category-button" class="text-nowrap btn btn-primary" href="/endpoint-ops?pageMode=security">Start with security engineering</a> </div> </div> </div> @@ -108,7 +108,7 @@ <h2><%= primaryBuyingSituation==='eo-security'? 'Instrument your endpoints' : 'Understand your computers'%></h2> <p>A <%= primaryBuyingSituation==='eo-security'? 'lightweight' : 'quick-fast' %> way to gather <%= primaryBuyingSituation==='vm'? 'patch level and custom reports across all your computing devices, even in OT and production environments' : primaryBuyingSituation==='eo-security'? 'deep context and custom telemetry across all your endpoints, even servers' : primaryBuyingSituation==='mdm'||primaryBuyingSituation==='eo-it'? 'compliance and inventory data across all your devices' : 'device data across all your computers' %>. Pulse check or automate anything on any platform.</p> <div> - <animated-arrow-button href="/endpoint-ops<%= primaryBuyingSituation==='eo-security' ? '?utm_content=eo-security' : '?utm_content=eo-it'%>">Start with <%= primaryBuyingSituation==='eo-security' ? 'security engineering' : 'IT engineering'%></animated-arrow-button> + <animated-arrow-button href="/endpoint-ops">Start with <%= primaryBuyingSituation==='eo-security' ? 'security engineering' : 'IT engineering'%></animated-arrow-button> </div> </div> <div purpose="endpoint-ops-image" class="right"> @@ -175,7 +175,7 @@ <h2><%= primaryBuyingSituation==='vm'? 'Instrument your endpoints' : 'Understand your computers'%></h2> <p>A <%= primaryBuyingSituation==='vm'? 'lightweight' : 'quick-fast' %> way to gather <%= primaryBuyingSituation==='vm'? 'patch level and custom reports across all your computing devices, even in OT and production environments' : primaryBuyingSituation==='vm'? 'deep context and custom telemetry across all your endpoints, even servers' : primaryBuyingSituation==='mdm'||primaryBuyingSituation==='eo-it'? 'compliance and inventory data across all your devices' : 'device data across all your computers' %>. Pulse check or automate anything on any platform.</p> <div> - <animated-arrow-button href="/endpoint-ops<%= primaryBuyingSituation==='mdm' ? '?utm_content=eo-it' : '?utm_content=eo-security'%>">Start with <%= primaryBuyingSituation==='mdm' ? 'IT engineering' : 'security engineering'%></animated-arrow-button> + <animated-arrow-button href="/endpoint-ops">Start with <%= primaryBuyingSituation==='mdm' ? 'IT engineering' : 'security engineering'%></animated-arrow-button> </div> </div> </div>