Merge branch 'main' into feat-byod-enrollment

This commit is contained in:
Gabriel Hernandez 2024-09-04 14:21:21 +01:00
commit 910b5a7b2b
102 changed files with 1669 additions and 1057 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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: |

View file

@ -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'

View file

@ -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 }}

View file

@ -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

View file

@ -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 }}

View file

@ -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

View file

@ -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

View file

@ -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: |

View file

@ -35,15 +35,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: Start Infra Dependencies
# Use & to background this
run: docker compose up -d mysql_test &

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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')

View file

@ -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: |

View file

@ -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

View file

@ -0,0 +1 @@
* Implement features allowing automatic installation of software on hosts that fail policies.

View file

@ -0,0 +1 @@
- Verify user has premium license before uploading VPP tokens

View file

@ -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.

1
changes/apns-errors Normal file
View file

@ -0,0 +1 @@
* Fixed logic to properly catch and log APNs errors.

View file

@ -633,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 (

View file

@ -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`
@ -9191,6 +9197,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 +9836,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. |
| &nbsp;&nbsp;failing_policies_webhook | object | body | Failing policies webhook settings. |
| &nbsp;&nbsp;&nbsp;&nbsp;enable_failing_policies_webhook | boolean | body | Whether or not the failing policies webhook is enabled. |
@ -9864,10 +9872,10 @@ _Available in Fleet Premium_
| &nbsp;&nbsp;&nbsp;&nbsp;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. |
| &nbsp;&nbsp;&nbsp;&nbsp;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. |
| &nbsp;&nbsp;macos_settings | object | body | macOS-specific settings. |
| &nbsp;&nbsp;&nbsp;&nbsp;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. |
| &nbsp;&nbsp;&nbsp;&nbsp;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. |
| &nbsp;&nbsp;&nbsp;&nbsp;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. |
| &nbsp;&nbsp;windows_settings | object | body | Windows-specific settings. |
| &nbsp;&nbsp;&nbsp;&nbsp;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. |
| &nbsp;&nbsp;&nbsp;&nbsp;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. |
| &nbsp;&nbsp;macos_setup | object | body | Setup for automatic MDM enrollment of macOS hosts. |
| &nbsp;&nbsp;&nbsp;&nbsp;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 +10095,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 +10207,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

View file

@ -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) {
@ -413,3 +417,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)
}

View file

@ -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 => {

View file

@ -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,
});

View file

@ -3,7 +3,6 @@
&__label {
font-size: $x-small;
font-weight: $bold;
margin-bottom: $pad-small;
&--error {
color: $core-vibrant-red;

View file

@ -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}

View file

@ -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;

View file

@ -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 });
}

View file

@ -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 {

View file

@ -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;

View file

@ -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} />

View file

@ -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>
)}

View file

@ -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]);

View file

@ -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 &quot;Installed&quot; status won&apos;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 />
&quot;installed&quot; status won&apos;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

View file

@ -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 {

View file

@ -208,6 +208,7 @@ const SoftwareTitleDetailsPage = ({
softwareTitle.source
)}
isAvailableForInstall={isAvailableForInstall}
countsUpdatedAt={softwareTitle.versions_updated_at}
/>
</Card>
</>

View file

@ -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 (

View file

@ -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));
}

View file

@ -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;

View file

@ -1,4 +1,4 @@
.add-software-advanced-options {
.add-package-advanced-options {
display: flex;
flex-direction: column;
align-items: flex-start;

View file

@ -0,0 +1 @@
export { default } from "./AddPackageAdvancedOptions";

View file

@ -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;

View file

@ -1,4 +1,4 @@
.add-software-form {
.add-package-form {
&__uploading-message {
display: flex;
align-items: center;

View file

@ -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) {

View file

@ -1 +1 @@
export { default } from "./AddSoftwareForm";
export { default } from "./AddPackageForm";

View file

@ -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;

View file

@ -1 +0,0 @@
export { default } from "./AddSoftwareAdvancedOptions";

View file

@ -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&apos;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 add VPP.
To add App Store apps, assign a VPP token to this team.
</p>
<CustomLink
url={PATHS.ADMIN_INTEGRATIONS_VPP}
text="Add VPP"
text="Edit VPP"
className={`${baseClass}__enable-vpp-link`}
/>
</div>
@ -57,9 +57,8 @@ const NoVppAppsCard = () => (
You don&apos;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>

View file

@ -53,6 +53,7 @@
&__no-software-description {
margin: 0;
color: $ui-fleet-black-75;
text-align: center;
}
&__error {

View file

@ -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}>

View file

@ -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;

View file

@ -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 &quot;Installed&quot;
status won&apos;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 &gt; Software details</b> to see more.
The host failed to install software. To view errors, select
<br />
<b>Actions &gt; 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>

View file

@ -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")

View file

@ -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 &quot;Installed&quot;
status won&apos;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}

View file

@ -87,9 +87,5 @@
&__item-action-button {
height: auto;
&--installing {
display: none;
}
}
}

View file

@ -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}

View file

@ -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;
}
}

View file

@ -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.{" "}

View file

@ -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;

View file

@ -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;
}
}
}

View file

@ -0,0 +1 @@
export { default } from "./InstallSoftwareModal";

View file

@ -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

View file

@ -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`,

View file

@ -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());

View file

@ -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[]) => {

View file

@ -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";

View file

@ -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

View file

@ -584,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.

View file

@ -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

View file

@ -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"

View file

@ -74,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
@ -123,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"

View file

@ -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.

View file

@ -6,7 +6,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: "alexmitchelliii" # DRI for ritual (assignee if autoIssue) (TODO display GitHub proflie pic instead of name or title)
autoIssue: # Enables automation of GitHub issues

View file

@ -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": [

View file

@ -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:

26
server/docs/patterns.md Normal file
View file

@ -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).

View file

@ -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 (

View file

@ -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 }

View file

@ -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())
})
}
}

View file

@ -3149,7 +3149,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
}

View file

@ -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
@ -2542,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)
}

View file

@ -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 {

View file

@ -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
}

View file

@ -36,6 +36,14 @@ func CreateVPPTokenEncoded(expiration time.Time, orgName, location string) ([]by
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 {

View file

@ -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
}

View file

@ -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)

View file

@ -1,4 +1,4 @@
FROM golang:1.22.5-alpine3.20@sha256:8c9183f715b0b4eca05b8b3dbf59766aaedb41ec07477b132ee2891ac0110a07
FROM golang:1.22.6-alpine3.20@sha256:1a478681b671001b7f029f94b5016aed984a23ad99c707f6a0ab6563860ae2f3
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 .

View file

@ -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()

View file

@ -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)

Some files were not shown because too many files have changed in this diff Show more