diff --git a/.github/actions/disable-docker/action.yml b/.github/actions/disable-docker/action.yml index c15351a3f5c3..bcdb0c861110 100644 --- a/.github/actions/disable-docker/action.yml +++ b/.github/actions/disable-docker/action.yml @@ -10,4 +10,4 @@ runs: set -eux sudo apt-get autopurge -y containerd.io moby-containerd docker docker-ce podman uidmap sudo ip link delete docker0 - sudo nft flush ruleset + sudo nft flush ruleset || sudo iptables -I DOCKER-USER -j ACCEPT diff --git a/.github/actions/download-minio/action.yml b/.github/actions/download-minio/action.yml new file mode 100644 index 000000000000..46640510bd6a --- /dev/null +++ b/.github/actions/download-minio/action.yml @@ -0,0 +1,18 @@ +name: Download minio/mc +description: Download minio/mc + +runs: + using: composite + steps: + - name: Download minio/mc + shell: bash + run: | + set -eux + mkdir -p "$(go env GOPATH)/bin" + # Download minio ready to include in dependencies for system tests. + curl -sSfL https://dl.min.io/server/minio/release/linux-amd64/minio --output "$(go env GOPATH)/bin/minio" + chmod +x "$(go env GOPATH)/bin/minio" + + # Also grab the latest minio client to maintain compatibility with the server. + curl -sSfL https://dl.min.io/client/mc/release/linux-amd64/mc --output "$(go env GOPATH)/bin/mc" + chmod +x "$(go env GOPATH)/bin/mc" diff --git a/.github/actions/install-lxd-builddeps/action.yml b/.github/actions/install-lxd-builddeps/action.yml index f17585a3aa6f..ade45ca5b6f9 100644 --- a/.github/actions/install-lxd-builddeps/action.yml +++ b/.github/actions/install-lxd-builddeps/action.yml @@ -11,6 +11,8 @@ runs: sudo add-apt-repository ppa:ubuntu-lxc/daily -y --no-update sudo apt-get update + # mask services from lxc-utils (`lxc-*` tools are used in test/suites/lxc-to-lxd.sh) + # doing this masking before the package is installed means they won't even start sudo systemctl mask lxc.service lxc-net.service sudo apt-get install --no-install-recommends -y \ @@ -22,7 +24,7 @@ runs: libcap-dev \ libdbus-1-dev \ liblxc-dev \ - lxc-templates \ + lxc-utils \ libseccomp-dev \ libselinux-dev \ libsqlite3-dev \ diff --git a/.github/actions/install-lxd-runtimedeps/action.yml b/.github/actions/install-lxd-runtimedeps/action.yml new file mode 100644 index 000000000000..9373baa5f0d4 --- /dev/null +++ b/.github/actions/install-lxd-runtimedeps/action.yml @@ -0,0 +1,49 @@ +name: Install LXD runtime dependencies +description: Installs LXD runtime dependencies + +runs: + using: composite + steps: + - name: Installs LXD runtime dependencies + shell: bash + run: | + set -eux + sudo add-apt-repository ppa:ubuntu-lxc/daily -y --no-update + sudo apt-get update + + # mask services from lxc-utils (`lxc-*` tools are used in test/suites/lxc-to-lxd.sh) + # doing this masking before the package is installed means they won't even start + sudo systemctl mask lxc.service lxc-net.service + + sudo apt-get install --no-install-recommends -y \ + curl \ + git \ + make \ + acl \ + attr \ + bind9-dnsutils \ + btrfs-progs \ + busybox-static \ + dnsmasq-base \ + easy-rsa \ + gettext \ + jq \ + lxc-utils \ + lvm2 \ + nftables \ + quota \ + rsync \ + s3cmd \ + socat \ + sqlite3 \ + squashfs-tools \ + tar \ + tcl \ + thin-provisioning-tools \ + uuid-runtime \ + xfsprogs \ + xz-utils \ + zfsutils-linux + + # reclaim some space + sudo apt-get clean diff --git a/.github/actions/reclaim-disk-space/action.yml b/.github/actions/reclaim-disk-space/action.yml index 2d500bcaeefc..3eeb21456d1a 100644 --- a/.github/actions/reclaim-disk-space/action.yml +++ b/.github/actions/reclaim-disk-space/action.yml @@ -9,12 +9,24 @@ runs: run: | set -eux - # Purge snaps. - sudo snap remove --purge $(snap list | awk '!/^Name|^core|^snapd/ {print $1}') + # Purge snaps, if any. + # The Canonical runners use aproxy for connectivity. + for s in $(snap list | awk '!/^(Name|core|snapd|aproxy)/ {print $1}'); do + sudo snap remove --purge "${s}" || true + done # This was inspired from https://github.com/easimon/maximize-build-space df -h / + # Remove leftover home directories + sudo rm -rf /home/linuxbrew /home/runneradmin + + # Remove unneeded directories + sudo rm -rf /opt/google/chrome + sudo rm -rf /opt/hostedtoolcache/CodeQL /opt/hostedtoolcache/PyPy /opt/hostedtoolcache/Python + sudo rm -rf /opt/microsoft/msedge /opt/microsoft/msodbcsql* /opt/microsoft/powershell + sudo rm -rf /root/.sbt + # dotnet sudo rm -rf /usr/share/dotnet # android diff --git a/.github/actions/setup-microceph/action.yml b/.github/actions/setup-microceph/action.yml index 320000ff3702..e3175d58f62d 100644 --- a/.github/actions/setup-microceph/action.yml +++ b/.github/actions/setup-microceph/action.yml @@ -87,6 +87,7 @@ runs: run: | set -eux + sudo apt-get update sudo apt-get install --no-install-recommends -y ceph-common # reclaim some space sudo apt-get clean diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 07bed395c8e9..357506c7586f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -12,6 +12,10 @@ name: "CodeQL" on: + push: + branches: + - main + - stable-* pull_request: paths-ignore: - '.github/**' @@ -47,45 +51,45 @@ jobs: strategy: fail-fast: false matrix: - language: [ 'go' ] + language: ['go'] # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ] # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support steps: - - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. - # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality - # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5 + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5 - # ℹī¸ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + # ℹī¸ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - # If the Autobuild fails above, remove it and uncomment the following three lines. - # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. - # - run: | - # echo "Run, Build Application using script" - # ./location_of_script_within_repo/buildscript.sh + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5 - with: - category: "/language:${{matrix.language}}" + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/commits.yml b/.github/workflows/commits.yml index 75c935eedd30..dce4a107780a 100644 --- a/.github/workflows/commits.yml +++ b/.github/workflows/commits.yml @@ -14,23 +14,23 @@ jobs: name: Branch target and CLA runs-on: ubuntu-latest steps: - - name: Check branch target - env: - TARGET: ${{ github.event.pull_request.base.ref }} - TITLE: ${{ github.event.pull_request.title }} - if: ${{ github.actor != 'dependabot[bot]' }} - run: | - set -eux - TARGET_FROM_PR_TITLE="$(echo "${TITLE}" | sed -n 's/.*(\(stable-[0-9]\+\.[0-9]\+\))$/\1/p')" - if [ -z "${TARGET_FROM_PR_TITLE}" ]; then - TARGET_FROM_PR_TITLE="main" - else - echo "Branch target overridden from PR title" - fi - [ "${TARGET}" = "${TARGET_FROM_PR_TITLE}" ] && exit 0 + - name: Check branch target + env: + TARGET: ${{ github.event.pull_request.base.ref }} + TITLE: ${{ github.event.pull_request.title }} + if: ${{ github.actor != 'dependabot[bot]' }} + run: | + set -eux + TARGET_FROM_PR_TITLE="$(echo "${TITLE}" | sed -n 's/.*(\(stable-[0-9]\+\.[0-9]\+\))$/\1/p')" + if [ -z "${TARGET_FROM_PR_TITLE}" ]; then + TARGET_FROM_PR_TITLE="main" + else + echo "Branch target overridden from PR title" + fi + [ "${TARGET}" = "${TARGET_FROM_PR_TITLE}" ] && exit 0 - echo "Invalid branch target: ${TARGET} != ${TARGET_FROM_PR_TITLE}" - exit 1 + echo "Invalid branch target: ${TARGET} != ${TARGET_FROM_PR_TITLE}" + exit 1 - - name: Check if CLA signed - uses: canonical/has-signed-canonical-cla@046337b42822b7868ad62970988929c79f9c1d40 # 1.2.3 + - name: Check if CLA signed + uses: canonical/has-signed-canonical-cla@046337b42822b7868ad62970988929c79f9c1d40 # 1.2.3 diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index c39d2bbbbfcd..ae2247fcdd56 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -23,26 +23,44 @@ jobs: if: ${{ ( github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' ) && github.ref_name == 'main' && github.repository == 'canonical/lxd' }} steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: main - name: Install Trivy uses: ./.github/actions/install-trivy + - name: Download Trivy DB + id: db_download + run: trivy fs --download-db-only --cache-dir /home/runner/vuln-cache + continue-on-error: true + + - name: Use previously downloaded database + if: ${{ steps.db_download.outcome == 'failure' }} + uses: actions/cache/restore@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2 + with: + path: /home/runner/vuln-cache + key: download-failed # Use a non existing key to fallback to restore-keys + restore-keys: | + trivy-cache- + - name: Run Trivy vulnerability scanner run: | - trivy fs --quiet --scanners vuln,secret,misconfig --format sarif --cache-dir /home/runner/vuln-cache \ - --severity LOW,MEDIUM,HIGH,CRITICAL --output trivy-lxd-repo-scan-results.sarif . + trivy fs --skip-db-update \ + --scanners vuln,secret,misconfig \ + --format sarif \ + --cache-dir /home/runner/vuln-cache \ + --severity LOW,MEDIUM,HIGH,CRITICAL \ + --output trivy-lxd-repo-scan-results.sarif . - name: Cache Trivy vulnerability database - uses: actions/cache/save@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2 + uses: actions/cache/save@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2 with: path: /home/runner/vuln-cache key: trivy-cache-${{ github.run_id }} - name: Upload Trivy scan results to GitHub Security tab - uses: github/codeql-action/upload-sarif@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5 + uses: github/codeql-action/upload-sarif@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5 with: sarif_file: "trivy-lxd-repo-scan-results.sarif" sha: ${{ github.sha }} @@ -62,16 +80,18 @@ jobs: - "4.0" steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Install Trivy uses: ./.github/actions/install-trivy - name: Restore cached Trivy vulnerability database - uses: actions/cache/restore@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2 + uses: actions/cache/restore@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2 with: path: /home/runner/vuln-cache - key: trivy-cache-${{ github.run_id }} + key: download-failed # Use a non existing key to fallback to restore-keys + restore-keys: | + trivy-cache- - name: Download snap for scan run: | @@ -80,8 +100,12 @@ jobs: - name: Run Trivy vulnerability scanner run: | - trivy rootfs --quiet --scanners vuln,secret,misconfig --format sarif --cache-dir /home/runner/vuln-cache \ - --severity LOW,MEDIUM,HIGH,CRITICAL --output /home/runner/${{ matrix.version }}-stable.sarif squashfs-root + trivy rootfs --skip-db-update \ + --scanners vuln,secret,misconfig \ + --format sarif \ + --cache-dir /home/runner/vuln-cache \ + --severity LOW,MEDIUM,HIGH,CRITICAL \ + --output /home/runner/${{ matrix.version }}-stable.sarif squashfs-root - name: Flag snap scanning alerts run: | @@ -91,12 +115,12 @@ jobs: # Now we checkout to the branch related to the scanned snap to populate github.sha appropriately. - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ (matrix.version == 'latest' && 'main') || format('stable-{0}', matrix.version) }} - name: Upload Trivy scan results to GitHub Security tab - uses: github/codeql-action/upload-sarif@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5 + uses: github/codeql-action/upload-sarif@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5 with: sarif_file: /home/runner/${{ matrix.version }}-stable.sarif sha: ${{ github.sha }} diff --git a/.github/workflows/tests-snap.yml b/.github/workflows/tests-snap.yml index 334d832de700..2257353c580d 100644 --- a/.github/workflows/tests-snap.yml +++ b/.github/workflows/tests-snap.yml @@ -9,4 +9,4 @@ jobs: test-self-hosted-large-container: runs-on: [self-hosted, linux, X64, jammy, large] steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 85a47cd906d3..04f64387d24a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -39,7 +39,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: # A non-shallow clone is needed for the Differential ShellCheck fetch-depth: 0 @@ -48,55 +48,44 @@ jobs: uses: ./.github/actions/tune-disk-performance - name: Dependency Review - uses: actions/dependency-review-action@3b139cfc5fae8b618d3eae3675e383bb1769c019 # v4.5.0 + uses: actions/dependency-review-action@3b139cfc5fae8b618d3eae3675e383bb1769c019 # v4.5.0 if: github.event_name == 'pull_request' # XXX: `make static-analysis` also run shellcheck but this one provides # useful feedback in the PR through github-code-scanning bot - id: ShellCheck name: Differential ShellCheck - uses: redhat-plumbers-in-action/differential-shellcheck@cc6721c45a8800cc666de45493545a07a638d121 # v5.4.0 + uses: redhat-plumbers-in-action/differential-shellcheck@cc6721c45a8800cc666de45493545a07a638d121 # v5.4.0 with: token: ${{ secrets.GITHUB_TOKEN }} strict-check-on-push: true if: github.event_name == 'pull_request' - name: Upload artifact with ShellCheck defects in SARIF format - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 with: name: Differential ShellCheck SARIF path: ${{ steps.ShellCheck.outputs.sarif }} if: github.event_name == 'pull_request' - name: Install Go - uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 + uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 with: go-version-file: 'go.mod' - name: Install build dependencies uses: ./.github/actions/install-lxd-builddeps - - name: Download minio/mc - run: | - # Download minio ready to include in dependencies for system tests. - mkdir -p "$(go env GOPATH)/bin" - curl -sSfL https://dl.min.io/server/minio/release/linux-amd64/minio --output "$(go env GOPATH)/bin/minio" - chmod +x "$(go env GOPATH)/bin/minio" - - # Also grab the latest minio client to maintain compatibility with the server. - curl -sSfL https://dl.min.io/client/mc/release/linux-amd64/mc --output "$(go env GOPATH)/bin/mc" - chmod +x "$(go env GOPATH)/bin/mc" - - name: Download go dependencies run: | set -eux + sudo chmod o+w {go.mod,go.sum} go mod download - name: Check compatibility with min Go version run: | set -eux GOMIN="$(sed -n 's/^GOMIN=\([0-9.]\+\)$/\1/p' Makefile)" - sudo chmod o+w {go.mod,go.sum} go mod tidy -go="${GOMIN}" DOC_GOMIN="$(sed -n 's/^LXD requires Go \([0-9.]\+\) .*/\1/p' doc/requirements.md)" @@ -132,8 +121,6 @@ jobs: cd /home/runner/work/lxd/lxd-test make - strip --strip-all /home/runner/go/bin/{lxc*,lxd*} -v - - name: Check lxc/lxd-agent binary sizes run: | set -eux @@ -192,14 +179,17 @@ jobs: sudo --preserve-env=CGO_CFLAGS,CGO_LDFLAGS,CGO_LDFLAGS_ALLOW,GOCOVERDIR,LD_LIBRARY_PATH LD_LIBRARY_PATH=${LD_LIBRARY_PATH} env "PATH=${PATH}" make check-unit - name: Upload coverage data - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 with: name: coverage-unit path: ${{env.GOCOVERDIR}} if: env.GOCOVERDIR != '' + - name: Download minio/mc to add to system test dependencies + uses: ./.github/actions/download-minio + - name: Upload system test dependencies - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 with: name: system-test-deps path: | @@ -219,6 +209,7 @@ jobs: LXD_VERBOSE: "1" LXD_OFFLINE: "1" LXD_TMPFS: "1" + GOTRACEBACK: "crash" name: System runs-on: ubuntu-22.04 needs: code-tests @@ -230,7 +221,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Tune disk performance uses: ./.github/actions/tune-disk-performance @@ -242,63 +233,15 @@ jobs: uses: ./.github/actions/disable-docker - name: Install Go - uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 + uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 with: go-version-file: 'go.mod' - - name: Install dependencies - run: | - set -eux - sudo add-apt-repository ppa:ubuntu-lxc/daily -y --no-update - sudo apt-get update - - sudo systemctl mask lxc.service lxc-net.service - - sudo apt-get install --no-install-recommends -y \ - curl \ - git \ - libacl1-dev \ - libcap-dev \ - libdbus-1-dev \ - liblxc-dev \ - libseccomp-dev \ - libselinux-dev \ - libsqlite3-dev \ - libtool \ - libudev-dev \ - make \ - pkg-config\ - acl \ - attr \ - bind9-dnsutils \ - btrfs-progs \ - busybox-static \ - dnsmasq-base \ - easy-rsa \ - gettext \ - jq \ - lxc-utils \ - lvm2 \ - nftables \ - quota \ - rsync \ - s3cmd \ - socat \ - sqlite3 \ - squashfs-tools \ - tar \ - tcl \ - thin-provisioning-tools \ - uuid-runtime \ - xfsprogs \ - xz-utils \ - zfsutils-linux - - # reclaim some space - sudo apt-get clean + - name: Install runtime dependencies + uses: ./.github/actions/install-lxd-runtimedeps - name: Download system test dependencies - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 with: name: system-test-deps merge-multiple: true @@ -322,18 +265,29 @@ jobs: mkdir -p "${GOCOVERDIR}" if: env.GOCOVERDIR != '' - - name: "Run system tests (${{ matrix.go }}, ${{ matrix.suite }}, ${{ matrix.backend }})" + - name: "Run system tests (${{ matrix.suite }}, ${{ matrix.backend }})" run: | + echo '|/bin/sh -c $@ -- eval exec gzip --fast > /var/crash/core-%e.%p.gz' | sudo tee /proc/sys/kernel/core_pattern set -eux chmod +x ~ echo "root:1000000:1000000000" | sudo tee /etc/subuid /etc/subgid cd test - sudo --preserve-env=PATH,GOPATH,GOCOVERDIR,GITHUB_ACTIONS,LXD_VERBOSE,LXD_BACKEND,LXD_CEPH_CLUSTER,LXD_CEPH_CEPHFS,LXD_CEPH_CEPHOBJECT_RADOSGW,LXD_OFFLINE,LXD_SKIP_TESTS,LXD_REQUIRED_TESTS, LXD_BACKEND=${{ matrix.backend }} ./main.sh ${{ matrix.suite }} + sudo --preserve-env=PATH,GOPATH,GOCOVERDIR,GITHUB_ACTIONS,LXD_VERBOSE,LXD_BACKEND,LXD_CEPH_CLUSTER,LXD_CEPH_CEPHFS,LXD_CEPH_CEPHOBJECT_RADOSGW,LXD_OFFLINE,LXD_SKIP_TESTS,LXD_REQUIRED_TESTS,GOTRACEBACK, LXD_BACKEND=${{ matrix.backend }} ./main.sh ${{ matrix.suite }} + + - name: Upload crash dumps + if: always() + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + with: + name: crash-dumps-${{ matrix.suite }}-${{ matrix.backend }} + path: | + /var/crash/core-* + retention-days: 5 + if-no-files-found: ignore - name: Upload coverage data - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 with: - name: coverage-${{ matrix.go }}-${{ matrix.suite }}-${{ matrix.backend }} + name: coverage-${{ matrix.suite }}-${{ matrix.backend }} path: ${{env.GOCOVERDIR}} if: env.GOCOVERDIR != '' @@ -350,9 +304,6 @@ jobs: steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - # A non-shallow clone is needed for the Differential ShellCheck - fetch-depth: 0 - name: Tune disk performance uses: ./.github/actions/tune-disk-performance @@ -364,19 +315,19 @@ jobs: uses: ./.github/actions/disable-docker - name: Install Go - uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 + uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 with: go-version-file: 'go.mod' - name: Download coverage data - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 with: pattern: coverage-* path: ${{env.GOCOVERDIR}} merge-multiple: true - name: Download system test dependencies - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 with: name: system-test-deps merge-multiple: true @@ -398,7 +349,7 @@ jobs: gocov-xml < "${GOCOVERDIR}"/coverage.json > "${GOCOVERDIR}"/coverage-go.xml - name: Run TICS - uses: tiobe/tics-github-action@03294702eb0a8e13c06ff1949c7bb6643b4c60fc # v3.2.1 + uses: tiobe/tics-github-action@03294702eb0a8e13c06ff1949c7bb6643b4c60fc # v3.2.1 with: mode: qserver project: LXD @@ -422,10 +373,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Install Go - uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 + uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 with: go-version-file: 'go.mod' @@ -490,7 +441,8 @@ jobs: go test -v ./shared/... - name: Upload lxc client artifacts - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + if: ${{ github.event_name == 'push' }} continue-on-error: true with: name: lxd-clients-${{ runner.os }} @@ -498,13 +450,13 @@ jobs: documentation: name: Documentation - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Install Go - uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 + uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 with: go-version-file: 'go.mod' @@ -532,7 +484,7 @@ jobs: make doc-spellcheck - name: Run inclusive naming checker - uses: get-woke/woke-action@b2ec032c4a2c912142b38a6a453ad62017813ed0 # v0 + uses: get-woke/woke-action@b2ec032c4a2c912142b38a6a453ad62017813ed0 # v0 with: fail-on-error: true woke-args: "*.md **/*.md -c https://github.com/canonical/Inclusive-naming/raw/main/config.yml" diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml index 058170be3444..980b415b1502 100644 --- a/.github/workflows/triage.yml +++ b/.github/workflows/triage.yml @@ -18,7 +18,7 @@ jobs: name: PR labels runs-on: ubuntu-22.04 steps: - - uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5.0.0 - with: - repo-token: "${{ secrets.GITHUB_TOKEN }}" - sync-labels: true + - uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5.0.0 + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" + sync-labels: true diff --git a/.yamllint b/.yamllint new file mode 100644 index 000000000000..343092a74193 --- /dev/null +++ b/.yamllint @@ -0,0 +1,8 @@ +--- +extends: default + +rules: + document-start: disable + line-length: disable + truthy: disable + comments: disable diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c37c9904bf50..26f1b4deee79 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,7 +39,6 @@ Separate commits should be used for: - API structure (`shared/api: Add XYZ` for changes to `shared/api/`) - Go client package (`client: Add XYZ` for changes to `client/`) - CLI (`lxc/: Change XYZ` for changes to `lxc/`) -- Scripts (`scripts: Update bash completion for XYZ` for changes to `scripts/`) - LXD daemon (`lxd/: Add support for XYZ` for changes to `lxd/`) - Tests (`tests: Add test for XYZ` for changes to `tests/`) diff --git a/Makefile b/Makefile index bbb71101ba06..423ce5eb0b7c 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ GOPATH ?= $(shell go env GOPATH) CGO_LDFLAGS_ALLOW ?= (-Wl,-wrap,pthread_create)|(-Wl,-z,now) SPHINXENV=doc/.sphinx/venv/bin/activate SPHINXPIPPATH=doc/.sphinx/venv/bin/pip -GOMIN=1.22.7 +GOMIN=1.23.3 GOCOVERDIR ?= $(shell go env GOCOVERDIR) DQLITE_BRANCH=lts-1.17.x @@ -123,9 +123,11 @@ endif go get github.com/gorilla/websocket@v1.5.1 # Due to riscv64 crashes in LP # Enforce minimum go version - go get toolchain@none # Use the bundled toolchain that meets the minimum go version go mod tidy -go=$(GOMIN) + # Use the bundled toolchain that meets the minimum go version + go get toolchain@none + @echo "Dependencies updated" .PHONY: update-protobuf @@ -258,6 +260,9 @@ endif ifeq ($(shell command -v golangci-lint),) curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $$(go env GOPATH)/bin endif +ifneq ($(shell command -v yamllint),) + yamllint .github/workflows/*.yml +endif ifeq ($(shell command -v shellcheck),) echo "Please install shellcheck" exit 1 diff --git a/client/interfaces.go b/client/interfaces.go index 03bdaef214f5..392c51d33f66 100644 --- a/client/interfaces.go +++ b/client/interfaces.go @@ -243,6 +243,8 @@ type InstanceServer interface { UpdateImageAlias(name string, alias api.ImageAliasesEntryPut, ETag string) (err error) RenameImageAlias(name string, alias api.ImageAliasesEntryPost) (err error) DeleteImageAlias(name string) (err error) + GetImagesAllProjects() (images []api.Image, err error) + GetImagesAllProjectsWithFilter(filters []string) (images []api.Image, err error) // Network functions ("network" API extension) GetNetworkNames() (names []string, err error) @@ -445,6 +447,9 @@ type InstanceServer interface { GetIdentity(authenticationMethod string, nameOrIdentifier string) (identity *api.Identity, ETag string, err error) GetCurrentIdentityInfo() (identityInfo *api.IdentityInfo, ETag string, err error) UpdateIdentity(authenticationMethod string, nameOrIdentifier string, identityPut api.IdentityPut, ETag string) error + DeleteIdentity(authenticationMethod string, nameOrIdentifier string) error + CreateIdentityTLS(identitiesTLSPost api.IdentitiesTLSPost) error + CreateIdentityTLSToken(identitiesTLSPost api.IdentitiesTLSPost) (*api.CertificateAddToken, error) GetIdentityProviderGroupNames() (identityProviderGroupNames []string, err error) GetIdentityProviderGroups() (identityProviderGroups []api.IdentityProviderGroup, err error) GetIdentityProviderGroup(identityProviderGroupName string) (identityProviderGroup *api.IdentityProviderGroup, ETag string, err error) diff --git a/client/lxd.go b/client/lxd.go index 796a6fb5c2bb..f85f0ef7f33a 100644 --- a/client/lxd.go +++ b/client/lxd.go @@ -221,7 +221,7 @@ func lxdParseResponse(resp *http.Response) (*api.Response, string, error) { // Handle errors if response.Type == api.ErrorResponse { - return nil, "", api.StatusErrorf(resp.StatusCode, response.Error) + return nil, "", api.StatusErrorf(resp.StatusCode, "%s", response.Error) } return &response, etag, nil @@ -354,7 +354,7 @@ func (r *ProtocolLXD) queryStruct(method string, path string, data any, ETag str // Log the data logger.Debugf("Got response struct from LXD") - logger.Debugf(logger.Pretty(target)) + logger.Debug(logger.Pretty(target)) return etag, nil } @@ -401,7 +401,7 @@ func (r *ProtocolLXD) queryOperation(method string, path string, data any, ETag // Log the data logger.Debugf("Got operation from LXD") - logger.Debugf(logger.Pretty(op.Operation)) + logger.Debug(logger.Pretty(op.Operation)) return &op, etag, nil } @@ -492,7 +492,7 @@ func (r *ProtocolLXD) getUnderlyingHTTPTransport() (*http.Transport, error) { // is also updated with the minimal source fields. func (r *ProtocolLXD) getSourceImageConnectionInfo(source ImageServer, image api.Image, instSrc *api.InstanceSource) (info *ConnectionInfo, err error) { // Set the minimal source fields - instSrc.Type = "image" + instSrc.Type = api.SourceTypeImage // Optimization for the local image case if r.isSameServer(source) { diff --git a/client/lxd_auth.go b/client/lxd_auth.go index 0533ae0c3a3c..92c32e355ba8 100644 --- a/client/lxd_auth.go +++ b/client/lxd_auth.go @@ -261,6 +261,56 @@ func (r *ProtocolLXD) UpdateIdentity(authenticationMethod string, nameOrIdentife return nil } +// DeleteIdentity deletes the identity with the given authentication method and identifier (or name, if unique). +func (r *ProtocolLXD) DeleteIdentity(authenticationMethod string, nameOrIdentifier string) error { + err := r.CheckExtension("access_management_tls") + if err != nil { + return err + } + + _, _, err = r.query(http.MethodDelete, api.NewURL().Path("auth", "identities", authenticationMethod, nameOrIdentifier).String(), nil, "") + if err != nil { + return err + } + + return nil +} + +// CreateIdentityTLS creates a TLS identity. +func (r *ProtocolLXD) CreateIdentityTLS(tlsIdentitiesPost api.IdentitiesTLSPost) error { + err := r.CheckExtension("access_management_tls") + if err != nil { + return err + } + + _, _, err = r.query(http.MethodPost, api.NewURL().Path("auth", "identities", api.AuthenticationMethodTLS).String(), tlsIdentitiesPost, "") + if err != nil { + return err + } + + return nil +} + +// CreateIdentityTLSToken creates a pending TLS identity and returns a token that can be used by an untrusted client to set up authentication with LXD. +func (r *ProtocolLXD) CreateIdentityTLSToken(tlsIdentitiesPost api.IdentitiesTLSPost) (*api.CertificateAddToken, error) { + err := r.CheckExtension("access_management_tls") + if err != nil { + return nil, err + } + + if !tlsIdentitiesPost.Token { + return nil, fmt.Errorf("Token needs to be true when requesting a token") + } + + var token api.CertificateAddToken + _, err = r.queryStruct(http.MethodPost, api.NewURL().Path("auth", "identities", api.AuthenticationMethodTLS).String(), tlsIdentitiesPost, "", &token) + if err != nil { + return nil, err + } + + return &token, nil +} + // GetIdentityProviderGroupNames returns a list of identity provider group names. func (r *ProtocolLXD) GetIdentityProviderGroupNames() ([]string, error) { err := r.CheckExtension("access_management") diff --git a/client/lxd_containers.go b/client/lxd_containers.go index fa9d27b0d6b1..9e17a32849c6 100644 --- a/client/lxd_containers.go +++ b/client/lxd_containers.go @@ -228,7 +228,7 @@ func (r *ProtocolLXD) tryCreateContainer(req api.ContainersPost, urls []string) // CreateContainerFromImage is a convenience function to make it easier to create a container from an existing image. func (r *ProtocolLXD) CreateContainerFromImage(source ImageServer, image api.Image, req api.ContainersPost) (RemoteOperation, error) { // Set the minimal source fields - req.Source.Type = "image" + req.Source.Type = api.SourceTypeImage // Optimization for the local image case if r.isSameServer(source) { @@ -372,7 +372,7 @@ func (r *ProtocolLXD) CopyContainer(source InstanceServer, container api.Contain } // Local copy source fields - req.Source.Type = "copy" + req.Source.Type = api.SourceTypeCopy req.Source.Source = container.Name // Copy the container @@ -411,7 +411,7 @@ func (r *ProtocolLXD) CopyContainer(source InstanceServer, container api.Contain } // Create the container - req.Source.Type = "migration" + req.Source.Type = api.SourceTypeMigration req.Source.Mode = "push" req.Source.Refresh = args.Refresh @@ -424,7 +424,10 @@ func (r *ProtocolLXD) CopyContainer(source InstanceServer, container api.Contain targetSecrets := map[string]string{} for k, v := range opAPI.Metadata { - targetSecrets[k] = v.(string) + value, ok := v.(string) + if ok { + targetSecrets[k] = value + } } // Prepare the source request @@ -452,13 +455,16 @@ func (r *ProtocolLXD) CopyContainer(source InstanceServer, container api.Contain sourceSecrets := map[string]string{} for k, v := range opAPI.Metadata { - sourceSecrets[k] = v.(string) + value, ok := v.(string) + if ok { + sourceSecrets[k] = value + } } // Relay mode migration if args != nil && args.Mode == "relay" { // Push copy source fields - req.Source.Type = "migration" + req.Source.Type = api.SourceTypeMigration req.Source.Mode = "push" // Start the process @@ -472,7 +478,10 @@ func (r *ProtocolLXD) CopyContainer(source InstanceServer, container api.Contain // Extract the websockets targetSecrets := map[string]string{} for k, v := range targetOpAPI.Metadata { - targetSecrets[k] = v.(string) + value, ok := v.(string) + if ok { + targetSecrets[k] = value + } } // Launch the relay @@ -497,7 +506,7 @@ func (r *ProtocolLXD) CopyContainer(source InstanceServer, container api.Contain } // Pull mode migration - req.Source.Type = "migration" + req.Source.Type = api.SourceTypeMigration req.Source.Mode = "pull" req.Source.Operation = opAPI.ID req.Source.Websockets = sourceSecrets @@ -651,11 +660,13 @@ func (r *ProtocolLXD) ExecContainer(containerName string, exec api.ContainerExec // Parse the fds fds := map[string]string{} - value, ok := opAPI.Metadata["fds"] + values, ok := opAPI.Metadata["fds"].(map[string]any) if ok { - values := value.(map[string]any) for k, v := range values { - fds[k] = v.(string) + fd, ok := v.(string) + if ok { + fds[k] = fd + } } } @@ -1075,7 +1086,7 @@ func (r *ProtocolLXD) CopyContainerSnapshot(source InstanceServer, containerName } // Local copy source fields - req.Source.Type = "copy" + req.Source.Type = api.SourceTypeCopy req.Source.Source = fmt.Sprintf("%s/%s", cName, sName) // Copy the container @@ -1117,7 +1128,7 @@ func (r *ProtocolLXD) CopyContainerSnapshot(source InstanceServer, containerName } // Create the container - req.Source.Type = "migration" + req.Source.Type = api.SourceTypeMigration req.Source.Mode = "push" op, err := r.CreateContainer(req) @@ -1129,7 +1140,10 @@ func (r *ProtocolLXD) CopyContainerSnapshot(source InstanceServer, containerName targetSecrets := map[string]string{} for k, v := range opAPI.Metadata { - targetSecrets[k] = v.(string) + value, ok := v.(string) + if ok { + targetSecrets[k] = value + } } // Prepare the source request @@ -1157,13 +1171,16 @@ func (r *ProtocolLXD) CopyContainerSnapshot(source InstanceServer, containerName sourceSecrets := map[string]string{} for k, v := range opAPI.Metadata { - sourceSecrets[k] = v.(string) + value, ok := v.(string) + if ok { + sourceSecrets[k] = value + } } // Relay mode migration if args != nil && args.Mode == "relay" { // Push copy source fields - req.Source.Type = "migration" + req.Source.Type = api.SourceTypeMigration req.Source.Mode = "push" // Start the process @@ -1177,7 +1194,10 @@ func (r *ProtocolLXD) CopyContainerSnapshot(source InstanceServer, containerName // Extract the websockets targetSecrets := map[string]string{} for k, v := range targetOpAPI.Metadata { - targetSecrets[k] = v.(string) + value, ok := v.(string) + if ok { + targetSecrets[k] = value + } } // Launch the relay @@ -1202,7 +1222,7 @@ func (r *ProtocolLXD) CopyContainerSnapshot(source InstanceServer, containerName } // Pull mode migration - req.Source.Type = "migration" + req.Source.Type = api.SourceTypeMigration req.Source.Mode = "pull" req.Source.Operation = opAPI.ID req.Source.Websockets = sourceSecrets @@ -1574,11 +1594,13 @@ func (r *ProtocolLXD) ConsoleContainer(containerName string, console api.Contain // Parse the fds fds := map[string]string{} - value, ok := opAPI.Metadata["fds"] + values, ok := opAPI.Metadata["fds"].(map[string]any) if ok { - values := value.(map[string]any) for k, v := range values { - fds[k] = v.(string) + fd, ok := v.(string) + if ok { + fds[k] = fd + } } } diff --git a/client/lxd_images.go b/client/lxd_images.go index 28d7cc3100f8..e26b874287b8 100644 --- a/client/lxd_images.go +++ b/client/lxd_images.go @@ -33,6 +33,47 @@ func (r *ProtocolLXD) GetImages() ([]api.Image, error) { return images, nil } +// GetImagesAllProjects returns a list of images across all projects as Image structs. +func (r *ProtocolLXD) GetImagesAllProjects() ([]api.Image, error) { + images := []api.Image{} + + err := r.CheckExtension("images_all_projects") + if err != nil { + return nil, err + } + + u := api.NewURL().Path("images").WithQuery("recursion", "1").WithQuery("all-projects", "true") + _, err = r.queryStruct("GET", u.String(), nil, "", &images) + if err != nil { + return nil, err + } + + return images, nil +} + +// GetImagesAllProjectsWithFilter returns a filtered list of images across all projects as Image structs. +func (r *ProtocolLXD) GetImagesAllProjectsWithFilter(filters []string) ([]api.Image, error) { + err := r.CheckExtension("api_filtering") + if err != nil { + return nil, err + } + + images := []api.Image{} + + err = r.CheckExtension("images_all_projects") + if err != nil { + return nil, err + } + + u := api.NewURL().Path("images").WithQuery("recursion", "1").WithQuery("all-projects", "true").WithQuery("filter", parseFilters(filters)) + _, err = r.queryStruct("GET", u.String(), nil, "", &images) + if err != nil { + return nil, err + } + + return images, nil +} + // GetImagesWithFilter returns a filtered list of available images as Image structs. func (r *ProtocolLXD) GetImagesWithFilter(filters []string) ([]api.Image, error) { err := r.CheckExtension("api_filtering") @@ -870,7 +911,7 @@ func (r *ProtocolLXD) CopyImage(source ImageServer, image api.Image, args *Image }, Fingerprint: image.Fingerprint, Mode: "pull", - Type: "image", + Type: api.SourceTypeImage, Project: info.Project, }, ImagePut: api.ImagePut{ diff --git a/client/lxd_instances.go b/client/lxd_instances.go index e57259d082ad..fcf67fbcba00 100644 --- a/client/lxd_instances.go +++ b/client/lxd_instances.go @@ -924,7 +924,7 @@ func (r *ProtocolLXD) CopyInstance(source InstanceServer, instance api.Instance, } // Local copy source fields - req.Source.Type = "copy" + req.Source.Type = api.SourceTypeCopy req.Source.Source = instance.Name // Copy the instance @@ -965,7 +965,7 @@ func (r *ProtocolLXD) CopyInstance(source InstanceServer, instance api.Instance, } // Create the instance - req.Source.Type = "migration" + req.Source.Type = api.SourceTypeMigration req.Source.Mode = "push" req.Source.Refresh = args.Refresh @@ -1022,7 +1022,7 @@ func (r *ProtocolLXD) CopyInstance(source InstanceServer, instance api.Instance, // Relay mode migration if args != nil && args.Mode == "relay" { // Push copy source fields - req.Source.Type = "migration" + req.Source.Type = api.SourceTypeMigration req.Source.Mode = "push" // Start the process @@ -1066,7 +1066,7 @@ func (r *ProtocolLXD) CopyInstance(source InstanceServer, instance api.Instance, } // Pull mode migration - req.Source.Type = "migration" + req.Source.Type = api.SourceTypeMigration req.Source.Mode = "pull" req.Source.Operation = opAPI.ID req.Source.Websockets = sourceSecrets @@ -2008,7 +2008,7 @@ func (r *ProtocolLXD) CopyInstanceSnapshot(source InstanceServer, instanceName s } // Local copy source fields - req.Source.Type = "copy" + req.Source.Type = api.SourceTypeCopy req.Source.Source = fmt.Sprintf("%s/%s", cName, sName) // Copy the instance @@ -2060,7 +2060,7 @@ func (r *ProtocolLXD) CopyInstanceSnapshot(source InstanceServer, instanceName s } // Create the instance - req.Source.Type = "migration" + req.Source.Type = api.SourceTypeMigration req.Source.Mode = "push" op, err := r.CreateInstance(req) @@ -2116,7 +2116,7 @@ func (r *ProtocolLXD) CopyInstanceSnapshot(source InstanceServer, instanceName s // Relay mode migration if args != nil && args.Mode == "relay" { // Push copy source fields - req.Source.Type = "migration" + req.Source.Type = api.SourceTypeMigration req.Source.Mode = "push" // Start the process @@ -2160,7 +2160,7 @@ func (r *ProtocolLXD) CopyInstanceSnapshot(source InstanceServer, instanceName s } // Pull mode migration - req.Source.Type = "migration" + req.Source.Type = api.SourceTypeMigration req.Source.Mode = "pull" req.Source.Operation = opAPI.ID req.Source.Websockets = sourceSecrets diff --git a/client/lxd_storage_volumes.go b/client/lxd_storage_volumes.go index cb9bcfc476f7..f3665778f57f 100644 --- a/client/lxd_storage_volumes.go +++ b/client/lxd_storage_volumes.go @@ -609,7 +609,7 @@ func (r *ProtocolLXD) CopyStoragePoolVolume(pool string, source InstanceServer, Type: volume.Type, Source: api.StorageVolumeSource{ Name: volume.Name, - Type: "copy", + Type: api.SourceTypeCopy, Pool: sourcePool, VolumeOnly: args.VolumeOnly, Refresh: args.Refresh, @@ -692,7 +692,7 @@ func (r *ProtocolLXD) CopyStoragePoolVolume(pool string, source InstanceServer, } // Create the container - req.Source.Type = "migration" + req.Source.Type = api.SourceTypeMigration req.Source.Mode = "push" // Send the request @@ -708,7 +708,10 @@ func (r *ProtocolLXD) CopyStoragePoolVolume(pool string, source InstanceServer, targetSecrets := map[string]string{} for k, v := range opAPI.Metadata { - targetSecrets[k] = v.(string) + value, ok := v.(string) + if ok { + targetSecrets[k] = value + } } // Prepare the source request @@ -738,13 +741,16 @@ func (r *ProtocolLXD) CopyStoragePoolVolume(pool string, source InstanceServer, // Prepare source server secrets for remote sourceSecrets := map[string]string{} for k, v := range opAPI.Metadata { - sourceSecrets[k] = v.(string) + value, ok := v.(string) + if ok { + sourceSecrets[k] = value + } } // Relay mode migration if args != nil && args.Mode == "relay" { // Push copy source fields - req.Source.Type = "migration" + req.Source.Type = api.SourceTypeMigration req.Source.Mode = "push" // Send the request @@ -761,7 +767,10 @@ func (r *ProtocolLXD) CopyStoragePoolVolume(pool string, source InstanceServer, // Extract the websockets targetSecrets := map[string]string{} for k, v := range targetOpAPI.Metadata { - targetSecrets[k] = v.(string) + value, ok := v.(string) + if ok { + targetSecrets[k] = value + } } // Launch the relay @@ -786,7 +795,7 @@ func (r *ProtocolLXD) CopyStoragePoolVolume(pool string, source InstanceServer, } // Pull mode migration - req.Source.Type = "migration" + req.Source.Type = api.SourceTypeMigration req.Source.Mode = "pull" req.Source.Operation = opAPI.ID req.Source.Websockets = sourceSecrets diff --git a/doc/.custom_wordlist.txt b/doc/.custom_wordlist.txt index 881fa29ac8a1..da97d3eaadf0 100644 --- a/doc/.custom_wordlist.txt +++ b/doc/.custom_wordlist.txt @@ -25,6 +25,7 @@ BPF Btrfs bugfix bugfixes +CDI CentOS Ceph CephFS @@ -46,6 +47,7 @@ CSM CSV CUDA dataset +dGPU DCO dereferenced DHCP @@ -97,6 +99,8 @@ idmap idmapped idmaps IdP +iGPU +iGPUs incrementing InfiniBand init @@ -140,6 +144,7 @@ MicroCloud MII MinIO MITM +MNIST MTU Mullvad multicast @@ -153,7 +158,10 @@ NIC NICs NUMA NVMe +NVML NVRAM +NVIDIA +OCI OData OIDC OpenFGA @@ -170,6 +178,7 @@ OVS Pbit PCI PCIe +passthrough peerings PFs PiB @@ -208,6 +217,7 @@ SATA scalable scriptlet SDC +SDK SDN SDS SDT @@ -225,6 +235,7 @@ SLAAC SLES SMTP Snapcraft +SoC Solaris SPAs SPL @@ -258,6 +269,8 @@ sysfs syslog Tbit TCP +TensorRT +Tegra TiB Tibit TinyPNG diff --git a/doc/.sphinx/.markdownlint/exceptions.txt b/doc/.sphinx/.markdownlint/exceptions.txt index 7cd1e234a1dc..2509a37253d6 100644 --- a/doc/.sphinx/.markdownlint/exceptions.txt +++ b/doc/.sphinx/.markdownlint/exceptions.txt @@ -1,6 +1,6 @@ .tmp/doc/howto/access_ui.md:31: MD029 Ordered list item prefix -.tmp/doc/howto/import_machines_to_instances.md:116: MD034 Bare URL used -.tmp/doc/howto/import_machines_to_instances.md:220: MD034 Bare URL used +.tmp/doc/howto/import_machines_to_instances.md:121: MD034 Bare URL used +.tmp/doc/howto/import_machines_to_instances.md:225: MD034 Bare URL used .tmp/doc/howto/network_forwards.md:66: MD004 Unordered list style .tmp/doc/howto/network_forwards.md:71: MD004 Unordered list style .tmp/doc/howto/network_forwards.md:67: MD005 Inconsistent indentation for list items at the same level diff --git a/doc/.wordlist.txt b/doc/.wordlist.txt index f7eb1db46839..2e454dacdcc8 100644 --- a/doc/.wordlist.txt +++ b/doc/.wordlist.txt @@ -15,6 +15,7 @@ EKS enablement favicon Furo +GDB Git GitHub Grafana @@ -43,6 +44,7 @@ pre QCow Quickstart ReadMe +REPL reST reStructuredText RTD diff --git a/doc/api-extensions.md b/doc/api-extensions.md index cfa7e3bcd889..3eeb9eecf15f 100644 --- a/doc/api-extensions.md +++ b/doc/api-extensions.md @@ -2452,11 +2452,6 @@ from being started if set to `true`. Adds a new `virtio-blk` value for `io.bus` on `disk` devices which allows for the attached disk to be connected to the `virtio-blk` bus. -## `ubuntu_pro_guest_attach` - -Adds a new {config:option}`instance-miscellaneous:ubuntu_pro.guest_attach` configuration option for instances. -When set to `on`, if the host has guest attachment enabled, the guest can request a guest token for Ubuntu Pro via `devlxd`. - ## `metadata_configuration_entity_types` This adds entity type metadata to `GET /1.0/metadata/configuration`. @@ -2471,3 +2466,60 @@ And lastly, adds a `project` field on leases, leases can be retrieved via `/1.0/ ## `network_ovn_uplink_vlan` Adds support for using a bridge network with a specified VLAN ID as an OVN uplink. + +## `shared_custom_block_volumes` + +This adds a configuration key `security.shared` to custom block volumes. +If unset or `false`, the custom block volume cannot be attached to multiple instances. +This feature was added to prevent data loss which can happen when custom block volumes are attached to multiple instances at once. + +## `metrics_api_requests` + +Adds the following internal metrics: + +* Total completed requests +* Number of ongoing requests + +## `projects_limits_disk_pool` + +This introduces per-pool project disk limits, introducing a `limits.disk.pool.NAME` +configuration option to the project limits. When `limits.disk.pool.POOLNAME: 0` +for a project, the pool is excluded from `lxc storage list` in that project. + +## `access_management_tls` + +Expands APIs under `/1.0/auth` to include: + +1. Creation of fine-grained TLS identities, whose permissions are managed via group membership. + This is performed via `POST /1.0/auth/identities/tls`. + If the request body contains `{"token": true}`, a token will be returned that may be used by a non-authenticated caller to gain trust with the LXD server (the caller must send their certificate during the TLS handshake). + If the request body contains `{"certificate": ""}"`, the identity will be created directly. + The request body may also specify an array of group names. + The caller must have `can_create_identities` on `server`. +1. Deletion of OIDC and fine-grained TLS identities. + This is performed via `DELETE /1.0/auth/identities/tls/{nameOrFingerprint}` or `DELETE /1.0/auth/identities/oidc/{nameOrEmailAddress}`. + The caller must have `can_delete` on the identity. All identities may delete their own identity. + For OIDC identities this revokes all access but does not revoke trust (authentication is performed by the identity provider). + For fine-grained TLS identities, this revokes all access and revokes trust. +1. Functionality to update the certificate of a fine-grained TLS identity. + This is performed via `PUT /1.0/auth/identities/tls/{nameOrFingerprint}` or `PATCH /1.0/auth/identities/tls/{nameOrFingerprint}`. + The caller must provide a base64 encoded x509 certificate in the `certificate` field of the request body. + Fine-grained TLS identities may update their own certificate. + To update the certificate of another identity, the caller must have `can_edit` on the identity. + +## `state_logical_cpus` + +Adds `logical_cpus` field to `GET /1.0/cluster/members/{name}/state` which +contains the total available logical CPUs available when LXD started. + +## `vm_limits_cpu_pin_strategy` + +Adds a new {config:option}`instance-resource-limits:limits.cpu.pin_strategy` configuration option for virtual machines. This option controls the CPU pinning strategy. When set to `none`, CPU auto pinning is disabled. When set to `auto`, CPU auto pinning is enabled. + +## `gpu_cdi` + +Adds support for using the Container Device Interface (CDI) specification to configure GPU passthrough in LXD containers. The `id` field of GPU devices now accepts CDI identifiers (for example, `{VENDOR_DOMAIN_NAME}/gpu=gpu{INDEX}`) for containers, in addition to DRM card IDs. This enables GPU passthrough for devices that don't use PCI addressing (like NVIDIA Tegra iGPUs) and provides a more flexible way to identify and configure GPU devices. + +## `images_all_projects` + +This adds support for listing images across all projects using the `all-projects` parameter in `GET /1.0/images` requests. diff --git a/doc/authentication.md b/doc/authentication.md index 214415d47c49..7b753e2fd4c3 100644 --- a/doc/authentication.md +++ b/doc/authentication.md @@ -46,14 +46,6 @@ any backward compatibility to broken protocol or ciphers. (authentication-trusted-clients)= ### Trusted TLS clients -You can obtain the list of TLS certificates trusted by a LXD server with [`lxc config trust list`](lxc_config_trust_list.md). - -Trusted clients can be added in either of the following ways: - -- {ref}`authentication-add-certs` -- {ref}`authentication-trust-pw` -- {ref}`authentication-token` - The workflow to authenticate with the server is similar to that of SSH, where an initial connection to an unknown server triggers a prompt: 1. When the user adds a server with [`lxc remote add`](lxc_remote_add.md), the server is contacted over HTTPS, its certificate is downloaded and the fingerprint is shown to the user. @@ -65,49 +57,7 @@ The workflow to authenticate with the server is similar to that of SSH, where an If the provided token or trust password matches, the client certificate is added to the server's trust store and the connection is granted. Otherwise, the connection is rejected. -To revoke trust to a client, remove its certificate from the server with [`lxc config trust remove `](lxc_config_trust_remove.md). - -TLS clients can be restricted to a subset of projects, see {ref}`restricted-tls-certs` for more information. - -(authentication-add-certs)= -#### Adding trusted certificates to the server - -The preferred way to add trusted clients is to directly add their certificates to the trust store on the server. -To do so, copy the client certificate to the server and register it using [`lxc config trust add `](lxc_config_trust_add.md). - -(authentication-trust-pw)= -#### Adding client certificates using a trust password - -To allow establishing a new trust relationship from the client side, you must set a trust password ({config:option}`server-core:core.trust_password`) for the server. Clients can then add their own certificate to the server's trust store by providing the trust password when prompted. - -In a production setup, unset `core.trust_password` after all clients have been added. -This prevents brute-force attacks trying to guess the password. - -(authentication-token)= -#### Adding client certificates using tokens - -You can also add new clients by using tokens. This is a safer way than using the trust password, because tokens expire after a configurable time ({config:option}`server-core:core.remote_token_expiry`) or once they've been used. - -To use this method, generate a token for each client by calling [`lxc config trust add`](lxc_config_trust_add.md), which will prompt for the client name. -The clients can then add their certificates to the server's trust store by providing the generated token when prompted for the trust password. - - - -```{note} -If your LXD server is behind NAT, you must specify its external public address when adding it as a remote for a client: - - lxc remote add - -When you are prompted for the admin password, specify the generated token. - -When generating the token on the server, LXD includes a list of IP addresses that the client can use to access the server. -However, if the server is behind NAT, these addresses might be local addresses that the client cannot connect to. -In this case, you must specify the external address manually. -``` - - - -Alternatively, the clients can provide the token directly when adding the remote: [`lxc remote add `](lxc_remote_add.md). +See {ref}`server-expose` and {ref}`server-authenticate` for instructions on how to configure TLS authentication and add trusted clients. (authentication-pki)= ### Using a PKI system diff --git a/doc/debugging.md b/doc/debugging.md index c3240b44114b..1f4222c84419 100644 --- a/doc/debugging.md +++ b/doc/debugging.md @@ -134,3 +134,42 @@ If you want to flush the content of the cluster database to disk, use the `lxd sql global .sync` command, that will write a plain SQLite database file into `./database/global/db.bin`, which you can then inspect with the `sqlite3` command line tool. + +## Inspect a core dump file + +In our continuous integration tests, we have configured the `core_pattern` as follows: + + echo '|/bin/sh -c $@ -- eval exec gzip --fast > /var/crash/core-%e.%p.gz' | sudo tee /proc/sys/kernel/core_pattern + +Additionally, we have set the `GOTRACEBACK` environment variable to `crash`. +Together, these ensure that when LXD crashes a core dump is compressed with `gzip` and placed in `/var/crash`. + +To inspect a core dump file, you will need the LXD binary that was running at the time of the crash. +The binary must include symbols; you can check this with the `file` utility. +You will also need any C libraries that are used by LXD which must also include symbols. + +You can inspect a core dump using [Delve](https://github.com/go-delve/delve) (see the [Go Wiki](https://go.dev/wiki/CoreDumpDebugging) for more information), but this does not support any dynamically linked C libraries. +Instead, you can use [GDB](https://sourceware.org/gdb/) which can inspect linked libraries and allows sourcing a file to load Golang support. + +To do this, run: + + gdb + +Then in the GDB REPL, run: + + (gdb) source /src/runtime/runtime-gdb.py + +Substituting in the actual path to your `$GOROOT`. +This will add Golang runtime support. + +Finally, set the search path for C libraries using: + + (gdb) set solib-search-path + +You can now use the GDB REPL to inspect the core dump. +Some useful commands are: + +- `backtrace` (print stack trace). +- `info goroutines` (show goroutines). +- `info threads` (show threads). +- `thread ` (change thread). diff --git a/doc/explanation/authorization.md b/doc/explanation/authorization.md index 81c0d920594e..5c2a1389c778 100644 --- a/doc/explanation/authorization.md +++ b/doc/explanation/authorization.md @@ -23,7 +23,7 @@ If the list of projects is empty, the client will not be allowed access to any o (fine-grained-authorization)= ## Fine-grained authorization -It is possible to restrict {ref}`OIDC clients ` to granular actions on specific LXD resources. +It is possible to restrict {ref}`OIDC clients ` and fine-grained TLS identities to granular actions on specific LXD resources. For example, one could restrict a user to be able to view, but not edit, a single instance. There are four key concepts that LXD uses to manage these fine-grained permissions: diff --git a/doc/explanation/projects.md b/doc/explanation/projects.md index 75d448416b43..637a79f0a842 100644 --- a/doc/explanation/projects.md +++ b/doc/explanation/projects.md @@ -67,24 +67,19 @@ In addition, this method allows users to work with LXD without being a member of Members of the `lxd` group have full access to LXD, including permission to attach file system paths and tweak the security features of an instance, which makes it possible to gain root access to the host system. Using confined projects limits what users can do in LXD, but it also prevents users from gaining root access. -### Authentication methods for projects +When LXD is accessible over the HTTPS API, both {ref}`authentication-tls-certs` and {ref}`OIDC clients ` can be restricted to allow access to specific projects only. +This is managed via {ref}`fine-grained-authorization`. +See {ref}`projects-confine-https` for instructions. -There are different ways of authentication that you can use to confine projects to specific users: +### Multi-user LXD daemon +The LXD snap contains a multi-user LXD daemon that allows dynamic project creation on a per-user basis. +You can configure a specific user group other than the `lxd` group to give restricted LXD access to every user in the group. -Client certificates -: You can restrict the {ref}`authentication-tls-certs` to allow access to specific projects only. - The projects must exist before you can restrict access to them. - A client that connects using a restricted certificate can see only the project or projects that the client has been granted access to. +When a user that is a member of this group starts using LXD, the multi-user daemon automatically creates a confined project for this user. -Multi-user LXD daemon -: The LXD snap contains a multi-user LXD daemon that allows dynamic project creation on a per-user basis. - You can configure a specific user group other than the `lxd` group to give restricted LXD access to every user in the group. +If you're not using the snap, you can still use this feature if your distribution supports it. - When a user that is a member of this group starts using LXD, LXD automatically creates a confined project for this user. - - If you're not using the snap, you can still use this feature if your distribution supports it. - -See {ref}`projects-confine` for instructions on how to enable and configure the different authentication methods. +See {ref}`projects-confine-users` for instructions on configuring the multi-user daemon. ## Related topics diff --git a/doc/explanation/storage.md b/doc/explanation/storage.md index ec12863fee3c..5966f45871b0 100644 --- a/doc/explanation/storage.md +++ b/doc/explanation/storage.md @@ -134,7 +134,7 @@ Storage volumes can be of the following types: `custom` : You can add one or more custom storage volumes to hold data that you want to store separately from your instances. - Custom storage volumes can be shared between instances, and they are retained until you delete them. + Custom storage volumes of content type `filesystem` or `iso` can be shared between instances, and they are retained until you delete them. You can also use custom storage volumes to hold your backups or images. @@ -156,7 +156,8 @@ Each storage volume uses one of the following content types: You can create a custom storage volume of type `block` by using the `--type=block` flag. Custom storage volumes of content type `block` can only be attached to virtual machines. - They should not be shared between instances, because simultaneous access can lead to data corruption. + By default, they can only be attached to one instance at a time, because simultaneous access can lead to data corruption. + Sharing a custom storage volumes of content type `block` is made possible through the usage of the `security.shared` configuration key. `iso` : This content type is used for custom ISO volumes. diff --git a/doc/howto/cluster_form.md b/doc/howto/cluster_form.md index 5c78c7b7c1d9..76cede81ae6a 100644 --- a/doc/howto/cluster_form.md +++ b/doc/howto/cluster_form.md @@ -13,8 +13,7 @@ See {ref}`clustering-members` for more information. You can form the LXD cluster interactively by providing configuration information during the initialization process or by using preseed files that contain the full configuration. -To quickly and automatically set up a basic LXD cluster, you can use MicroCloud. -Note, however, that this project is still in an early phase. +To quickly and automatically set up a basic LXD cluster, you can use {ref}`MicroCloud `. ## Configure the cluster interactively @@ -39,7 +38,7 @@ You can accept the default values for most questions, but make sure to answer th Select **no**. - `Setup password authentication on the cluster?` - Select **no** to use {ref}`authentication tokens ` (recommended) or **yes** to use a {ref}`trust password `. + Select **no** to use authentication tokens (recommended) or **yes** to use a trust password.
Expand to see a full example for lxd init on the bootstrap server @@ -100,7 +99,7 @@ Basically, the initialization process consists of the following steps: Select **yes**. - `Do you have a join token?` - Select **yes** if you configured the bootstrap server to use {ref}`authentication tokens ` (recommended) or **no** if you configured it to use a {ref}`trust password `. + Select **yes** if you configured the bootstrap server to use authentication tokens (recommended) or **no** if you configured it to use a trust password. 1. Authenticate with the cluster. There are two alternative methods, depending on which authentication method you choose when configuring the bootstrap server. @@ -108,7 +107,8 @@ Basically, the initialization process consists of the following steps: `````{tabs} ````{group-tab} Authentication tokens (recommended) - If you configured your cluster to use {ref}`authentication tokens `, you must generate a join token for each new member. + Generate a cluster join token for each new member. + To do so, run the following command on an existing cluster member (for example, the bootstrap server): lxc cluster add @@ -120,7 +120,7 @@ Basically, the initialization process consists of the following steps: This reduces the amount of questions that you must answer during `lxd init`, because the join token can be used to answer these questions automatically. ```` ````{group-tab} Trust password - If you configured your cluster to use a {ref}`trust password `, `lxd init` requires more information about the cluster before it can start the authorization process: + If you configured your cluster to use a trust password, `lxd init` requires more information about the cluster before it can start the authorization process: 1. Specify a name for the new cluster member. 1. Provide the address of an existing cluster member (the bootstrap server or any other server you have already added). @@ -202,7 +202,7 @@ You need a different preseed file for every server. ### Initialize the bootstrap server -The required contents of the preseed file depend on whether you want to use {ref}`authentication tokens ` (recommended) or a {ref}`trust password ` for authentication. +The required contents of the preseed file depend on whether you want to use authentication tokens (recommended) or a trust password for authentication. `````{tabs} @@ -300,7 +300,7 @@ See {ref}`preseed-yaml-file-fields` for the complete fields of the preseed YAML ### Join additional servers -The required contents of the preseed files depend on whether you configured the bootstrap server to use {ref}`authentication tokens ` (recommended) or a {ref}`trust password ` for authentication. +The required contents of the preseed files depend on whether you configured the bootstrap server to use authentication tokens (recommended) or a trust password for authentication. The preseed files for new cluster members require only a `cluster` section with data and configuration values that are specific to the joining server. @@ -401,6 +401,7 @@ opyQ1VRpAg2sV2C4W8irbNqeUsTeZZxhLqp4vNOXXBBrSqUCdPu1JXADV0kavg1l See {ref}`preseed-yaml-file-fields` for the complete fields of the preseed YAML file. +(use-microcloud)= ## Use MicroCloud ```{youtube} https://www.youtube.com/watch?v=iWZYUU8lX5A diff --git a/doc/howto/cluster_recover.md b/doc/howto/cluster_recover.md index 4f119aab9cee..261522cd70be 100644 --- a/doc/howto/cluster_recover.md +++ b/doc/howto/cluster_recover.md @@ -2,47 +2,54 @@ # How to recover a cluster It might happen that one or several members of your cluster go offline or become unreachable. -In that case, no operations are possible on this member, and neither are operations that require a state change across all members. +If too many cluster members go offline, no operations will be possible on the cluster. See {ref}`clustering-offline-members` and {ref}`cluster-automatic-evacuation` for more information. -If you can bring the offline cluster members back or delete them from the cluster, operation resumes as normal. -If this is not possible, there are a few ways to recover the cluster, depending on the scenario that caused the failure. -See the following sections for details. +If you can bring the offline cluster members back up, operation resumes as normal. +If the cluster members are lost permanently (e.g. disk failure), it is possible +to recover any remaining cluster members. ```{note} -When your cluster is in a state that needs recovery, most `lxc` commands do not work, because the LXD client cannot connect to the LXD daemon. +When your cluster is in a state that needs recovery, most `lxc` commands do not +work because the LXD database does not respond when a majority of database +voters are inaccessible. + +The commands to recover a cluster are provided directly by the LXD daemon (`lxd`) +because they modify database files directly instead of making requests to the +LXD daemon. -Therefore, the commands to recover the cluster are provided directly by the LXD daemon (`lxd`). Run `lxd cluster --help` for an overview of all available commands. ``` -## Recover from quorum loss +## Database members Every LXD cluster has a specific number of members (configured through {config:option}`server-cluster:cluster.max_voters`) that serve as voting members of the distributed database. -If you permanently lose a majority of these cluster members (for example, you have a three-member cluster and you lose two members), the cluster loses quorum and becomes unavailable. -However, if at least one database member survives, it is possible to recover the cluster. +If you lose a majority of these cluster members (for example, you have a three-member cluster and you lose two members), the cluster loses quorum and becomes unavailable. -To do so, complete the following steps: +To determine which members have (or had) database roles, log on to any surviving member of your cluster and run the following command: -1. Log on to any surviving member of your cluster and run the following command: + sudo lxd cluster list-database + +## Recover from quorum loss + +```{note} +LXD automatically takes a backup of the database before making changes (see {ref}`automated_backups`). +``` - sudo lxd cluster list-database +If only one cluster member with the database role survives, complete the following +steps. See [Reconfigure the cluster](#reconfigure-the-cluster) below for recovering +more than one member. - This command shows which cluster members have one of the database roles. -1. Pick one of the listed database members that is still online as the new leader. - Log on to the machine (if it differs from the one you are already logged on to). 1. Make sure that the LXD daemon is not running on the machine. For example, if you're using the snap: sudo snap stop lxd -1. Log on to all other cluster members that are still online and stop the LXD daemon. -1. On the server that you picked as the new leader, run the following command: +1. Use the following command to reconfigure the database: sudo lxd cluster recover-from-quorum-loss -1. Start the LXD daemon again on all machines, starting with the new leader. - For example, if you're using the snap: +1. Start the LXD daemon again. For example, if you're using the snap: sudo snap start lxd @@ -54,25 +61,32 @@ This can help you with further recovery steps if you need to re-create the lost To permanently delete the cluster members that you have lost, force-remove them. See {ref}`cluster-manage-delete-members`. -## Recover cluster members with changed addresses +## Reconfigure the cluster + +```{note} +LXD automatically takes a backup of the database before making changes (see {ref}`automated_backups`). +``` If some members of your cluster are no longer reachable, or if the cluster itself is unreachable due to a change in IP address or listening port number, you can reconfigure the cluster. -To do so, edit the cluster configuration on each member of the cluster and change the IP addresses or listening port numbers as required. -You cannot remove any members during this process. -The cluster configuration must contain the description of the full cluster, so you must do the changes for all cluster members on all cluster members. +To do so, choose the {ref}`most up-to-date database member ` to edit the cluster configuration. +Once the cluster edit is complete you will need to manually copy the reconfigured global database to every other surviving member. + +You can change the IP addresses or listening port numbers for each member as required. +You cannot add or remove any members during this process. +The cluster configuration must contain the description of the full cluster. -You can edit the {ref}`clustering-member-roles` of the different members, but with the following limitations: +You can edit the {ref}`clustering-member-roles` of the members, but with the following limitations: - A cluster member that does not have a `database*` role cannot become a voter, because it might lack a global database. - At least two members must remain voters (except in the case of a two-member cluster, where one voter suffices), or there will be no quorum. -Log on to each cluster member and complete the following steps: - -1. Stop the LXD daemon. +Before performing the recovery, stop the LXD daemon on all surviving cluster members. For example, if you're using the snap: - sudo snap stop lxd + sudo snap stop lxd + +Complete the following steps on one database member: 1. Run the following command: @@ -100,15 +114,51 @@ Log on to each cluster member and complete the following steps: You can edit the addresses and the roles. -After doing the changes on all cluster members, start the LXD daemon on all members again. -For example, if you're using the snap: +1. When the cluster configuration has been changed on one member, LXD will create + a tarball of the global database (`/var/snap/lxd/common/lxd/database/lxd_recovery_db.tar.gz` + for snap installations or `/var/lib/lxd/database/lxd_recovery_db.tar.gz`). + Copy this recovery tarball to the same path on all remaining cluster members. + + ```{note} + The tarball can be removed from the first member after it is generated, but + it does not have to be. + ``` - sudo snap start lxd +1. Once the tarball has been copied to all remaining cluster members, start the + LXD daemon on all members again. LXD will load the recovery tarball on startup. + + If you're using the snap: + + sudo snap start lxd -The cluster should now be fully available again with all members reporting in. +The cluster should now be fully available again with all surviving members reporting in. No information has been deleted from the database. All information about the cluster members and their instances is still there. +(automated_backups)= +## Automated Backups +LXD automatically creates a backup of the database before making changes during +recovery. The backup is just a tarball of `/var/snap/lxd/common/lxd/database` +(for snap users) or `/var/lib/lxd/lxd/database` (otherwise). To reset the state +of the database in case of a failure, simply delete the database directory and +unpack the tarball in its place: + + cd /var/snap/lxd/common/lxd + sudo rm -r database + sudo tar -xf db_backup.TIMESTAMP.tar.gz + +(up-to-date_cluster_member)= +## Find the most up-to-date cluster member +On every shutdown, LXD's {ref}`database members ` log +the Raft term and index: + + Dqlite last entry index=1039 term=672 + +To determine which database member is most up to date: + +- If two members have different terms, the member with the higher term is more up to date. +- If two members have the same term, the member with the higher index is more up to date. + ## Manually alter Raft membership In some situations, you might need to manually alter the Raft membership configuration of the cluster because of some unexpected behavior. diff --git a/doc/howto/container_gpu_passthrough_with_docker.md b/doc/howto/container_gpu_passthrough_with_docker.md new file mode 100644 index 000000000000..5c2a1084a6a2 --- /dev/null +++ b/doc/howto/container_gpu_passthrough_with_docker.md @@ -0,0 +1,150 @@ +(container-gpu-passthrough-with-docker)= +# How to pass an NVIDIA GPU to a container + +If you have an NVIDIA GPU (either discrete (dGPU) or integrated (iGPU)) and you want to pass the runtime libraries and configuration installed on your host to your container, you should add a {ref}`LXD GPU device `. +Consider the following scenario: + +Your host is an NVIDIA single board computer that has a Tegra SoC with an iGPU, and you have the Tegra SDK installed on the host. You want to create a LXD container and run an application inside the container using the iGPU as a compute backend. You want to run this application inside a Docker container (or another OCI-compliant runtime). +To achieve this, complete the following steps: + +1. Running a Docker container inside a LXD container can potentially consume a lot of disk space if the outer container is not well configured. Here are two options you can use to optimize the consumed disk space: + + - Either you create a BTRFS storage pool to back the LXD container so that the Docker image later used does not use the VFS storage driver which is very space inefficient, then you initialize the LXD container with {config:option}`instance-security:security.nesting` enabled (needed for running a Docker container inside a LXD container) and using the BTRFS storage pool: + + lxc storage create p1 btrfs size=15GiB + lxc init ubuntu:24.04 t1 --config security.nesting=true -s p1 + + - Or you use the `overlayFS` storage driver in Docker but you need to specify the following syscall interceptions, still with the {config:option}`instance-security:security.nesting` enabled: + + lxc init ubuntu:24.04 t1 --config security.nesting=true --config security.syscalls.intercept.mknod=true --config security.syscalls.intercept.setxattr=true + +1. Add the GPU device to your container: + + - If you want to do an iGPU pass-through: + + lxc config device add t1 igpu0 gpu gputype=physical id=nvidia.com/igpu=0 + + - If you want to do a dGPU pass-through: + + lxc config device add t1 gpu0 gpu gputype=physical id=nvidia.com/gpu=0 + +After adding the device, let's try to run a basic [MNIST](https://en.wikipedia.org/wiki/MNIST_database) inference job inside our LXD container. + +1. Create a `cloud-init` script that installs the Docker runtime, the [NVIDIA Container Toolkit](https://github.com/NVIDIA/nvidia-container-toolkit), and a script to run a test [TensorRT](https://github.com/NVIDIA/TensorRT) workload: + + #cloud-config + package_update: true + write_files: + # `run_tensorrt.sh` compiles samples TensorRT applications and run the the `sample_onnx_mnist` program which loads an ONNX model into the TensorRT inference server and execute a digit recognition job. + - path: /root/run_tensorrt.sh + permissions: "0755" + owner: root:root + content: | + #!/bin/bash + echo "OS release,Kernel version" + (. /etc/os-release; echo "${PRETTY_NAME}"; uname -r) | paste -s -d, + echo + nvidia-smi -q + echo + exec bash -o pipefail -c " + cd /workspace/tensorrt/samples + make -j4 + cd /workspace/tensorrt/bin + ./sample_onnx_mnist + retstatus=\${PIPESTATUS[0]} + echo \"Test exited with status code: \${retstatus}\" >&2 + exit \${retstatus} + " + runcmd: + # Install Docker to run the AI workload + - curl -fsSL https://get.docker.com -o install-docker.sh + - sh install-docker.sh --version 24.0 + # The following installs the NVIDIA container toolkit + # as explained in the official doc website: https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html#installing-with-apt + - curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg + - curl -fsSL https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | sed -e 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' -e '/experimental/ s/^#//g' | tee /etc/apt/sources.list.d/nvidia-container-toolkit.list + # Now that an new apt source/key was added, update the package definitions. + - apt-get update + # Install NVIDIA container toolkit + - DEBIAN_FRONTEND=noninteractive apt-get install -y nvidia-container-toolkit + # Ultimately, we need to tell Docker, our container runtime, to use `nvidia-ctk` as a runtime. + - nvidia-ctk runtime configure --runtime=docker --config=/etc/docker/daemon.json + - systemctl restart docker + +1. Apply this `cloud-init` setup to your instance: + + lxc config set t1 cloud-init.user-data - < cloud-init.yml + +1. Start the instance: + + lxc start t1 + +1. Wait for the `cloud-init` process to finish: + + lxc exec t1 -- cloud-init status --wait + +1. Once `cloud-init` is finished, open a shell in the instance: + + lxc exec t1 -- bash + +1. Edit the NVIDIA container runtime to avoid using `cgroups`: + + sudo nvidia-ctk config --in-place --set nvidia-container-cli.no-cgroups + +1. If you use an iGPU and your NVIDIA container runtime is not automatically enabled with CSV mode (needed for NVIDIA Tegra board), enable it manually: + + sudo nvidia-ctk config --in-place --set nvidia-container-runtime.mode=csv + +1. Now, run the inference workload with Docker: + + - If you set up a dGPU pass-through: + + docker run --gpus all --runtime nvidia --rm -v $(pwd):/sh_input nvcr.io/nvidia/tensorrt:24.02-py3 bash /sh_input/run_tensorrt.sh + + - If you set up an iGPU pass-through: + + docker run --gpus all --runtime nvidia --rm -v $(pwd):/sh_input nvcr.io/nvidia/tensorrt:24.02-py3-igpu bash /sh_input/run_tensorrt.sh + + In the end you should see something like: + + @@@@@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@= ++++#++=*@@@@@ + @@@@@@@@#. *@@@@@ + @@@@@@@@= *@@@@@ + @@@@@@@@. .. ...****%@@@@@ + @@@@@@@@: .%@@#@@@@@@@@@@@@@ + @@@@@@@% -@@@@@@@@@@@@@@@@@ + @@@@@@@% -@@*@@@*@@@@@@@@@@ + @@@@@@@# :#- ::. ::=@@@@@@@ + @@@@@@@- -@@@@@@ + @@@@@@%. *@@@@@ + @@@@@@# :==*+== *@@@@@ + @@@@@@%---%%@@@@@@@. *@@@@@ + @@@@@@@@@@@@@@@@@@@+ *@@@@@ + @@@@@@@@@@@@@@@@@@@= *@@@@@ + @@@@@@@@@@@@@@@@@@* *@@@@@ + @@@@@%+%@@@@@@@@%. .%@@@@@ + @@@@@* .******= -@@@@@@@ + @@@@@* .#@@@@@@@ + @@@@@* =%@@@@@@@@ + @@@@@@%#+++= =@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@@@@ + + [07/31/2024-13:19:21] [I] Output: + [07/31/2024-13:19:21] [I] Prob 0 0.0000 Class 0: + [07/31/2024-13:19:21] [I] Prob 1 0.0000 Class 1: + [07/31/2024-13:19:21] [I] Prob 2 0.0000 Class 2: + [07/31/2024-13:19:21] [I] Prob 3 0.0000 Class 3: + [07/31/2024-13:19:21] [I] Prob 4 0.0000 Class 4: + [07/31/2024-13:19:21] [I] Prob 5 1.0000 Class 5: ********** + [07/31/2024-13:19:21] [I] Prob 6 0.0000 Class 6: + [07/31/2024-13:19:21] [I] Prob 7 0.0000 Class 7: + [07/31/2024-13:19:21] [I] Prob 8 0.0000 Class 8: + [07/31/2024-13:19:21] [I] Prob 9 0.0000 Class 9: + [07/31/2024-13:19:21] [I] + &&&& PASSED TensorRT.sample_onnx_mnist [TensorRT v8603] # ./sample_onnx_mnist diff --git a/doc/howto/import_machines_to_instances.md b/doc/howto/import_machines_to_instances.md index e017394d7f04..7682ea96bbc4 100644 --- a/doc/howto/import_machines_to_instances.md +++ b/doc/howto/import_machines_to_instances.md @@ -54,12 +54,17 @@ The tool can also inject the required VIRTIO drivers into the image: Expand to see how to convert your Windows VM using virt-v2v Use `virt-v2v` to convert Windows image into `raw` format and include the required drivers. + The resulting image is suitable for use with `lxd-migrate`. ``` - # Example 1. Convert a vmdk disk image to a raw image suitable for lxd-migrate + # Example 1. Convert a VMDK image to a raw image + sudo virt-v2v --block-driver virtio-scsi -o local -of raw -os ./os -i disk -if vmdk test-vm-disk.vmdk + + # Example 2. Convert a QEMU/KVM qcow2 image to a raw image + sudo virt-v2v --block-driver virtio-scsi -o local -of raw -os ./os -i disk -if qcow2 test-vm-disk.qcow2 + + # Example 3. Convert a VMX image to a raw image sudo virt-v2v --block-driver virtio-scsi -o local -of raw -os ./os -i vmx ./test-vm.vmx - # Example 2. Convert a QEMU/KVM qcow2 image and integrate virtio-scsi driver - sudo virt-v2v --block-driver virtio-scsi -o local -of raw -os ./os -if qcow2 -i disk test-vm-disk.qcow2 ``` You can find the resulting image in the `os` directory and use it with `lxd-migrate` on the next steps. diff --git a/doc/howto/initialize.md b/doc/howto/initialize.md index d6c7e74c3670..53048cc7f4fc 100644 --- a/doc/howto/initialize.md +++ b/doc/howto/initialize.md @@ -26,7 +26,7 @@ Clustering (see {ref}`exp-clustering` and {ref}`cluster-form`) The default answer is `no`, which means clustering is not enabled. If you answer `yes`, you can either connect to an existing cluster or create one. -MAAS support (see [`maas.io`](https://maas.io/) and [MAAS - Setting up LXD for VMs](https://maas.io/docs/setting-up-lxd-for-vms)) +MAAS support (see [`maas.io`](https://maas.io/) and [MAAS - Setting up LXD for VMs](https://maas.io/docs/how-to-use-lxd-vms)) : MAAS is an open-source tool that lets you build a data center from bare-metal servers. The default answer is `no`, which means MAAS support is not enabled. diff --git a/doc/howto/projects_confine.md b/doc/howto/projects_confine.md index 065b3034b928..727afae4263b 100644 --- a/doc/howto/projects_confine.md +++ b/doc/howto/projects_confine.md @@ -1,32 +1,37 @@ (projects-confine)= -# How to confine projects to specific users +# How to confine users to specific projects -You can use projects to confine the activities of different users or clients. -See {ref}`projects-confined` for more information. +You restrict users or clients to specific projects. +Projects can be configured with features, limits, and restrictions to prevent misuse. +See {ref}`exp-projects` for more information. -How to confine a project to a specific user depends on the authentication method you choose. +How to confine users to specific projects depends on whether LXD is accessible via the {ref}`HTTPS API `, or via the {ref}`Unix socket `. -## Confine projects to specific TLS clients +(projects-confine-https)= +## Confine users to specific projects on the HTTPS API +You can confine access to specific projects by restricting the TLS client certificate that is used to connect to the LXD server. +See {ref}`restricted-tls-certs` for more information. +Only certificates returned by `lxc config trust list` can be managed in this way. ```{youtube} https://www.youtube.com/watch?v=4iNpiL-lrXU&t=525s ``` -You can confine access to specific projects by restricting the TLS client certificate that is used to connect to the LXD server. -See {ref}`authentication-tls-certs` for detailed information. - ```{note} -The UI does not currently support configuring project confinement. +The UI does not currently support configuring project confinement for certificates of this type. Use the CLI or API to set up confinement. ``` To confine the access from the time the client certificate is added, you must either use token authentication or add the client certificate to the server directly. If you use password authentication, you can restrict the client certificate only after it has been added. -Follow these instructions: +You can also confine access to specific projects via group membership and {ref}`fine-grained-authorization`. +The permissions of OIDC clients and fine-grained TLS identities must be managed with `lxc auth` subcommands and the `/1.0/auth` API. + +To create a TLS client and restrict the client to a single project, follow these instructions: `````{tabs} ````{group-tab} CLI - +##### Create a restricted trust store entry with access to a project If you're using token authentication: lxc config trust add --projects --restricted @@ -49,8 +54,30 @@ This configuration pre-selects the specified project. However, it does not confine the client to this project. ``` +##### Create a fine-grained TLS identity with access to a project +First create a group and grant the group the `operator` entitlement on the project. + + lxc auth group create + lxc auth group permission add project operator + +The `operator` entitlement grants members of the group permission to create and edit resources belonging to that project, but does not grant permission to delete the project or edit its configuration. +See {ref}`fine-grained-authorization` for more details. + +Next create a TLS identity and add the identity to the group: + + lxc auth identity create tls/ [] --group + +If `` is provided the identity will be created directly. +Otherwise, a token will be returned that the client can use to add the LXD server as a remote: + + # Client machine + lxc remote add + +The client will be prompted with a list of projects to use as their default project. +Only the configured project will be presented to the client. ```` ````{group-tab} API +##### Create a restricted trust store entry with access to a project If you're using token authentication, create the token first: lxc query --request POST /1.0/certificates --data '{ @@ -58,7 +85,7 @@ If you're using token authentication, create the token first: "projects": [""] "restricted": true, "token": true, - "type": "client", + "type": "client" }' % Include content from [/howto/server_expose.md](/howto/server_expose.md) @@ -75,15 +102,77 @@ To instead add the client certificate directly, send the following request: "projects": [""] "restricted": true, "token": false, - "type": "client", + "type": "client" }' The client can then authenticate using this trust token or client certificate and can only access the project or projects that have been specified. % Include content from [/howto/server_expose.md](/howto/server_expose.md) ```{include} /howto/server_expose.md - :start-after: - :end-before: + :start-after: + :end-before: +``` +% Include content from [/howto/server_expose.md](/howto/server_expose.md) +```{include} /howto/server_expose.md + :start-after: + :end-before: +``` + +**Create a fine-grained TLS identity with access to a project** + +First create a group and grant the group the `operator` entitlement on the project. + + lxc query --request POST /1.0/auth/groups --data '{ + "name": "", + }' + + lxc query --request PUT /1.0/auth/groups/ --data '{ + "permissions": [ + { + "entity_type": "project", + "url": "/1.0/projects/", + "entitlement": "operator" + } + ] + }' + +The `operator` entitlement grants members of the group permission to create and edit resources belonging to that project, but does not grant permission to delete the project or edit its configuration. +See {ref}`fine-grained-authorization` for more details. + +Next create a TLS identity and add the identity to the group: + + lxc query --request POST /1.0/auth/identities/tls --data '{ + "name": "", + "groups": [""], + "token": true + }' + +% Include content from [/howto/server_expose.md](/howto/server_expose.md) +```{include} /howto/server_expose.md + :start-after: + :end-before: +``` + +To instead add the client certificate directly, send the following request: + + lxc query --request POST /1.0/certificates --data '{ + "certificate": "", + "name": "", + "groups": [""] + }' + +If the certificate was added directly, the client is now authenticated with LXD. +If a token was used, the client must use it to add their certificate. + +% Include content from [/howto/server_expose.md](/howto/server_expose.md) +```{include} /howto/server_expose.md + :start-after: + :end-before: +``` +% Include content from [/howto/server_expose.md](/howto/server_expose.md) +```{include} /howto/server_expose.md + :start-after: + :end-before: ``` ```` ````` @@ -92,25 +181,83 @@ To confine access for an existing certificate: ````{tabs} ```{group-tab} CLI +**Trust store entry** + Use the following command: lxc config trust edit + +Make sure that `restricted` is set to `true` and specify the projects that the certificate should give access to under `projects`. + +**Fine-grained TLS or OIDC identity** + +Create a group with the `operator` entitlement on the project: + + lxc auth group create + lxc auth group permission add project operator + +Then add the group to the identity. For TLS identities run: + + lxc auth identity group add tls/ + +The `` must be unique. If it is not, the certificate fingerprint of the client can be used. + +For OIDC identities, run: + + lxc auth identity group add oidc/ + +The `` must be unique. If it is not, the email address of the client can be used. ``` ```{group-tab} API +**Trust store entry** + Send the following request: lxc query --request PATCH /1.0/certificates/ --data '{ "projects": [""], "restricted": true - }' + }' + +Make sure that `restricted` is set to `true` and specify the projects that the certificate should give access to under `projects`. + +**Fine-grained TLS or OIDC identity** + +Create a group with the `operator` entitlement on the project: + + lxc query --request POST /1.0/auth/groups --data '{ + "name": "", + }' + + lxc query --request PUT /1.0/auth/groups/ --data '{ + "permissions": [ + { + "entity_type": "project", + "url": "/1.0/projects/", + "entitlement": "operator" + } + ] + }' +Then add the group to the identity. For TLS identities run: + + lxc query --request PATCH /1.0/auth/identities/tls/ --data '{ + "groups": [""] + }' + +The `` must be unique. If it is not, the certificate fingerprint of the client can be used. + +For OIDC identities, run: + + lxc query --request PATCH /1.0/auth/identities/oidc/ --data '{ + "groups": [""] + }' + +The `` must be unique. If it is not, the email address of the client can be used. ``` ```` -Make sure that `restricted` is set to `true` and specify the projects that the certificate should give access to under `projects`. - (projects-confine-users)= -## Confine projects to specific LXD users +## Confine users to specific LXD projects via Unix socket ```{youtube} https://www.youtube.com/watch?v=6O0q3rSWr8A ``` diff --git a/doc/howto/server_expose.md b/doc/howto/server_expose.md index 0000a4c0e5e5..8f6aa852972b 100644 --- a/doc/howto/server_expose.md +++ b/doc/howto/server_expose.md @@ -70,22 +70,51 @@ To be able to access the remote API, clients must authenticate with the LXD serv There are several authentication methods; see {ref}`authentication` for detailed information. The recommended method is to add the client's TLS certificate to the server's trust store through a trust token. +There are two ways to create a token. +Create a *pending fine-grained TLS identity* if you would like to manage client permissions via {ref}`fine-grained-authorization`. +Create a *certificate add token* if you would like to grant the client full access to LXD, or manage their permissions via {ref}`restricted-tls-certs`. See {ref}`access-ui` for instructions on how to authenticate with the LXD server using the UI. To authenticate a CLI or API client using a trust token, complete the following steps: 1. On the server, generate a trust token. - ````{tabs} - ```{group-tab} CLI + `````{tabs} + ````{group-tab} CLI + There are currently two ways to retrieve a trust token in LXD. + + **Create a certificate add token** + To generate a trust token, enter the following command on the server: lxc config trust add Enter the name of the client that you want to add. The command generates and prints a token that can be used to add the client certificate. + + ```{note} + The recipient of this token will have full access to LXD. + To restrict the access of the client, you must use the `--restricted` flag. + See {ref}`projects-confine-https` for more details. + ``` + + **Create a pending fine-grained TLS identity** + + To create a pending fine-grained TLS identity, enter the following command on the server: + + lxc auth identity create tls/ + + The command generates and prints a token that can be used to add the client certificate. + + ```{note} + The recipient of this token is not authorized to perform any actions in the LXD server. + To grant access, the identity must be added to one or more groups with permissions assigned. + See {ref}`fine-grained-authorization`. ``` - ```{group-tab} API + ```` + ````{group-tab} API + **Create a certificate add token** + To generate a trust token, send a POST request to the `/1.0/certificates` endpoint: lxc query --request POST /1.0/certificates --data '{ @@ -110,7 +139,7 @@ To authenticate a CLI or API client using a trust token, complete the following ... "secret": "" }, - ... + ... } Use this information to generate the trust token: @@ -119,8 +148,40 @@ To authenticate a CLI or API client using a trust token, complete the following '"addresses":[""],'\ '"secret":"","expires_at":"0001-01-01T00:00:00Z"}' | base64 -w0 - ``` + + **Create a pending fine-grained TLS identity** + + To generate a trust token, send a POST request to the `/1.0/auth/identities/tls` endpoint: + + lxc query --request POST /1.0/auth/identities/tls --data '{ + "name": "", + "token": true + }' + + + See [`POST /1.0/auth/identities/tls`](swagger:/auth/identitites/identities_post_tls) for more information. + + The return value of this query contains the information that is required to generate the trust token: + + { + "client_name": "", + "addresses": [ + "" + ], + "expires_at": "" + "fingerprint": "", + "type": "", + "secret": "" + } + + Use this information to generate the trust token: + + echo -n '{"client_name":"","fingerprint":"",'\ + '"addresses":[""],'\ + '"secret":"","expires_at":"0001-01-01T00:00:00Z","type":""}' | base64 -w0 + ```` + ````` 1. Authenticate the client. @@ -130,19 +191,31 @@ To authenticate a CLI or API client using a trust token, complete the following lxc remote add - % Include content from [../authentication.md](../authentication.md) - ```{include} ../authentication.md - :start-after: - :end-before: + ```{note} + If your LXD server is behind NAT, you must specify its external public address when adding it as a remote for a client: + + lxc remote add + + When you are prompted for the token, specify the generated token from the previous step. + Alternatively, use the `--token` flag: + + lxc remote add --token + + When generating the token on the server, LXD includes a list of IP addresses that the client can use to access the server. + However, if the server is behind NAT, these addresses might be local addresses that the client cannot connect to. + In this case, you must specify the external address manually. ``` ```` ````{group-tab} API - + On the client, generate a certificate to use for the connection: openssl req -x509 -newkey rsa:2048 -keyout "" -nodes \ -out "" -subj "/CN=" + + **Trust store entries** + Then send a POST request to the `/1.0/certificates?public` endpoint to authenticate: curl -k -s --key "" --cert "" \ @@ -150,7 +223,17 @@ To authenticate a CLI or API client using a trust token, complete the following --data '{ "password": "" }' See [`POST /1.0/certificates?public`](swagger:/certificates/certificates_post_untrusted) for more information. - + + **TLS identities** + + Send a POST request to the `/1.0/auth/identities/tls?public` endpoint to authenticate: + + curl --insecure --key "" --cert "" \ + -X POST https:///1.0/auth/identities/tls \ + --data '{ "trust_token": "" }' + + See [`POST /1.0/auth/identities/tls?public`](swagger:/auth/identities/identities_post_tls_untrusted) for more information. + ```` ````` diff --git a/doc/howto/storage_volumes.md b/doc/howto/storage_volumes.md index 6a305c1bafb4..329eb8ac61b6 100644 --- a/doc/howto/storage_volumes.md +++ b/doc/howto/storage_volumes.md @@ -53,6 +53,7 @@ The following restrictions apply: - To avoid data corruption, storage volumes of {ref}`content type ` `block` should never be attached to more than one virtual machine at a time. - Storage volumes of {ref}`content type ` `iso` are always read-only, and can therefore be attached to more than one virtual machine at a time without corrupting data. - File system storage volumes can't be attached to virtual machines while they're running. +- Custom block storage volumes that don't have `security.shared` enabled cannot be attached to more than one instance at the same time and neither can be attached to profiles. For custom storage volumes with the content type `filesystem`, use the following command, where `` is the path for accessing the storage volume inside the instance (for example, `/data`): diff --git a/doc/instances.md b/doc/instances.md index 402281d3cf31..f729aa0eb72c 100644 --- a/doc/instances.md +++ b/doc/instances.md @@ -57,6 +57,16 @@ How to import instances: :diataxis:Migrate from LXC ``` +```{only} diataxis +How to pass an NVIDIA GPU to a container with a Docker workload: +``` + +```{filtered-toctree} +:titlesonly: + +:diataxis:Pass NVIDIA GPUs +``` + ## Related topics ```{only} diataxis diff --git a/doc/metadata.txt b/doc/metadata.txt index d98f49ce85fe..b3c9e898acc7 100644 --- a/doc/metadata.txt +++ b/doc/metadata.txt @@ -275,9 +275,15 @@ You can omit the `MIG-` prefix when specifying this option. ``` ```{config:option} id device-gpu-physical-device-conf -:shortdesc: "DRM card ID of the GPU device" +:shortdesc: "ID of the GPU device" :type: "string" +The ID can either be the DRM card ID of the GPU device (container or VM) or a fully-qualified Container Device Interface (CDI) name (container only). +Here are some examples of fully-qualified CDI names: +- `nvidia.com/gpu=0`: Instructs LXD to operate a discrete GPU (dGPU) pass-through of brand NVIDIA with the first discovered GPU on your system. You can use the `nvidia-smi` tool on your host to find out which identifier to use. +- `nvidia.com/gpu=1833c8b5-9aa0-5382-b784-68b7e77eb185`: Instructs LXD to operate a discrete GPU (dGPU) pass-through of brand NVIDIA with a given GPU unique identifier. This identifier should also appear with `nvidia-smi -L`. +- `nvidia.com/igpu=all`: Instructs LXD to pass all the host integrated GPUs (iGPU) of brand NVIDIA. The concept of an index does not currently map to iGPUs. It is possible to list them with the `nvidia-smi -L` command. A special `nvgpu` mention should appear in the generated list to indicate a device to be an iGPU. +- `nvidia.com/gpu=all`: Instructs LXD to pass all the host GPUs of brand NVIDIA through to the container. ``` ```{config:option} mode device-gpu-physical-device-conf @@ -1411,7 +1417,7 @@ For example: `/dev/tpmrm0` ```{config:option} gid device-unix-block-device-conf :defaultdesc: "`0`" -:shortdesc: "GID of the device owner in the instance" +:shortdesc: "GID of the device owner in the container" :type: "integer" ``` @@ -1432,21 +1438,21 @@ For example: `/dev/tpmrm0` ```{config:option} mode device-unix-block-device-conf :defaultdesc: "`0660`" -:shortdesc: "Mode of the device in the instance" +:shortdesc: "Mode of the device in the container" :type: "integer" ``` ```{config:option} path device-unix-block-device-conf :required: "either `source` or `path` must be set" -:shortdesc: "Path inside the instance" +:shortdesc: "Path inside the container" :type: "string" ``` ```{config:option} required device-unix-block-device-conf :defaultdesc: "`true`" -:shortdesc: "Whether this device is required to start the instance" +:shortdesc: "Whether this device is required to start the container" :type: "bool" See {ref}`devices-unix-block-hotplugging` for more information. ``` @@ -1460,7 +1466,7 @@ See {ref}`devices-unix-block-hotplugging` for more information. ```{config:option} uid device-unix-block-device-conf :defaultdesc: "`0`" -:shortdesc: "UID of the device owner in the instance" +:shortdesc: "UID of the device owner in the container" :type: "integer" ``` @@ -1469,7 +1475,7 @@ See {ref}`devices-unix-block-hotplugging` for more information. ```{config:option} gid device-unix-char-device-conf :defaultdesc: "`0`" -:shortdesc: "GID of the device owner in the instance" +:shortdesc: "GID of the device owner in the container" :type: "integer" ``` @@ -1490,21 +1496,21 @@ See {ref}`devices-unix-block-hotplugging` for more information. ```{config:option} mode device-unix-char-device-conf :defaultdesc: "`0660`" -:shortdesc: "Mode of the device in the instance" +:shortdesc: "Mode of the device in the container" :type: "integer" ``` ```{config:option} path device-unix-char-device-conf :required: "either `source` or `path` must be set" -:shortdesc: "Path inside the instance" +:shortdesc: "Path inside the container" :type: "string" ``` ```{config:option} required device-unix-char-device-conf :defaultdesc: "`true`" -:shortdesc: "Whether this device is required to start the instance" +:shortdesc: "Whether this device is required to start the container" :type: "bool" See {ref}`devices-unix-char-hotplugging` for more information. ``` @@ -1518,7 +1524,7 @@ See {ref}`devices-unix-char-hotplugging` for more information. ```{config:option} uid device-unix-char-device-conf :defaultdesc: "`0`" -:shortdesc: "UID of the device owner in the instance" +:shortdesc: "UID of the device owner in the container" :type: "integer" ``` @@ -1527,14 +1533,14 @@ See {ref}`devices-unix-char-hotplugging` for more information. ```{config:option} gid device-unix-hotplug-device-conf :defaultdesc: "`0`" -:shortdesc: "GID of the device owner in the instance" +:shortdesc: "GID of the device owner in the container" :type: "integer" ``` ```{config:option} mode device-unix-hotplug-device-conf :defaultdesc: "`0660`" -:shortdesc: "Mode of the device in the instance" +:shortdesc: "Mode of the device in the container" :type: "integer" ``` @@ -1547,14 +1553,14 @@ See {ref}`devices-unix-char-hotplugging` for more information. ```{config:option} required device-unix-hotplug-device-conf :defaultdesc: "`false`" -:shortdesc: "Whether this device is required to start the instance" +:shortdesc: "Whether this device is required to start the container" :type: "bool" The default is `false`, which means that all devices can be hotplugged. ``` ```{config:option} uid device-unix-hotplug-device-conf :defaultdesc: "`0`" -:shortdesc: "UID of the device owner in the instance" +:shortdesc: "UID of the device owner in the container" :type: "integer" ``` @@ -1582,7 +1588,7 @@ The default is `false`, which means that all devices can be hotplugged. ```{config:option} gid device-unix-usb-device-conf :condition: "container" :defaultdesc: "`0`" -:shortdesc: "GID of the device owner in the container" +:shortdesc: "GID of the device owner in the instance" :type: "integer" ``` @@ -1590,7 +1596,7 @@ The default is `false`, which means that all devices can be hotplugged. ```{config:option} mode device-unix-usb-device-conf :condition: "container" :defaultdesc: "`0660`" -:shortdesc: "Mode of the device in the container" +:shortdesc: "Mode of the device in the instance" :type: "integer" ``` @@ -1617,7 +1623,7 @@ The default is `false`, which means that all devices can be hotplugged. ```{config:option} uid device-unix-usb-device-conf :condition: "container" :defaultdesc: "`0`" -:shortdesc: "UID of the device owner in the container" +:shortdesc: "UID of the device owner in the instance" :type: "integer" ``` @@ -1638,7 +1644,7 @@ If set to `false`, restore the last state. ``` ```{config:option} boot.autostart.delay instance-boot -:defaultdesc: "0" +:defaultdesc: "`0`" :liveupdate: "no" :shortdesc: "Delay after starting the instance" :type: "integer" @@ -1646,7 +1652,7 @@ The number of seconds to wait after the instance started before starting the nex ``` ```{config:option} boot.autostart.priority instance-boot -:defaultdesc: "0" +:defaultdesc: "`0`" :liveupdate: "no" :shortdesc: "What order to start the instances in" :type: "integer" @@ -1661,7 +1667,7 @@ A log file can be found in `$LXD_DIR/logs//edk2.log`. ``` ```{config:option} boot.host_shutdown_timeout instance-boot -:defaultdesc: "30" +:defaultdesc: "`30`" :liveupdate: "yes" :shortdesc: "How long to wait for the instance to shut down" :type: "integer" @@ -1669,7 +1675,7 @@ Number of seconds to wait for the instance to shut down before it is force-stopp ``` ```{config:option} boot.stop.priority instance-boot -:defaultdesc: "0" +:defaultdesc: "`0`" :liveupdate: "no" :shortdesc: "What order to shut down the instances in" :type: "integer" @@ -1834,19 +1840,6 @@ Possible values are `boot` (load the modules when booting the container) and `on ``` -```{config:option} ubuntu_pro.guest_attach instance-miscellaneous -:liveupdate: "no" -:shortdesc: "Whether to auto-attach Ubuntu Pro." -:type: "string" -Indicate whether the guest should auto-attach Ubuntu Pro at start up. -The allowed values are `off`, `on`, and `available`. -If set to `off`, it will not be possible for the Ubuntu Pro client in the guest to obtain guest token via `devlxd`. -If set to `available`, attachment via guest token is possible but will not be performed automatically by the Ubuntu Pro client in the guest at startup. -If set to `on`, attachment will be performed automatically by the Ubuntu Pro client in the guest at startup. -To allow guest attachment, the host must be an Ubuntu machine that is Pro attached, and guest attachment must be enabled via the Pro client. -To do this, run `pro config set lxd_guest_attach=on`. -``` - ```{config:option} user.* instance-miscellaneous :liveupdate: "no" :shortdesc: "Free-form user key/value storage" @@ -1972,6 +1965,18 @@ A comma-separated list of NUMA node IDs or ranges to place the instance CPUs on. See {ref}`instance-options-limits-cpu-container` for more information. ``` +```{config:option} limits.cpu.pin_strategy instance-resource-limits +:condition: "virtual machine" +:defaultdesc: "`none`" +:liveupdate: "no" +:shortdesc: "VM CPU auto pinning strategy" +:type: "string" +Specify the strategy for VM CPU auto pinning. +Possible values: `none` (disables CPU auto pinning) and `auto` (enables CPU auto pinning). + +See {ref}`instance-options-limits-cpu-vm` for more information. +``` + ```{config:option} limits.cpu.priority instance-resource-limits :condition: "container" :defaultdesc: "`10` (maximum)" @@ -2126,7 +2131,7 @@ See {ref}`dev-lxd` for more information. ```{config:option} security.devlxd.images instance-security :defaultdesc: "`false`" -:liveupdate: "no" +:liveupdate: "yes" :shortdesc: "Controls the availability of the `/1.0/images` API over `devlxd`" :type: "bool" @@ -3411,7 +3416,7 @@ Specify a comma-separated list of DNS zone names. ```{config:option} ipv4.address network-ovn-network-conf :condition: "standard mode" :defaultdesc: "initial value on creation: `auto`" -:shortdesc: "IPv4 address for the bridge" +:shortdesc: "IPv4 address for the OVN network" :type: "string" Use CIDR notation. @@ -3452,7 +3457,7 @@ You can set the option to `none` to turn off IPv4, or to `auto` to generate a ne ```{config:option} ipv6.address network-ovn-network-conf :condition: "standard mode" :defaultdesc: "initial value on creation: `auto`" -:shortdesc: "IPv6 address for the bridge" +:shortdesc: "IPv6 address for the OVN network" :type: "string" Use CIDR notation. @@ -3921,6 +3926,17 @@ This value is the maximum value for the sum of the individual {config:option}`in This value is the maximum value of the aggregate disk space used by all instance volumes, custom volumes, and images of the project. ``` +```{config:option} limits.disk.pool.POOL_NAME project-limits +:shortdesc: "Maximum disk space used by the project on this pool" +:type: "string" +This value is the maximum value of the aggregate disk +space used by all instance volumes, custom volumes, and images of the +project on this specific storage pool. + +When set to 0, the pool is excluded from storage pool list for +the project. +``` + ```{config:option} limits.instances project-limits :shortdesc: "Maximum number of instances that can be created in the project" :type: "integer" @@ -4768,6 +4784,15 @@ prior to creating the storage pool. +```{config:option} security.shared storage-btrfs-volume-conf +:condition: "custom block volume" +:defaultdesc: "same as `volume.security.shared` or `false`" +:shortdesc: "Enable volume sharing" +:type: "bool" +Enabling this option allows sharing the volume across multiple instances despite the possibility of data loss. + +``` + ```{config:option} security.shifted storage-btrfs-volume-conf :condition: "custom volume" :defaultdesc: "same as `volume.security.shifted` or `false`" @@ -4923,6 +4948,15 @@ If not set, `ext4` is assumed. ``` +```{config:option} security.shared storage-ceph-volume-conf +:condition: "custom block volume" +:defaultdesc: "same as `volume.security.shared` or `false`" +:shortdesc: "Enable volume sharing" +:type: "bool" +Enabling this option allows sharing the volume across multiple instances despite the possibility of data loss. + +``` + ```{config:option} security.shifted storage-ceph-volume-conf :condition: "custom volume" :defaultdesc: "same as `volume.security.shifted` or `false`" @@ -5202,6 +5236,15 @@ to be placed on the socket I/O. +```{config:option} security.shared storage-dir-volume-conf +:condition: "custom block volume" +:defaultdesc: "same as `volume.security.shared` or `false`" +:shortdesc: "Enable volume sharing" +:type: "bool" +Enabling this option allows sharing the volume across multiple instances despite the possibility of data loss. + +``` + ```{config:option} security.shifted storage-dir-volume-conf :condition: "custom volume" :defaultdesc: "same as `volume.security.shifted` or `false`" @@ -5388,6 +5431,15 @@ If not set, `ext4` is assumed. The size must be at least 4096 bytes, and a multiple of 512 bytes. ``` +```{config:option} security.shared storage-lvm-volume-conf +:condition: "custom block volume" +:defaultdesc: "same as `volume.security.shared` or `false`" +:shortdesc: "Enable volume sharing" +:type: "bool" +Enabling this option allows sharing the volume across multiple instances despite the possibility of data loss. + +``` + ```{config:option} security.shifted storage-lvm-volume-conf :condition: "custom volume" :defaultdesc: "same as `volume.security.shifted` or `false`" @@ -5566,6 +5618,15 @@ If not set, `ext4` is assumed. ``` +```{config:option} security.shared storage-powerflex-volume-conf +:condition: "custom block volume" +:defaultdesc: "same as `volume.security.shared` or `false`" +:shortdesc: "Enable volume sharing" +:type: "bool" +Enabling this option allows sharing the volume across multiple instances despite the possibility of data loss. + +``` + ```{config:option} security.shifted storage-powerflex-volume-conf :condition: "custom volume" :defaultdesc: "same as `volume.security.shifted` or `false`" @@ -5711,6 +5772,15 @@ If not set, `ext4` is assumed. ``` +```{config:option} security.shared storage-zfs-volume-conf +:condition: "custom block volume" +:defaultdesc: "same as `volume.security.shared` or `false`" +:shortdesc: "Enable volume sharing" +:type: "bool" +Enabling this option allows sharing the volume across multiple instances despite the possibility of data loss. + +``` + ```{config:option} security.shifted storage-zfs-volume-conf :condition: "custom volume" :defaultdesc: "same as `volume.security.shifted` or `false`" @@ -5914,7 +5984,7 @@ container or containers that use it. This allows using the `zfs` command in the : Grants permission to delete the instance. `can_view` -: Grants permission to view the instance. +: Grants permission to view the instance and any snapshots or backups it might have. `can_update_state` : Grants permission to change the instance state. @@ -6246,6 +6316,9 @@ container or containers that use it. This allows using the `zfs` command in the `can_view_warnings` : Grants permission to view warnings. +`can_view_unmanaged_networks` +: Grants permission to view unmanaged networks on the LXD host machines. + @@ -6277,7 +6350,7 @@ container or containers that use it. This allows using the `zfs` command in the : Grants permission to delete the storage volume. `can_view` -: Grants permission to view the storage volume. +: Grants permission to view the storage volume and any snapshots or backups it might have. `can_manage_snapshots` : Grants permission to create and delete snapshots of the storage volume. diff --git a/doc/metrics.md b/doc/metrics.md index 3f0dfb9a98cc..cfb52de4d5c0 100644 --- a/doc/metrics.md +++ b/doc/metrics.md @@ -29,26 +29,66 @@ To view the raw data that LXD collects, use the [`lxc query`](lxc_query.md) comm ```{terminal} :input: lxc query /1.0/metrics -# HELP lxd_cpu_seconds_total The total number of CPU time used in seconds. -# TYPE lxd_cpu_seconds_total counter -lxd_cpu_seconds_total{cpu="0",mode="system",name="u1",project="default",type="container"} 60.304517 -lxd_cpu_seconds_total{cpu="0",mode="user",name="u1",project="default",type="container"} 145.647502 -lxd_cpu_seconds_total{cpu="0",mode="iowait",name="vm",project="default",type="virtual-machine"} 4614.78 -lxd_cpu_seconds_total{cpu="0",mode="irq",name="vm",project="default",type="virtual-machine"} 0 -lxd_cpu_seconds_total{cpu="0",mode="idle",name="vm",project="default",type="virtual-machine"} 412762 -lxd_cpu_seconds_total{cpu="0",mode="nice",name="vm",project="default",type="virtual-machine"} 35.06 -lxd_cpu_seconds_total{cpu="0",mode="softirq",name="vm",project="default",type="virtual-machine"} 2.41 -lxd_cpu_seconds_total{cpu="0",mode="steal",name="vm",project="default",type="virtual-machine"} 9.84 -lxd_cpu_seconds_total{cpu="0",mode="system",name="vm",project="default",type="virtual-machine"} 340.84 -lxd_cpu_seconds_total{cpu="0",mode="user",name="vm",project="default",type="virtual-machine"} 261.25 +# HELP lxd_api_requests_completed_total The total number of completed API requests. +# TYPE lxd_api_requests_completed_total counter +lxd_api_requests_completed_total{entity_type="server",result="error_client"} 0 +lxd_api_requests_completed_total{entity_type="server",result="succeeded"} 9 +lxd_api_requests_completed_total{entity_type="server",result="error_server"} 0 +lxd_api_requests_completed_total{entity_type="network",result="error_server"} 0 +lxd_api_requests_completed_total{entity_type="network",result="error_client"} 0 +lxd_api_requests_completed_total{entity_type="network",result="succeeded"} 0 +lxd_api_requests_completed_total{entity_type="cluster_member",result="error_server"} 0 +lxd_api_requests_completed_total{entity_type="cluster_member",result="error_client"} 0 +lxd_api_requests_completed_total{entity_type="cluster_member",result="succeeded"} 0 +lxd_api_requests_completed_total{entity_type="project",result="succeeded"} 0 +lxd_api_requests_completed_total{entity_type="project",result="error_server"} 0 +lxd_api_requests_completed_total{entity_type="project",result="error_client"} 0 +lxd_api_requests_completed_total{entity_type="image",result="error_server"} 0 +lxd_api_requests_completed_total{entity_type="image",result="error_client"} 0 +lxd_api_requests_completed_total{entity_type="image",result="succeeded"} 0 +lxd_api_requests_completed_total{entity_type="operation",result="error_server"} 0 +lxd_api_requests_completed_total{entity_type="operation",result="error_client"} 0 +lxd_api_requests_completed_total{entity_type="operation",result="succeeded"} 0 +lxd_api_requests_completed_total{entity_type="storage_pool",result="error_server"} 0 +lxd_api_requests_completed_total{entity_type="storage_pool",result="error_client"} 0 +lxd_api_requests_completed_total{entity_type="storage_pool",result="succeeded"} 0 +lxd_api_requests_completed_total{entity_type="warning",result="error_server"} 0 +lxd_api_requests_completed_total{entity_type="warning",result="error_client"} 0 +lxd_api_requests_completed_total{entity_type="warning",result="succeeded"} 0 +lxd_api_requests_completed_total{entity_type="identity",result="error_client"} 0 +lxd_api_requests_completed_total{entity_type="identity",result="succeeded"} 0 +lxd_api_requests_completed_total{entity_type="identity",result="error_server"} 0 +lxd_api_requests_completed_total{entity_type="profile",result="error_server"} 0 +lxd_api_requests_completed_total{entity_type="profile",result="error_client"} 0 +lxd_api_requests_completed_total{entity_type="profile",result="succeeded"} 0 +lxd_api_requests_completed_total{entity_type="instance",result="succeeded"} 2 +lxd_api_requests_completed_total{entity_type="instance",result="error_server"} 0 +lxd_api_requests_completed_total{entity_type="instance",result="error_client"} 0 +# HELP lxd_api_requests_ongoing The number of API requests currently being handled. +# TYPE lxd_api_requests_ongoing gauge +lxd_api_requests_ongoing{entity_type="server"} 1 +lxd_api_requests_ongoing{entity_type="network"} 0 +lxd_api_requests_ongoing{entity_type="cluster_member"} 0 +lxd_api_requests_ongoing{entity_type="project"} 0 +lxd_api_requests_ongoing{entity_type="image"} 0 +lxd_api_requests_ongoing{entity_type="operation"} 0 +lxd_api_requests_ongoing{entity_type="storage_pool"} 0 +lxd_api_requests_ongoing{entity_type="warning"} 0 +lxd_api_requests_ongoing{entity_type="identity"} 0 +lxd_api_requests_ongoing{entity_type="profile"} 0 +lxd_api_requests_ongoing{entity_type="instance"} 0 # HELP lxd_cpu_effective_total The total number of effective CPUs. # TYPE lxd_cpu_effective_total gauge -lxd_cpu_effective_total{name="u1",project="default",type="container"} 4 -lxd_cpu_effective_total{name="vm",project="default",type="virtual-machine"} 0 +lxd_cpu_effective_total{name="c",project="default",type="container"} 8 +# HELP lxd_cpu_seconds_total The total number of CPU time used in seconds. +# TYPE lxd_cpu_seconds_total counter +lxd_cpu_seconds_total{cpu="0",mode="system",name="c",project="default",type="container"} 1.53794 +lxd_cpu_seconds_total{cpu="0",mode="user",name="c",project="default",type="container"} 2.613658 # HELP lxd_disk_read_bytes_total The total number of bytes read. # TYPE lxd_disk_read_bytes_total counter -lxd_disk_read_bytes_total{device="loop5",name="u1",project="default",type="container"} 2048 -lxd_disk_read_bytes_total{device="loop3",name="vm",project="default",type="virtual-machine"} 353280 +lxd_disk_read_bytes_total{device="nvme0n1",name="c",project="default",type="container"} 3.6151296e+07 +# HELP lxd_disk_reads_completed_total The total number of completed reads. +# TYPE lxd_disk_reads_completed_total counter ... ``` diff --git a/doc/projects.md b/doc/projects.md index 58426689a1fb..141bc29fca1a 100644 --- a/doc/projects.md +++ b/doc/projects.md @@ -14,7 +14,7 @@ The following how-to guides cover common operations related to projects: :diataxis:Create and configure :diataxis:Work with projects -:diataxis:Confine projects to users +:diataxis:Confine users to projects ``` ## Related topics @@ -31,6 +31,6 @@ The following how-to guides cover common operations related to projects: :topical:/explanation/projects :topical:Create and configure projects :topical:Work with different projects -:topical:Confine projects to users +:topical:Confine users to projects :topical:/reference/projects ``` diff --git a/doc/reference/devices_gpu.md b/doc/reference/devices_gpu.md index c044dcc56236..374e82b0edbf 100644 --- a/doc/reference/devices_gpu.md +++ b/doc/reference/devices_gpu.md @@ -53,6 +53,26 @@ Add a specific GPU from the host system as a `physical` GPU device to an instanc See {ref}`instances-configure-devices` for more information. +#### CDI mode + +```{note} +The CDI mode is currently not supported on `armhf` architectures. +``` + +Add a specific GPU from the host system as a `physical` GPU device to an instance using the [Container Device Interface](https://github.com/cncf-tags/container-device-interface) (CDI) notation through a fully-qualified CDI name: + + lxc config device add gpu gputype=physical id= + +For example, add the first available NVIDIA discrete GPU on your system: + + lxc config device add gpu gputype=physical id=nvidia.com/gpu=0 + +If your machine has an NVIDIA iGPU (integrated GPU) located at index 0, you can add it like this: + + lxc config device add gpu gputype=physical id=nvidia.com/igpu=0 + +For a complete example on how to use a GPU CDI pass-through, see {ref}`container-gpu-passthrough-with-docker`. + (gpu-mdev)= ## `gputype`: `mdev` diff --git a/doc/reference/devices_unix_block.md b/doc/reference/devices_unix_block.md index d48401b7e1fc..8909f7211efc 100644 --- a/doc/reference/devices_unix_block.md +++ b/doc/reference/devices_unix_block.md @@ -10,7 +10,7 @@ The `unix-block` device type is supported for containers. It supports hotplugging. ``` -Unix block devices make the specified block device appear as a device in the instance (under `/dev`). +Unix block devices make the specified block device appear as a device in the container (under `/dev`). You can read from the device and write to it. ## Device options @@ -25,11 +25,11 @@ You can read from the device and write to it. ## Configuration examples -Add a `unix-block` device to an instance by specifying its source and path: +Add a `unix-block` device to a container by specifying its source and path: lxc config device add unix-block source= path= -If you want to use the same path on the instance as on the host, you can omit the `source` option: +If you want to use the same path on the container as on the host, you can omit the `source` option: lxc config device add unix-block path= diff --git a/doc/reference/devices_unix_char.md b/doc/reference/devices_unix_char.md index dfe51f7a9623..cc1b37ea9f43 100644 --- a/doc/reference/devices_unix_char.md +++ b/doc/reference/devices_unix_char.md @@ -10,7 +10,7 @@ The `unix-char` device type is supported for containers. It supports hotplugging. ``` -Unix character devices make the specified character device appear as a device in the instance (under `/dev`). +Unix character devices make the specified character device appear as a device in the container (under `/dev`). You can read from the device and write to it. ## Device options @@ -25,11 +25,11 @@ You can read from the device and write to it. ## Configuration examples -Add a `unix-char` device to an instance by specifying its source and path: +Add a `unix-char` device to a container by specifying its source and path: lxc config device add unix-char source= path= -If you want to use the same path on the instance as on the host, you can omit the `source` option: +If you want to use the same path on the container as on the host, you can omit the `source` option: lxc config device add unix-char path= diff --git a/doc/reference/devices_unix_hotplug.md b/doc/reference/devices_unix_hotplug.md index 0e56614be038..8421fdf4f1c9 100644 --- a/doc/reference/devices_unix_hotplug.md +++ b/doc/reference/devices_unix_hotplug.md @@ -10,7 +10,7 @@ The `unix-hotplug` device type is supported for containers. It supports hotplugging. ``` -Unix hotplug devices make the requested Unix device appear as a device in the instance (under `/dev`). +Unix hotplug devices make the requested Unix device appear as a device in the container (under `/dev`). If the device exists on the host system, you can read from it and write to it. The implementation depends on `systemd-udev` to be run on the host. @@ -27,7 +27,7 @@ The implementation depends on `systemd-udev` to be run on the host. ## Configuration examples -Add a `unix-hotplug` device to an instance by specifying its vendor ID and product ID: +Add a `unix-hotplug` device to a container by specifying its vendor ID and product ID: lxc config device add unix-hotplug vendorid= productid= diff --git a/doc/reference/instance_options.md b/doc/reference/instance_options.md index 652691b56a08..5055a6ce0efb 100644 --- a/doc/reference/instance_options.md +++ b/doc/reference/instance_options.md @@ -98,6 +98,9 @@ You have different options to limit CPU usage: - Set {config:option}`instance-resource-limits:limits.cpu.allowance` to restrict the load an instance can put on the available CPUs. This option is available only for containers. See {ref}`instance-options-limits-cpu-container` for how to set this option. +- Set {config:option}`instance-resource-limits:limits.cpu.pin_strategy` to specify the strategy for virtual-machine CPU auto pinning. + This option is available only for virtual machines. + See {ref}`instance-options-limits-cpu-vm` for how to set this option. It is possible to set both options at the same time to restrict both which CPUs are visible to the instance and the allowed usage of those instances. However, if you use {config:option}`instance-resource-limits:limits.cpu.allowance` with a time limit, you should avoid using {config:option}`instance-resource-limits:limits.cpu` in addition, because that puts a lot of constraints on the scheduler and might lead to less efficient allocations. @@ -116,6 +119,7 @@ You can specify either which CPUs or how many CPUs are visible and available to - If you specify a number (for example, `4`) of CPUs, LXD will do dynamic load-balancing of all instances that aren't pinned to specific CPUs, trying to spread the load on the machine. Instances are re-balanced every time an instance starts or stops, as well as whenever a CPU is added to the system. +(instance-options-limits-cpu-vm)= ##### CPU limits for virtual machines ```{note} @@ -127,10 +131,10 @@ Depending on the guest operating system, you might need to either restart the in LXD virtual machines default to having just one vCPU allocated, which shows up as matching the host CPU vendor and type, but has a single core and no threads. When {config:option}`instance-resource-limits:limits.cpu` is set to a single integer, LXD allocates multiple vCPUs and exposes them to the guest as full cores. -Those vCPUs are not pinned to specific physical cores on the host. +Unless {config:option}`instance-resource-limits:limits.cpu.pin_strategy` is set to `auto`, those vCPUs are not pinned to specific cores on the host. The number of vCPUs can be updated while the VM is running. -When {config:option}`instance-resource-limits:limits.cpu` is set to a range or comma-separated list of CPU IDs (as provided by [`lxc info --resources`](lxc_info.md)), the vCPUs are pinned to those physical cores. +When {config:option}`instance-resource-limits:limits.cpu` is set to a range or comma-separated list of CPU IDs (as provided by [`lxc info --resources`](lxc_info.md)), the vCPUs are pinned to those cores. In this scenario, LXD checks whether the CPU configuration lines up with a realistic hardware topology and if it does, it replicates that topology in the guest. When doing CPU pinning, it is not possible to change the configuration while the VM is running. diff --git a/doc/reference/provided_metrics.md b/doc/reference/provided_metrics.md index 2c25de82604d..fae1409d282b 100644 --- a/doc/reference/provided_metrics.md +++ b/doc/reference/provided_metrics.md @@ -100,6 +100,10 @@ The following internal metrics are provided: * - Metric - Description +* - `lxd_api_requests_completed_total` + - Total number of completed requests. See [API rates metrics](api-rates-metrics). +* - `lxd_api_requests_ongoing` + - Number of requests currently being handled. See [API rates metrics](api-rates-metrics). * - `lxd_go_alloc_bytes_total` - Total number of bytes allocated (even if freed) * - `lxd_go_alloc_bytes` @@ -154,6 +158,19 @@ The following internal metrics are provided: - Number of active warnings ``` +(api-rates-metrics)= +## API rates metrics + +The API rates metrics include `lxd_api_requests_completed_total` and `lxd_api_requests_ongoing`. These metrics can be consumed by an observability tool deployed externally (for example, the [Canonical Observability Stack](https://charmhub.io/topics/canonical-observability-stack) or another third-party tool) to help identify failures or overload on a LXD server. You can set thresholds on the observability tools for these metrics' values to trigger alarms and take programmatic actions. + +These metrics consider all endpoints in the [LXD REST API](../api), with the exception of the `/` endpoint. Requests using an invalid URL are also counted. Requests against the metrics server are also counted. Both introduced metrics include a label `entity_type` based on the main entity type that the endpoint is operating on. + +`lxd_api_requests_ongoing` contains the number of requests that are not yet completed by the time the metrics are queried. A request is considered completed when the response is returned to the client and any asynchronous operations spawned by that request are done. `lxd_api_requests_completed_total` contains the number of completed requests. This metric includes an additional label named `result` based on the outcome of the request. The label can have one of the following values: + +- `error_server`, for errors on the server side, this includes responses with HTTP status codes from 500 to 599. Any failed asynchronous operations also fall into this category. +- `error_client`, for responses with HTTP status codes from 400 to 499, indicating an error on the client side. +- `succeeded`, for endpoints that executed successfully. + ## Related topics How-to guides: diff --git a/doc/requirements.md b/doc/requirements.md index 3f9ae08384cb..a97b59a8defd 100644 --- a/doc/requirements.md +++ b/doc/requirements.md @@ -4,7 +4,7 @@ (requirements-go)= ## Go -LXD requires Go 1.22.7 or higher and is only tested with the Golang compiler. +LXD requires Go 1.23.3 or higher and is only tested with the Golang compiler. We recommend having at least 2GiB of RAM to allow the build to complete. diff --git a/doc/rest-api.yaml b/doc/rest-api.yaml index 4b9c42cb2655..c811f9e04e94 100644 --- a/doc/rest-api.yaml +++ b/doc/rest-api.yaml @@ -158,6 +158,11 @@ definitions: example: 2b2284d44db32675923fe0d2020477e0e9be11801ff70c435e032b97028c35cd type: string x-go-name: Secret + type: + description: Type is an indicator for which API (certificates or identities) to send the token. + example: Client certificate + type: string + x-go-name: Type title: CertificateAddToken represents the fields contained within an encoded certificate add token. type: object x-go-package: github.com/canonical/lxd/shared/api @@ -597,6 +602,10 @@ definitions: type: number type: array x-go-name: LoadAverages + logical_cpus: + format: uint64 + type: integer + x-go-name: LogicalCPUs processes: format: uint16 type: integer @@ -713,6 +722,39 @@ definitions: x-go-name: Type type: object x-go-package: github.com/canonical/lxd/shared/api + IdentitiesTLSPost: + properties: + certificate: + description: The PEM encoded x509 certificate of the identity + type: string + x-go-name: Certificate + groups: + description: Groups is the list of groups for which the identity is a member. + example: + - foo + - bar + items: + type: string + type: array + x-go-name: Groups + name: + description: Name associated with the identity + example: foo + type: string + x-go-name: Name + token: + description: Whether to create a certificate add token + example: true + type: boolean + x-go-name: Token + trust_token: + description: Trust token (used to add an untrusted client) + example: blah + type: string + x-go-name: TrustToken + title: IdentitiesTLSPost contains required information for the creation of a TLS identity. + type: object + x-go-package: github.com/canonical/lxd/shared/api Identity: properties: authentication_method: @@ -743,6 +785,13 @@ definitions: example: Jane Doe type: string x-go-name: Name + tls_certificate: + description: |- + TLSCertificate is a PEM encoded x509 certificate. This is only set if the AuthenticationMethod is AuthenticationMethodTLS. + + API extension: access_management_tls. + type: string + x-go-name: TLSCertificate type: description: Type is the type of identity. example: oidc-service-account @@ -801,6 +850,13 @@ definitions: example: Jane Doe type: string x-go-name: Name + tls_certificate: + description: |- + TLSCertificate is a PEM encoded x509 certificate. This is only set if the AuthenticationMethod is AuthenticationMethodTLS. + + API extension: access_management_tls. + type: string + x-go-name: TLSCertificate type: description: Type is the type of identity. example: oidc-service-account @@ -861,6 +917,13 @@ definitions: type: string type: array x-go-name: Groups + tls_certificate: + description: |- + TLSCertificate is a base64 encoded x509 certificate. This can only be set if the authentication method of the identity is AuthenticationMethodTLS. + + API extension: access_management_tls. + type: string + x-go-name: TLSCertificate title: IdentityPut contains the editable fields of an IdentityInfo. type: object x-go-package: github.com/canonical/lxd/shared/api @@ -924,6 +987,11 @@ definitions: type: string type: array x-go-name: Profiles + project: + description: Project name + example: project1 + type: string + x-go-name: Project properties: additionalProperties: type: string @@ -7191,10 +7259,44 @@ paths: summary: Get the identities tags: - identities - /1.0/auth/identities/{authenticationMethod}: + /1.0/auth/identities/current: get: - description: Returns a list of identities (URLs). - operationId: identities_get_by_auth_method + description: Gets the identity of the requestor, including contextual authorization information. + operationId: identity_get_current + produces: + - application/json + responses: + "200": + description: API endpoints + schema: + description: Sync response + properties: + metadata: + $ref: '#/definitions/IdentityInfo' + status: + description: Status description + example: Success + type: string + status_code: + description: Status code + example: 200 + type: integer + type: + description: Response type + example: sync + type: string + type: object + "403": + $ref: '#/responses/Forbidden' + "500": + $ref: '#/responses/InternalServerError' + summary: Get the current identity + tags: + - identities + /1.0/auth/identities/oidc: + get: + description: Returns a list of OIDC identities (URLs). + operationId: identities_get_oidc produces: - application/json responses: @@ -7207,8 +7309,8 @@ paths: description: List of endpoints example: |- [ - "/1.0/auth/identities/tls/e1e06266e36f67431c996d5678e66d732dfd12fe5073c161e62e6360619fc226", - "/1.0/auth/identities/oidc/auth0|4daf5e37ce230e455b64b65b" + "/1.0/auth/identities/oidc/jane.doe@example.com", + "/1.0/auth/identities/oidc/joe.bloggs@example.com" ] items: type: string @@ -7230,13 +7332,32 @@ paths: $ref: '#/responses/Forbidden' "500": $ref: '#/responses/InternalServerError' - summary: Get the identities + summary: Get the OIDC identities + tags: + - identities + /1.0/auth/identities/oidc/{nameOrIdentifier}: + delete: + description: Removes the OIDC identity. + operationId: identity_delete_oidc + produces: + - application/json + responses: + "200": + $ref: '#/responses/EmptySyncResponse' + "400": + $ref: '#/responses/BadRequest' + "403": + $ref: '#/responses/Forbidden' + "500": + $ref: '#/responses/InternalServerError' + "501": + $ref: '#/responses/NotImplemented' + summary: Delete the OIDC identity tags: - identities - /1.0/auth/identities/{authenticationMethod}/{nameOrIdentifier}: get: - description: Gets a specific identity. - operationId: identity_get + description: Gets a specific OIDC identity. + operationId: identity_get_oidc produces: - application/json responses: @@ -7264,14 +7385,14 @@ paths: $ref: '#/responses/Forbidden' "500": $ref: '#/responses/InternalServerError' - summary: Get the identity + summary: Get the OIDC identity tags: - identities patch: consumes: - application/json - description: Updates the editable fields of an identity - operationId: identity_patch + description: Updates the editable fields of an OIDC identity + operationId: identity_patch_oidc parameters: - description: Update request in: body @@ -7287,16 +7408,20 @@ paths: $ref: '#/responses/BadRequest' "403": $ref: '#/responses/Forbidden' + "412": + $ref: '#/responses/PreconditionFailed' "500": $ref: '#/responses/InternalServerError' - summary: Partially update the identity + "501": + $ref: '#/responses/NotImplemented' + summary: Partially update the OIDC identity tags: - identities put: consumes: - application/json - description: Replaces the editable fields of an identity - operationId: identity_put + description: Replaces the editable fields of an OIDC identity + operationId: identity_put_oidc parameters: - description: Update request in: body @@ -7312,15 +7437,19 @@ paths: $ref: '#/responses/BadRequest' "403": $ref: '#/responses/Forbidden' + "412": + $ref: '#/responses/PreconditionFailed' "500": $ref: '#/responses/InternalServerError' - summary: Update the identity + "501": + $ref: '#/responses/NotImplemented' + summary: Update the OIDC identity tags: - identities - /1.0/auth/identities/{authenticationMethod}?recursion=1: + /1.0/auth/identities/oidc?recursion=1: get: - description: Returns a list of identities. - operationId: identities_get_by_auth_method_recursion1 + description: Returns a list of OIDC identities. + operationId: identities_get_oidc_recursion1 produces: - application/json responses: @@ -7351,13 +7480,13 @@ paths: $ref: '#/responses/Forbidden' "500": $ref: '#/responses/InternalServerError' - summary: Get the identities + summary: Get the OIDC identities tags: - identities - /1.0/auth/identities/current: + /1.0/auth/identities/tls: get: - description: Gets the identity of the requestor, including contextual authorization information. - operationId: identity_get_current + description: Returns a list of TLS identities (URLs). + operationId: identities_get_tls produces: - application/json responses: @@ -7367,7 +7496,15 @@ paths: description: Sync response properties: metadata: - $ref: '#/definitions/IdentityInfo' + description: List of endpoints + example: |- + [ + "/1.0/auth/identities/tls/e1e06266e36f67431c996d5678e66d732dfd12fe5073c161e62e6360619fc226", + "/1.0/auth/identities/tls/6d5678e66d732dfd12fe5073c161eec9962e6360619fc2261e06266e36f67431" + ] + items: + type: string + type: array status: description: Status description example: Success @@ -7385,7 +7522,217 @@ paths: $ref: '#/responses/Forbidden' "500": $ref: '#/responses/InternalServerError' - summary: Get the current identity + summary: Get the TLS identities + tags: + - identities + post: + consumes: + - application/json + description: |- + Adds a TLS identity as a trusted client, or creates a pending TLS identity and returns a token + for use by an untrusted client. One of `token` or `certificate` must be set. + operationId: identities_post_tls + parameters: + - description: TLS Identity + in: body + name: TLS identity + required: true + schema: + $ref: '#/definitions/IdentitiesPostTLS' + produces: + - application/json + responses: + "201": + description: "" + "400": + $ref: '#/responses/BadRequest' + "403": + $ref: '#/responses/Forbidden' + "500": + $ref: '#/responses/InternalServerError' + summary: Add a TLS identity. + tags: + - identities + /1.0/auth/identities/tls/{nameOrIdentifier}: + delete: + description: Removes the TLS identity and revokes trust. + operationId: identity_delete_tls + produces: + - application/json + responses: + "200": + $ref: '#/responses/EmptySyncResponse' + "400": + $ref: '#/responses/BadRequest' + "403": + $ref: '#/responses/Forbidden' + "500": + $ref: '#/responses/InternalServerError' + "501": + $ref: '#/responses/NotImplemented' + summary: Delete the TLS identity + tags: + - identities + get: + description: Gets a specific TLS identity. + operationId: identity_get_tls + produces: + - application/json + responses: + "200": + description: API endpoints + schema: + description: Sync response + properties: + metadata: + $ref: '#/definitions/Identity' + status: + description: Status description + example: Success + type: string + status_code: + description: Status code + example: 200 + type: integer + type: + description: Response type + example: sync + type: string + type: object + "403": + $ref: '#/responses/Forbidden' + "500": + $ref: '#/responses/InternalServerError' + summary: Get the TLS identity + tags: + - identities + patch: + consumes: + - application/json + description: Updates the editable fields of a TLS identity + operationId: identity_patch_tls + parameters: + - description: Update request + in: body + name: identity + schema: + $ref: '#/definitions/IdentityPut' + produces: + - application/json + responses: + "200": + $ref: '#/responses/EmptySyncResponse' + "400": + $ref: '#/responses/BadRequest' + "403": + $ref: '#/responses/Forbidden' + "412": + $ref: '#/responses/PreconditionFailed' + "500": + $ref: '#/responses/InternalServerError' + "501": + $ref: '#/responses/NotImplemented' + summary: Partially update the TLS identity + tags: + - identities + put: + consumes: + - application/json + description: Replaces the editable fields of a TLS identity + operationId: identity_put_tls + parameters: + - description: Update request + in: body + name: identity + schema: + $ref: '#/definitions/IdentityPut' + produces: + - application/json + responses: + "200": + $ref: '#/responses/EmptySyncResponse' + "400": + $ref: '#/responses/BadRequest' + "403": + $ref: '#/responses/Forbidden' + "412": + $ref: '#/responses/PreconditionFailed' + "500": + $ref: '#/responses/InternalServerError' + "501": + $ref: '#/responses/NotImplemented' + summary: Update the TLS identity + tags: + - identities + /1.0/auth/identities/tls?public: + post: + consumes: + - application/json + description: |- + Adds a TLS identity as a trusted client. + In this mode, the `token` property must be set to the correct value. + The certificate that the client sent during the TLS handshake will be added. + The `certificate` field must be omitted. + + The `?public` part of the URL isn't required, it's simply used to + separate the two behaviors of this endpoint. + operationId: identities_post_tls_untrusted + parameters: + - description: TLS Identity + in: body + name: TLS identity + required: true + schema: + $ref: '#/definitions/IdentitiesPostTLS' + produces: + - application/json + responses: + "201": + $ref: '#/responses/EmptySyncResponse' + "400": + $ref: '#/responses/BadRequest' + "403": + $ref: '#/responses/Forbidden' + "500": + $ref: '#/responses/InternalServerError' + summary: Add a TLS identity + tags: + - identities + /1.0/auth/identities/tls?recursion=1: + get: + description: Returns a list of TLS identities. + operationId: identities_get_tls_recursion1 + produces: + - application/json + responses: + "200": + description: API endpoints + schema: + description: Sync response + properties: + metadata: + description: List of identities + items: + $ref: '#/definitions/Identity' + type: array + status: + description: Status description + example: Success + type: string + status_code: + description: Status code + example: 200 + type: integer + type: + description: Response type + example: sync + type: string + type: object + "403": + $ref: '#/responses/Forbidden' + "500": + $ref: '#/responses/InternalServerError' + summary: Get the TLS identities tags: - identities /1.0/auth/identities?recursion=1: @@ -8677,6 +9024,10 @@ paths: in: query name: filter type: string + - description: Retrieve images from all projects + in: query + name: all-projects + type: boolean produces: - application/json responses: @@ -9428,6 +9779,10 @@ paths: in: query name: filter type: string + - description: Retrieve images from all projects + in: query + name: all-projects + type: boolean produces: - application/json responses: @@ -9516,6 +9871,10 @@ paths: in: query name: filter type: string + - description: Retrieve images from all projects + in: query + name: all-projects + type: boolean produces: - application/json responses: @@ -9564,6 +9923,11 @@ paths: in: query name: filter type: string + - description: Retrieve images from all projects + example: default + in: query + name: all-projects + type: boolean produces: - application/json responses: @@ -16944,6 +17308,24 @@ responses: type: string x-go-name: Type type: object + NotImplemented: + description: Not implemented + schema: + properties: + error: + example: not implemented + type: string + x-go-name: Error + error_code: + example: 501 + format: int64 + type: integer + x-go-name: ErrorCode + type: + example: error + type: string + x-go-name: Type + type: object Operation: description: Operation schema: diff --git a/doc/tutorial/first_steps.md b/doc/tutorial/first_steps.md index fff575e614d4..19874a7a254c 100644 --- a/doc/tutorial/first_steps.md +++ b/doc/tutorial/first_steps.md @@ -46,7 +46,7 @@ If you prefer a different installation method, or use a Linux distribution that sudo snap install lxd - If you get an error message that the snap is already installed, run the following command to refresh it and ensure that you are running an up-to-date version: + If you get an error message that the LXD snap is already installed, run the following command to refresh it and ensure that you are running an up-to-date version: sudo snap refresh lxd diff --git a/go.mod b/go.mod index 561bf83edae2..50e1fbf42f9a 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,14 @@ module github.com/canonical/lxd -go 1.22.7 +go 1.23.3 require ( + github.com/NVIDIA/nvidia-container-toolkit v1.17.3 github.com/Rican7/retry v0.3.1 github.com/armon/go-proxyproto v0.1.0 github.com/canonical/go-dqlite/v2 v2.0.0 github.com/checkpoint-restore/go-criu/v6 v6.3.0 - github.com/dell/goscaleio v1.16.0 + github.com/dell/goscaleio v1.17.1 github.com/digitalocean/go-qemu v0.0.0-20230711162256-2e3d0186973e github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0 @@ -38,10 +39,10 @@ require ( github.com/mitchellh/mapstructure v1.5.0 github.com/oklog/ulid/v2 v2.1.0 github.com/olekukonko/tablewriter v0.0.5 - github.com/openfga/api/proto v0.0.0-20241107182745-c14fb4b3d4b4 - github.com/openfga/language/pkg/go v0.2.0-beta.2.0.20240926131254-992b301a003f - github.com/openfga/openfga v1.8.0 - github.com/osrg/gobgp/v3 v3.31.0 + github.com/openfga/api/proto v0.0.0-20241204203216-24dec682b31d + github.com/openfga/language/pkg/go v0.2.0-beta.2.0.20241115164311-10e575c8e47c + github.com/openfga/openfga v1.8.1 + github.com/osrg/gobgp/v3 v3.32.0 github.com/pkg/sftp v1.13.7 github.com/pkg/xattr v0.4.10 github.com/robfig/cron/v3 v3.0.1 @@ -51,23 +52,29 @@ require ( github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 github.com/vishvananda/netlink v1.3.0 github.com/zitadel/oidc/v3 v3.33.1 - go.starlark.net v0.0.0-20240925182052-1207426daebd + go.starlark.net v0.0.0-20241125201518-c05ff208a98f go.uber.org/zap v1.27.0 - golang.org/x/crypto v0.29.0 - golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c + golang.org/x/crypto v0.30.0 + golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d golang.org/x/oauth2 v0.24.0 - golang.org/x/sync v0.9.0 - golang.org/x/sys v0.27.0 - golang.org/x/term v0.26.0 - golang.org/x/text v0.20.0 - golang.org/x/tools v0.27.0 + golang.org/x/sync v0.10.0 + golang.org/x/sys v0.28.0 + golang.org/x/term v0.27.0 + golang.org/x/text v0.21.0 + golang.org/x/tools v0.28.0 google.golang.org/protobuf v1.35.2 gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637 gopkg.in/yaml.v2 v2.4.0 - k8s.io/utils v0.0.0-20240921022957-49e7df575cb6 + k8s.io/utils v0.0.0-20241104163129-6fe5fd82f078 + tags.cncf.io/container-device-interface v0.8.0 + tags.cncf.io/container-device-interface/specs-go v0.8.0 ) require ( + cel.dev/expr v0.19.1 // indirect + github.com/NVIDIA/go-nvlib v0.7.0 // indirect + github.com/NVIDIA/go-nvml v0.12.4-0 // indirect + github.com/Yiling-J/theine-go v0.6.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect @@ -76,22 +83,22 @@ require ( github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da // indirect - github.com/digitalocean/go-libvirt v0.0.0-20241007203800-ad92148935b6 // indirect + github.com/digitalocean/go-libvirt v0.0.0-20241112162257-c54891ad610b // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/eapache/channels v1.1.0 // indirect github.com/eapache/queue v1.1.0 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/envoyproxy/protoc-gen-validate v1.1.0 // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/go-ini/ini v1.67.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/goccy/go-json v0.10.3 // indirect - github.com/google/cel-go v0.21.0 // indirect + github.com/google/cel-go v0.22.1 // indirect github.com/google/renameio v1.0.1 // indirect github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/hcl v1.0.0 // indirect @@ -104,11 +111,10 @@ require ( github.com/juju/schema v1.2.0 // indirect github.com/juju/version v0.0.0-20210303051006-2015802527a8 // indirect github.com/k-sone/critbitgo v1.4.0 // indirect - github.com/karlseguin/ccache/v3 v3.0.6 // indirect github.com/klauspost/compress v1.17.11 // indirect - github.com/klauspost/cpuid/v2 v2.2.8 // indirect + github.com/klauspost/cpuid/v2 v2.2.9 // indirect github.com/kr/fs v0.1.0 // indirect - github.com/magiconair/properties v1.8.7 // indirect + github.com/magiconair/properties v1.8.9 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mdlayher/socket v0.5.1 // indirect @@ -118,12 +124,14 @@ require ( github.com/muhlemmer/httpforwarded v0.1.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/natefinch/wrap v0.2.0 // indirect + github.com/opencontainers/runtime-spec v1.2.0 // indirect + github.com/opencontainers/runtime-tools v0.9.1-0.20221107090550-2e043c6bd626 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.20.5 // indirect github.com/prometheus/client_model v0.6.1 // indirect - github.com/prometheus/common v0.60.0 // indirect + github.com/prometheus/common v0.61.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rs/cors v1.11.1 // indirect @@ -138,25 +146,27 @@ require ( github.com/spf13/viper v1.19.0 // indirect github.com/stoewer/go-strcase v1.3.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect - github.com/vishvananda/netns v0.0.4 // indirect + github.com/vishvananda/netns v0.0.5 // indirect + github.com/zeebo/xxh3 v1.0.2 // indirect github.com/zitadel/logging v0.6.1 // indirect github.com/zitadel/schema v1.3.0 // indirect - go.opentelemetry.io/otel v1.31.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.31.0 // indirect - go.opentelemetry.io/otel/metric v1.31.0 // indirect - go.opentelemetry.io/otel/sdk v1.31.0 // indirect - go.opentelemetry.io/otel/trace v1.31.0 // indirect - go.opentelemetry.io/proto/otlp v1.3.1 // indirect + go.opentelemetry.io/otel v1.32.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0 // indirect + go.opentelemetry.io/otel/metric v1.32.0 // indirect + go.opentelemetry.io/otel/sdk v1.32.0 // indirect + go.opentelemetry.io/otel/trace v1.32.0 // indirect + go.opentelemetry.io/proto/otlp v1.4.0 // indirect go.uber.org/mock v0.5.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/mod v0.22.0 // indirect - golang.org/x/net v0.31.0 // indirect + golang.org/x/net v0.32.0 // indirect gonum.org/v1/gonum v0.15.1 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20241021214115-324edc3d5d38 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38 // indirect - google.golang.org/grpc v1.67.1 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20241206012308-a4fef0638583 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20241206012308-a4fef0638583 // indirect + google.golang.org/grpc v1.68.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/mgo.v2 v2.0.0-20190816093944-a6b53ec6cb22 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect ) diff --git a/go.sum b/go.sum index e89cf95871f0..364d6e33efde 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +cel.dev/expr v0.19.1 h1:NciYrtDRIR0lNCnH1LFJegdjspNx9fI59O7TWcua/W4= +cel.dev/expr v0.19.1/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= @@ -8,8 +10,16 @@ github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8 github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= +github.com/NVIDIA/go-nvlib v0.7.0 h1:Z/J7skMdLbTiHvomKVsGYsttfQMZj5FwNYIFXhZ4i/c= +github.com/NVIDIA/go-nvlib v0.7.0/go.mod h1:9UrsLGx/q1OrENygXjOuM5Ey5KCtiZhbvBlbUIxtGWY= +github.com/NVIDIA/go-nvml v0.12.4-0 h1:4tkbB3pT1O77JGr0gQ6uD8FrsUPqP1A/EOEm2wI1TUg= +github.com/NVIDIA/go-nvml v0.12.4-0/go.mod h1:8Llmj+1Rr+9VGGwZuRer5N/aCjxGuR5nPb/9ebBiIEQ= +github.com/NVIDIA/nvidia-container-toolkit v1.17.3 h1:vCJ9IY/YNcwNiMv7anAWEmUW8Xocqdmv73V98MXTrxo= +github.com/NVIDIA/nvidia-container-toolkit v1.17.3/go.mod h1:R6bNf6ca0IjjACa0ncKGvsrx6zSjsgz8QkFyBDk5szU= github.com/Rican7/retry v0.3.1 h1:scY4IbO8swckzoA/11HgBwaZRJEyY9vaNJshcdhp1Mc= github.com/Rican7/retry v0.3.1/go.mod h1:CxSDrhAyXmTMeEuRAnArMu1FHu48vtfjLREWqVl7Vw0= +github.com/Yiling-J/theine-go v0.6.0 h1:jv7V/tcD6ijL0T4kfbJDKP81TCZBkoriNTPSqwivWuY= +github.com/Yiling-J/theine-go v0.6.0/go.mod h1:mdch1vjgGWd7s3rWKvY+MF5InRLfRv/CWVI9RVNQ8wY= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/armon/go-proxyproto v0.1.0 h1:TWWcSsjco7o2itn6r25/5AqKBiWmsiuzsUDLT/MTl7k= @@ -17,6 +27,8 @@ github.com/armon/go-proxyproto v0.1.0/go.mod h1:Xj90dce2VKbHzRAeiVQAMBtj4M5oidoX github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/bmatcuk/doublestar/v4 v4.7.1 h1:fdDeAqgT47acgwd9bd9HxJRDmc9UAmPpc+2m0CXv75Q= github.com/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/canonical/go-dqlite/v2 v2.0.0 h1:RNFcFVhHMh70muKKErbW35rSzqmAFswheHdAgxW0Ddw= @@ -39,12 +51,12 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dell/goscaleio v1.16.0 h1:dhk5M/ozQTh4ponSKLbL6gTmqQg/lp004s8ZnSeSjo0= -github.com/dell/goscaleio v1.16.0/go.mod h1:h7SCmReARG/szFWBMQGETGkZObknhS45lQipQbtdmJ8= +github.com/dell/goscaleio v1.17.1 h1:0gwR1c55ij3xVu/ARDWQNxBKCRlxMmg61n+5gKBX3v8= +github.com/dell/goscaleio v1.17.1/go.mod h1:7bX3rL8JWMmdifGr/UeD/Ju9wbkHUqvKDrbdu7XyGm8= github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38= github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= -github.com/digitalocean/go-libvirt v0.0.0-20241007203800-ad92148935b6 h1:Bl49Fp2ksddm9qA9UGtWEYeob5iBZzNAmkB4Oz6oHQ4= -github.com/digitalocean/go-libvirt v0.0.0-20241007203800-ad92148935b6/go.mod h1:2BnN3ZaDXE9uqsRk3eBTZAYHo9iGk0HP1FrgXLxyn4w= +github.com/digitalocean/go-libvirt v0.0.0-20241112162257-c54891ad610b h1:uvE6fxdZFLEh4nUhrKlLXfh7cBPzi+UV9/HbowzslvU= +github.com/digitalocean/go-libvirt v0.0.0-20241112162257-c54891ad610b/go.mod h1:3PkTlhCk+dzM+02U40OlETWoWXcLgh4oNusnukhVNwk= github.com/digitalocean/go-qemu v0.0.0-20230711162256-2e3d0186973e h1:x5PInTuXLddHWHlePCNAcM8QtUfOGx44f3UmYPMtDcI= github.com/digitalocean/go-qemu v0.0.0-20230711162256-2e3d0186973e/go.mod h1:K4+o74YGNjOb9N6yyG+LPj1NjHtk+Qz0IYQPvirbaLs= github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q= @@ -79,8 +91,8 @@ github.com/flosch/pongo2 v0.0.0-20200913210552-0d938eb266f3 h1:fmFk0Wt3bBxxwZnu4 github.com/flosch/pongo2 v0.0.0-20200913210552-0d938eb266f3/go.mod h1:bJWSKrZyQvfTnb2OudyUjurSG4/edverV7n82+K3JiM= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fvbommel/sortorder v1.1.0 h1:fUmoe+HLsBTctBDoaBwpQo5N+nrCp8g/BjKb/6ZQmYw= github.com/fvbommel/sortorder v1.1.0/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0= github.com/go-acme/lego/v4 v4.20.4 h1:yCQGBX9jOfMbriEQUocdYm7EBapdTp8nLXYG8k6SqSU= @@ -119,13 +131,14 @@ github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/google/cel-go v0.21.0 h1:cl6uW/gxN+Hy50tNYvI691+sXxioCnstFzLp2WO4GCI= -github.com/google/cel-go v0.21.0/go.mod h1:rHUlWCcBKgyEk+eV03RPdZUekPp6YcJwV0FxuUksYxc= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/cel-go v0.22.1 h1:AfVXx3chM2qwoSbM7Da8g8hX8OVSkBFwX+rz2+PcK40= +github.com/google/cel-go v0.22.1/go.mod h1:BuznPXXfQDpXKWQ9sPW3TzlAJN5zzFe+i9tIs0yC4s8= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= @@ -134,6 +147,7 @@ github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= github.com/google/renameio v1.0.1 h1:Lh/jXZmvZxb0BBeSY5VKEfidcbcbenKjZFzM/q0fSeU= github.com/google/renameio v1.0.1/go.mod h1:t/HQoYBZSsWSNK35C6CO/TpPLDVWvxOHboWUAweKUpk= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= @@ -150,8 +164,8 @@ github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDa github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0 h1:pRhl55Yx1eC7BZ1N+BBWwnKaMyD8uC+34TLdndZMAKk= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0/go.mod h1:XKMd7iuf/RGPSMJ/U4HP0zS2Z9Fh8Ps9a+6X26m/tmI= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 h1:ad0vkEBuk23VJzZR9nkLVG0YAoN9coASF1GusYX6AlU= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0/go.mod h1:igFoXX2ELCW06bol23DWPB5BEWfZISOzSP5K2sbLea0= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0/go.mod h1:qztMSjm835F2bXf+5HKAPIS5qsmQDqZna/PgVt4rWtI= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -266,8 +280,6 @@ github.com/juju/version/v2 v2.0.0/go.mod h1:ZeFjNy+UFEWJDDPdzW7Cm9NeU6dsViGaFYhX github.com/julienschmidt/httprouter v1.1.1-0.20151013225520-77a895ad01eb/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/k-sone/critbitgo v1.4.0 h1:l71cTyBGeh6X5ATh6Fibgw3+rtNT80BA0uNNWgkPrbE= github.com/k-sone/critbitgo v1.4.0/go.mod h1:7E6pyoyADnFxlUBEKcnfS49b7SUAQGMK+OAp/UQvo0s= -github.com/karlseguin/ccache/v3 v3.0.6 h1:6wC04CXSdptebuSUBgsQixNrrRMUdimtwmjlJUpCf/4= -github.com/karlseguin/ccache/v3 v3.0.6/go.mod h1:b0qfdUOHl4vJgKFQN41paXIdBb3acAtyX2uWrBAZs1w= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= @@ -275,8 +287,8 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM= -github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= +github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= @@ -296,8 +308,8 @@ github.com/lunixbochs/vtclean v0.0.0-20160125035106-4fbf7632a2c6/go.mod h1:pHhQN github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= github.com/lxc/go-lxc v0.0.0-20240606200241-27b3d116511f h1:KnZqnn4R9Ae+jOK7DwacF1CnWEBMSwoXh44owa6j6k4= github.com/lxc/go-lxc v0.0.0-20240606200241-27b3d116511f/go.mod h1:3UTWXVcHfgxE7JM4ZUnsy6bDA8L1vuzwJbJRF6dlB90= -github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= -github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM= +github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/masterzen/azure-sdk-for-go v3.2.0-beta.0.20161014135628-ee4f0065d00c+incompatible/go.mod h1:mf8fjOu33zCqxUjuiU3I8S1lJMyEAlH+0F2+M5xl3hE= github.com/masterzen/simplexml v0.0.0-20160608183007-4572e39b1ab9/go.mod h1:kCEbxUJlNDEBNbdQMkPSp6yaKcRXVI6f4ddk8Riv4bc= github.com/masterzen/simplexml v0.0.0-20190410153822-31eea3082786/go.mod h1:kCEbxUJlNDEBNbdQMkPSp6yaKcRXVI6f4ddk8Riv4bc= @@ -342,8 +354,10 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mndrix/tap-go v0.0.0-20171203230836-629fa407e90b/go.mod h1:pzzDgJWZ34fGzaAZGFW22KVZDfyrYW+QABMrWnJBnSs= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/mrunalp/fileutils v0.5.0/go.mod h1:M1WthSahJixYnrXQl/DFQuteStB1weuxD2QJNHXfbSQ= github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM= github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM= github.com/muhlemmer/httpforwarded v0.1.0 h1:x4DLrzXdliq8mprgUMR0olDvHGkou5BJsK/vWUetyzY= @@ -364,15 +378,23 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= -github.com/openfga/api/proto v0.0.0-20241107182745-c14fb4b3d4b4 h1:anI1zonbViSGV2X8szoTa0l2I28vjVKN4PGOJO/th7o= -github.com/openfga/api/proto v0.0.0-20241107182745-c14fb4b3d4b4/go.mod h1:gil5LBD8tSdFQbUkCQdnXsoeU9kDJdJgbGdHkgJfcd0= -github.com/openfga/language/pkg/go v0.2.0-beta.2.0.20240926131254-992b301a003f h1:ZMZ7ntMnaHIPZxvVQv/aqC4ctzLqH+9Fqn4uw35kQpk= -github.com/openfga/language/pkg/go v0.2.0-beta.2.0.20240926131254-992b301a003f/go.mod h1:ll/hN6kS4EE6B/7J/PbZqac9Nuv7ZHpI+Jfh36JLrbs= -github.com/openfga/openfga v1.8.0 h1:Yqsont2a4Taocg8hunsIXMDX4TPv1CbGTS5Co3IAs00= -github.com/openfga/openfga v1.8.0/go.mod h1:vxq88JqBCy0tWKpQK5qtKspShV+onryc7N7JML1qu2c= +github.com/opencontainers/runtime-spec v1.0.3-0.20220825212826-86290f6a00fb/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +github.com/opencontainers/runtime-spec v1.2.0 h1:z97+pHb3uELt/yiAWD691HNHQIF07bE7dzrbT927iTk= +github.com/opencontainers/runtime-spec v1.2.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +github.com/opencontainers/runtime-tools v0.9.1-0.20221107090550-2e043c6bd626 h1:DmNGcqH3WDbV5k8OJ+esPWbqUOX5rMLR2PMvziDMJi0= +github.com/opencontainers/runtime-tools v0.9.1-0.20221107090550-2e043c6bd626/go.mod h1:BRHJJd0E+cx42OybVYSgUvZmU0B8P9gZuRXlZUP7TKI= +github.com/opencontainers/selinux v1.9.1/go.mod h1:2i0OySw99QjzBBQByd1Gr9gSjvuho1lHsJxIJ3gGbJI= +github.com/opencontainers/selinux v1.11.0 h1:+5Zbo97w3Lbmb3PeqQtpmTkMwsW5nRI3YaLpt7tQ7oU= +github.com/opencontainers/selinux v1.11.0/go.mod h1:E5dMC3VPuVvVHDYmi78qvhJp8+M586T4DlDRYpFkyec= +github.com/openfga/api/proto v0.0.0-20241204203216-24dec682b31d h1:2a0ExEDWvZvEl0MntwSkc7leb5ArQr5z6sG5cABIcdY= +github.com/openfga/api/proto v0.0.0-20241204203216-24dec682b31d/go.mod h1:gil5LBD8tSdFQbUkCQdnXsoeU9kDJdJgbGdHkgJfcd0= +github.com/openfga/language/pkg/go v0.2.0-beta.2.0.20241115164311-10e575c8e47c h1:1y84C0V4NRfPtRi4MqQ7+gnFtYgeBKPIeIAPLdVJ7j4= +github.com/openfga/language/pkg/go v0.2.0-beta.2.0.20241115164311-10e575c8e47c/go.mod h1:12RMe/HuRNyOzS33RQa53jwdcxE2znr8ycXMlVbgQN4= +github.com/openfga/openfga v1.8.1 h1:25ojlyTzmEI9SNCUR2WeFdBMx83SJRVW13KskTD7iS0= +github.com/openfga/openfga v1.8.1/go.mod h1:4jy3ACzbce6NG6modqchQprQfHsSSizj2dL+W8IH7V4= github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= -github.com/osrg/gobgp/v3 v3.31.0 h1:qDKokSsHUlvp03kHwOIwq0D1jPJruYRBpOHQsJYHdfc= -github.com/osrg/gobgp/v3 v3.31.0/go.mod h1:8m+kgkdaWrByxg5EWpNUO2r/mopodrNBOUBhMnW/yGQ= +github.com/osrg/gobgp/v3 v3.32.0 h1:B2krh/44etYQAuLq+iMkORxIvXj+cGIpuR6qDGNGagM= +github.com/osrg/gobgp/v3 v3.32.0/go.mod h1:8m+kgkdaWrByxg5EWpNUO2r/mopodrNBOUBhMnW/yGQ= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= @@ -387,15 +409,15 @@ github.com/pkg/xattr v0.4.10/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6k github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pressly/goose/v3 v3.22.1 h1:2zICEfr1O3yTP9BRZMGPj7qFxQ+ik6yeo+z1LMuioLc= -github.com/pressly/goose/v3 v3.22.1/go.mod h1:xtMpbstWyCpyH+0cxLTMCENWBG+0CSxvTsXhW95d5eo= +github.com/pressly/goose/v3 v3.23.0 h1:57hqKos8izGek4v6D5+OXBa+Y4Rq8MU//+MmnevdpVA= +github.com/pressly/goose/v3 v3.23.0/go.mod h1:rpx+D9GX/+stXmzKa+uh1DkjPnNVMdiOCV9iLdle4N8= github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.60.0 h1:+V9PAREWNvJMAuJ1x1BaWl9dewMW4YrHZQbx0sJNllA= -github.com/prometheus/common v0.60.0/go.mod h1:h0LYf1R1deLSKtD4Vdg8gy4RuOvENW2J/h19V5NADQw= +github.com/prometheus/common v0.61.0 h1:3gv/GThfX0cV2lpO7gkTUwZru38mxevy90Bj8YFSRQQ= +github.com/prometheus/common v0.61.0/go.mod h1:zr29OCN/2BsJRaFwG8QOBr41D6kkchKbpeNH7pAjb/s= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= @@ -420,6 +442,7 @@ github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWR github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= @@ -455,39 +478,52 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 h1:kdXcSzyDtseVEc4yCz2qF8ZrQvIDBJLl4S1c3GCXmoI= github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= +github.com/urfave/cli v1.19.1/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= github.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQdrZk= github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs= -github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8= github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= +github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM= github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= github.com/zitadel/logging v0.6.1 h1:Vyzk1rl9Kq9RCevcpX6ujUaTYFX43aa4LkvV1TvUk+Y= github.com/zitadel/logging v0.6.1/go.mod h1:Y4CyAXHpl3Mig6JOszcV5Rqqsojj+3n7y2F591Mp/ow= github.com/zitadel/oidc/v3 v3.33.1 h1:e3w9PDV0Mh50/ZiJWtzyT0E4uxJ6RXll+hqVDnqGbTU= github.com/zitadel/oidc/v3 v3.33.1/go.mod h1:zkoZ1Oq6CweX3BaLrftLEGCs6YK6zDpjjVGZrP10AWU= github.com/zitadel/schema v1.3.0 h1:kQ9W9tvIwZICCKWcMvCEweXET1OcOyGEuFbHs4o5kg0= github.com/zitadel/schema v1.3.0/go.mod h1:NptN6mkBDFvERUCvZHlvWmmME+gmZ44xzwRXwhzsbtc= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM= -go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= -go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 h1:K0XaT3DwHAcV4nKLzcQvwAgSyisUghWoY20I7huthMk= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0/go.mod h1:B5Ki776z/MBnVha1Nzwp5arlzBbE3+1jk+pGmaP5HME= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.31.0 h1:FFeLy03iVTXP6ffeN2iXrxfGsZGCjVx0/4KlizjyBwU= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.31.0/go.mod h1:TMu73/k1CP8nBUpDLc71Wj/Kf7ZS9FK5b53VapRsP9o= -go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= -go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= -go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= -go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= -go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= -go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= -go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= -go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= -go.starlark.net v0.0.0-20240925182052-1207426daebd h1:S+EMisJOHklQxnS3kqsY8jl2y5aF0FDEdcLnOw3q22E= -go.starlark.net v0.0.0-20240925182052-1207426daebd/go.mod h1:YKMCv9b1WrfWmeqdV5MAuEHWsu5iC+fe6kYl2sQjdI8= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 h1:DheMAlT6POBP+gh8RUH19EOTnQIor5QE0uSRPtzCpSw= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0/go.mod h1:wZcGmeVO9nzP67aYSLDqXNWK87EZWhi7JWj1v7ZXf94= +go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U= +go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0 h1:IJFEoHiytixx8cMiVAO+GmHR6Frwu+u5Ur8njpFO6Ac= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0/go.mod h1:3rHrKNtLIoS0oZwkY2vxi+oJcwFRWdtUyRII+so45p8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0 h1:9kV11HXBHZAvuPUZxmMWrH8hZn/6UnHX4K0mu36vNsU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0/go.mod h1:JyA0FHXe22E1NeNiHmVp7kFHglnexDQ7uRWDiiJ1hKQ= +go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M= +go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8= +go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4= +go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU= +go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM= +go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8= +go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg= +go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY= +go.starlark.net v0.0.0-20241125201518-c05ff208a98f h1:W+3pcCdjGognUT+oE6tXsC3xiCEcCYTaJBXHHRn7aW0= +go.starlark.net v0.0.0-20241125201518-c05ff208a98f/go.mod h1:YKMCv9b1WrfWmeqdV5MAuEHWsu5iC+fe6kYl2sQjdI8= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -510,11 +546,11 @@ golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= -golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= +golang.org/x/crypto v0.30.0 h1:RwoQn3GkWiMkzlX562cLB7OxWvjH1L8xutO2WoJcRoY= +golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY= -golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8= +golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d h1:0olWaB5pg3+oychR51GUVCEsGkeCU/2JxjBgIo4f3M0= +golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= @@ -545,8 +581,8 @@ golang.org/x/net v0.0.0-20211216030914-fe4d6282115f/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= -golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= +golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI= +golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= @@ -558,12 +594,14 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= -golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191115151921-52ab43148777/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -586,15 +624,15 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= -golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= -golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU= -golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E= +golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -603,8 +641,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= -golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -617,8 +655,8 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.27.0 h1:qEKojBykQkQ4EynWy4S8Weg69NumxKdn40Fce3uc/8o= -golang.org/x/tools v0.27.0/go.mod h1:sUi0ZgbwW9ZPAq26Ekut+weQPR5eIM6GQLQ1Yjm1H0Q= +golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8= +golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -630,17 +668,17 @@ google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto/googleapis/api v0.0.0-20241021214115-324edc3d5d38 h1:2oV8dfuIkM1Ti7DwXc0BJfnwr9csz4TDXI9EmiI+Rbw= -google.golang.org/genproto/googleapis/api v0.0.0-20241021214115-324edc3d5d38/go.mod h1:vuAjtvlwkDKF6L1GQ0SokiRLCGFfeBUXWr/aFFkHACc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38 h1:zciRKQ4kBpFgpfC5QQCVtnnNAcLIqweL7plyZRQHVpI= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= +google.golang.org/genproto/googleapis/api v0.0.0-20241206012308-a4fef0638583 h1:v+j+5gpj0FopU0KKLDGfDo9ZRRpKdi5UBrCP0f76kuY= +google.golang.org/genproto/googleapis/api v0.0.0-20241206012308-a4fef0638583/go.mod h1:jehYqy3+AhJU9ve55aNOaSml7wUXjF9x6z2LcCfpAhY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241206012308-a4fef0638583 h1:IfdSdTcLFy4lqUQrQJLkLt1PB+AsqVz6lwkWPzWEz10= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241206012308-a4fef0638583/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= -google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= +google.golang.org/grpc v1.68.1 h1:oI5oTa11+ng8r8XMMN7jAOmWfPZWbYpCFaMUTACxkM0= +google.golang.org/grpc v1.68.1/go.mod h1:+q1XYFJjShcqn0QZHvCyeR4CXPA+llXIeUIfIe00waw= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= @@ -673,8 +711,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -k8s.io/utils v0.0.0-20240921022957-49e7df575cb6 h1:MDF6h2H/h4tbzmtIKTuctcwZmY0tY9mD9fNT47QO6HI= -k8s.io/utils v0.0.0-20240921022957-49e7df575cb6/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +k8s.io/utils v0.0.0-20241104163129-6fe5fd82f078 h1:jGnCPejIetjiy2gqaJ5V0NLwTpF4wbQ6cZIItJCSHno= +k8s.io/utils v0.0.0-20241104163129-6fe5fd82f078/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= launchpad.net/gocheck v0.0.0-20140225173054-000000000087/go.mod h1:hj7XX3B/0A+80Vse0e+BUHsHMTEhd0O4cpUHr/e/BUM= launchpad.net/xmlpath v0.0.0-20130614043138-000000000004/go.mod h1:vqyExLOM3qBx7mvYRkoxjSCF945s0mbe7YynlKYXtsA= modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= @@ -685,9 +723,15 @@ modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= -modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM= -modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= +modernc.org/sqlite v1.34.1 h1:u3Yi6M0N8t9yKRDwhXcyp1eS5/ErhPTBggxWFuR6Hfk= +modernc.org/sqlite v1.34.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= +tags.cncf.io/container-device-interface v0.8.0 h1:8bCFo/g9WODjWx3m6EYl3GfUG31eKJbaggyBDxEldRc= +tags.cncf.io/container-device-interface v0.8.0/go.mod h1:Apb7N4VdILW0EVdEMRYXIDVRZfNJZ+kmEUss2kRRQ6Y= +tags.cncf.io/container-device-interface/specs-go v0.8.0 h1:QYGFzGxvYK/ZLMrjhvY0RjpUavIn4KcmRmVP/JjdBTA= +tags.cncf.io/container-device-interface/specs-go v0.8.0/go.mod h1:BhJIkjjPh4qpys+qm4DAYtUyryaTDg9zris+AczXyws= diff --git a/lxc-to-lxd/main.go b/lxc-to-lxd/main.go index 5faeebc3a80a..91a32fc60044 100644 --- a/lxc-to-lxd/main.go +++ b/lxc-to-lxd/main.go @@ -44,6 +44,7 @@ __attribute__((constructor)) void init(void) { import "C" import ( + "fmt" "os" "github.com/spf13/cobra" @@ -83,6 +84,7 @@ func main() { // Run the main command and handle errors err := app.Execute() if err != nil { + fmt.Fprintln(os.Stderr, err) os.Exit(1) } } diff --git a/lxc-to-lxd/main_migrate.go b/lxc-to-lxd/main_migrate.go index 09125ce10811..0ed748272562 100644 --- a/lxc-to-lxd/main_migrate.go +++ b/lxc-to-lxd/main_migrate.go @@ -2,8 +2,8 @@ package main import ( "encoding/json" + "errors" "fmt" - "os" "runtime" "strconv" "strings" @@ -63,9 +63,9 @@ func (c *cmdMigrate) Command() *cobra.Command { // RunE executes the migrate command. func (c *cmdMigrate) RunE(cmd *cobra.Command, args []string) error { if (len(c.flagContainers) == 0 && !c.flagAll) || (len(c.flagContainers) > 0 && c.flagAll) { - fmt.Fprintln(os.Stderr, "You must either pass container names or --all") - os.Exit(1) + return errors.New("You must either pass container names or --all") } + // Connect to LXD d, err := lxd.ConnectLXDUnix("", nil) if err != nil { @@ -381,7 +381,7 @@ func convertContainer(d lxd.ContainerServer, container *liblxc.Container, storag req := api.ContainersPost{ Name: container.Name(), Source: api.ContainerSource{ - Type: "migration", + Type: api.SourceTypeMigration, Mode: "push", }, } diff --git a/lxc/action.go b/lxc/action.go index d8000109870a..33961ed2c216 100644 --- a/lxc/action.go +++ b/lxc/action.go @@ -33,6 +33,10 @@ func (c *cmdStart) command() *cobra.Command { cmd.Long = cli.FormatSection(i18n.G("Description"), i18n.G( `Start instances`)) + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return c.global.cmpInstancesAction(toComplete, "start", c.action.flagForce) + } + return cmd } @@ -55,6 +59,10 @@ func (c *cmdPause) command() *cobra.Command { `Pause instances`)) cmd.Aliases = []string{"freeze"} + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return c.global.cmpInstancesAction(toComplete, "pause", c.action.flagForce) + } + return cmd } @@ -78,6 +86,10 @@ func (c *cmdRestart) command() *cobra.Command { The opposite of "lxc pause" is "lxc start".`)) + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return c.global.cmpInstancesAction(toComplete, "restart", c.action.flagForce) + } + return cmd } @@ -99,6 +111,10 @@ func (c *cmdStop) command() *cobra.Command { cmd.Long = cli.FormatSection(i18n.G("Description"), i18n.G( `Stop instances`)) + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return c.global.cmpInstancesAction(toComplete, "stop", c.action.flagForce) + } + return cmd } diff --git a/lxc/auth.go b/lxc/auth.go index db9995e85921..ddfbedb2fd3f 100644 --- a/lxc/auth.go +++ b/lxc/auth.go @@ -1,13 +1,17 @@ package main import ( + "encoding/base64" + "encoding/json" "errors" "fmt" "io" + "net/url" "os" "sort" "strings" + "github.com/google/uuid" "github.com/spf13/cobra" "gopkg.in/yaml.v2" @@ -18,6 +22,7 @@ import ( "github.com/canonical/lxd/shared/entity" "github.com/canonical/lxd/shared/i18n" "github.com/canonical/lxd/shared/termios" + "github.com/canonical/lxd/shared/version" ) type cmdAuth struct { @@ -732,6 +737,9 @@ func (c *cmdIdentity) command() *cobra.Command { cmd.Long = cli.FormatSection(i18n.G("Description"), i18n.G( `Manage identities`)) + identityCreateCmd := cmdIdentityCreate{global: c.global} + cmd.AddCommand(identityCreateCmd.command()) + identityListCmd := cmdIdentityList{global: c.global} cmd.AddCommand(identityListCmd.command()) @@ -744,6 +752,9 @@ func (c *cmdIdentity) command() *cobra.Command { identityEditCmd := cmdIdentityEdit{global: c.global} cmd.AddCommand(identityEditCmd.command()) + identityDeleteCmd := cmdIdentityDelete{global: c.global} + cmd.AddCommand(identityDeleteCmd.command()) + identityGroupCmd := cmdIdentityGroup{global: c.global} cmd.AddCommand(identityGroupCmd.command()) @@ -753,6 +764,147 @@ func (c *cmdIdentity) command() *cobra.Command { return cmd } +type cmdIdentityCreate struct { + global *cmdGlobal + flagGroups []string +} + +func (c *cmdIdentityCreate) command() *cobra.Command { + cmd := &cobra.Command{} + cmd.Use = usage("create", i18n.G("[:]/ [] [[--group ]]")) + cmd.Short = i18n.G("Create an identity") + cmd.Long = cli.FormatSection(i18n.G("Description"), i18n.G( + `Create a TLS identity`)) + + cmd.RunE = c.run + cmd.Flags().StringSliceVarP(&c.flagGroups, "group", "g", []string{}, "Groups to add to the identity") + + return cmd +} + +func (c *cmdIdentityCreate) run(cmd *cobra.Command, args []string) error { + var stdinData api.IdentitiesTLSPost + + // Quick checks. + exit, err := c.global.CheckArgs(cmd, args, 1, 2) + if exit { + return err + } + + // If stdin isn't a terminal, read text from it + if !termios.IsTerminal(getStdinFd()) { + contents, err := io.ReadAll(os.Stdin) + if err != nil { + return err + } + + err = yaml.Unmarshal(contents, &stdinData) + if err != nil { + return err + } + } + + // Parse remote + remoteName, resourceName, err := c.global.conf.ParseRemote(args[0]) + if err != nil { + return err + } + + transporter, wrapper := newLocationHeaderTransportWrapper() + client, err := c.global.conf.GetInstanceServerWithTransportWrapper(remoteName, wrapper) + if err != nil { + return err + } + + if resourceName == "" { + return errors.New(i18n.G("Missing identity argument")) + } + + authMethod, name, ok := strings.Cut(resourceName, "/") + if !ok { + return errors.New(i18n.G("Malformed argument, expected `[:]/`, got ") + args[0]) + } + + if authMethod != api.AuthenticationMethodTLS { + return errors.New(i18n.G("Identity creation only supported for TLS identities")) + } + + // Add name and groups to any stdin data + stdinData.Name = name + for _, group := range c.flagGroups { + if !shared.ValueInSlice(group, stdinData.Groups) { + stdinData.Groups = append(stdinData.Groups, group) + } + } + + // If the certificate argument is provided, read it and add it to the stdin data. + if len(args) == 2 { + pemEncodedX509Cert, err := os.ReadFile(args[1]) + if err != nil { + return err + } + + stdinData.Certificate = string(pemEncodedX509Cert) + } + + // Expect that if the caller did not provide a certificate then they want to get a token. + if stdinData.Certificate == "" { + stdinData.Token = true + token, err := client.CreateIdentityTLSToken(stdinData) + if err != nil { + return err + } + + if !c.global.flagQuiet { + pendingIdentityURL, err := url.Parse(transporter.location) + if err != nil { + return fmt.Errorf("Received invalid location header %q: %w", transporter.location, err) + } + + var pendingIdentityUUIDStr string + identityURLPrefix := api.NewURL().Path(version.APIVersion, "auth", "identities", authMethod).String() + _, err = fmt.Sscanf(pendingIdentityURL.Path, identityURLPrefix+"/%s", &pendingIdentityUUIDStr) + if err != nil { + return fmt.Errorf("Received unexpected location header %q: %w", transporter.location, err) + } + + pendingIdentityUUID, err := uuid.Parse(pendingIdentityUUIDStr) + if err != nil { + return fmt.Errorf("Received invalid pending identity UUID %q: %w", pendingIdentityUUIDStr, err) + } + + fmt.Printf(i18n.G("TLS identity %q (%s) pending identity token:")+"\n", resourceName, pendingIdentityUUID.String()) + } + + // Encode certificate add token to JSON. + tokenJSON, err := json.Marshal(token) + if err != nil { + return fmt.Errorf("Failed to encode identity token: %w", err) + } + + // Print the base64 encoded token. + fmt.Println(base64.StdEncoding.EncodeToString(tokenJSON)) + return nil + } + + fingerprint, err := shared.CertFingerprintStr(stdinData.Certificate) + if err != nil { + return err + } + + // Otherwise create the identity directly. + err = client.CreateIdentityTLS(stdinData) + if err != nil { + return err + } + + if !c.global.flagQuiet { + fmt.Printf(i18n.G("TLS identity %q created with fingerprint %q")+"\n", resourceName, fingerprint) + } + + return nil +} + type cmdIdentityList struct { global *cmdGlobal flagFormat string @@ -1074,6 +1226,61 @@ func (c *cmdIdentityEdit) run(cmd *cobra.Command, args []string) error { return nil } +type cmdIdentityDelete struct { + global *cmdGlobal +} + +func (c *cmdIdentityDelete) command() *cobra.Command { + cmd := &cobra.Command{} + cmd.Use = usage("delete", i18n.G("[:]/")) + cmd.Aliases = []string{"rm"} + cmd.Short = i18n.G("Delete an identity") + cmd.Long = cli.FormatSection(i18n.G("Description"), i18n.G( + `Delete an identity`)) + cmd.Example = cli.FormatSection("", `lxc auth identity delete oidc/jane.doe@example.com + Delete the OIDC identity with email address "jane.doe@example.com" in the default remote. + +lxc auth identity delete oidc/'Jane Doe' + Delete the OIDC identity with name "Jane Doe" in the default remote (there must be only one OIDC identity on the server with this name). + +lxc auth identity delete my-remote:tls/12beaccbf9e7b7445185581b70099a5962c927e85006d5883856d909fe79f976 + Delete the TLS identity with certificate fingerprint "12beaccbf9e7b7445185581b70099a5962c927e85006d5883856d909fe79f976" in remote "my-remote". + +lxc auth identity delete my-remote:tls/jane-doe + Delete the TLS identity with name "jane-doe" in remote "my-remote" (there must be only one TLS identity on "my-remote" with this name). +`) + cmd.RunE = c.run + + return cmd +} + +func (c *cmdIdentityDelete) run(cmd *cobra.Command, args []string) error { + // Quick checks. + exit, err := c.global.CheckArgs(cmd, args, 1, 1) + if exit { + return err + } + + // Parse remote + resources, err := c.global.ParseServers(args[0]) + if err != nil { + return err + } + + resource := resources[0] + + if resource.name == "" { + return errors.New(i18n.G("Missing identity argument")) + } + + authenticationMethod, nameOrID, ok := strings.Cut(resource.name, "/") + if !ok { + return fmt.Errorf("Malformed argument, expected `[:]/`, got %q", args[0]) + } + + return resource.server.DeleteIdentity(authenticationMethod, nameOrID) +} + type cmdIdentityGroup struct { global *cmdGlobal } diff --git a/lxc/cluster.go b/lxc/cluster.go index bd9bd5b8e575..82fb0e280ab7 100644 --- a/lxc/cluster.go +++ b/lxc/cluster.go @@ -126,6 +126,14 @@ func (c *cmdClusterList) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpRemotes(false) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -209,6 +217,14 @@ func (c *cmdClusterShow) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpClusterMembers(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -258,6 +274,14 @@ func (c *cmdClusterInfo) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpClusterMembers(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -309,6 +333,18 @@ func (c *cmdClusterGet) command() *cobra.Command { cmd.Flags().BoolVarP(&c.flagIsProperty, "property", "p", false, i18n.G("Get the key as a cluster property")) cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpClusterMembers(toComplete) + } + + if len(args) == 1 { + return c.global.cmpClusterMemberConfigs(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -370,6 +406,14 @@ func (c *cmdClusterSet) command() *cobra.Command { cmd.Flags().BoolVarP(&c.flagIsProperty, "property", "p", false, i18n.G("Set the key as a cluster property")) cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpClusterMembers(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -442,6 +486,18 @@ func (c *cmdClusterUnset) command() *cobra.Command { cmd.Flags().BoolVarP(&c.flagIsProperty, "property", "p", false, i18n.G("Unset the key as a cluster property")) cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpClusterMembers(toComplete) + } + + if len(args) == 1 { + return c.global.cmpClusterMemberConfigs(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -474,6 +530,14 @@ func (c *cmdClusterRename) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpClusterMembers(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -526,6 +590,14 @@ func (c *cmdClusterRemove) command() *cobra.Command { cmd.Flags().BoolVarP(&c.flagForce, "force", "f", false, i18n.G("Force removing a member, even if degraded")) cmd.Flags().BoolVar(&c.flagNonInteractive, "yes", false, i18n.G("Don't require user confirmation for using --force")) + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpClusterMembers(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -614,6 +686,14 @@ func (c *cmdClusterEnable) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpRemotes(false) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -685,7 +765,7 @@ type cmdClusterEdit struct { func (c *cmdClusterEdit) command() *cobra.Command { cmd := &cobra.Command{} - cmd.Use = usage("edit", i18n.G("[:]")) + cmd.Use = usage("edit", i18n.G("[:]")) cmd.Short = i18n.G("Edit cluster member configurations as YAML") cmd.Long = cli.FormatSection(i18n.G("Description"), i18n.G( `Edit cluster member configurations as YAML`)) @@ -695,6 +775,14 @@ func (c *cmdClusterEdit) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpClusterMembers(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -800,13 +888,21 @@ type cmdClusterAdd struct { func (c *cmdClusterAdd) command() *cobra.Command { cmd := &cobra.Command{} - cmd.Use = usage("add", i18n.G("[[:]]")) + cmd.Use = usage("add", i18n.G("[[:]]")) cmd.Short = i18n.G("Request a join token for adding a cluster member") cmd.Long = cli.FormatSection(i18n.G("Description"), i18n.G(`Request a join token for adding a cluster member`)) cmd.Flags().StringVar(&c.flagName, "name", "", i18n.G("Cluster member name (alternative to passing it as an argument)")+"``") cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpClusterMembers(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -883,6 +979,14 @@ func (c *cmdClusterListTokens) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpRemotes(false) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -983,6 +1087,15 @@ func (c *cmdClusterRevokeToken) command() *cobra.Command { cmd.Long = cli.FormatSection(i18n.G("Description"), cmd.Short) cmd.RunE = c.run + + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpClusterMembers(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -1063,6 +1176,23 @@ func (c *cmdClusterUpdateCertificate) command() *cobra.Command { i18n.G("Update cluster certificate with PEM certificate and key read from input files.")) cmd.RunE = c.run + + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpClusterMembers(toComplete) + } + + if len(args) == 1 { + return nil, cobra.ShellCompDirectiveDefault + } + + if len(args) == 2 { + return nil, cobra.ShellCompDirectiveDefault + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -1171,6 +1301,14 @@ func (c *cmdClusterEvacuate) command() *cobra.Command { cmd.Flags().BoolVar(&c.action.flagForce, "force", false, i18n.G(`Force evacuation without user confirmation`)+"``") cmd.Flags().StringVar(&c.action.flagAction, "action", "", i18n.G(`Force a particular evacuation action`)+"``") + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpClusterMembers(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -1192,6 +1330,14 @@ func (c *cmdClusterRestore) command() *cobra.Command { cmd.Flags().BoolVar(&c.action.flagForce, "force", false, i18n.G(`Force restoration without user confirmation`)+"``") + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpClusterMembers(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } diff --git a/lxc/cluster_group.go b/lxc/cluster_group.go index 5446eec88d91..291a73c7128b 100644 --- a/lxc/cluster_group.go +++ b/lxc/cluster_group.go @@ -93,6 +93,18 @@ lxc cluster group assign foo default cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpClusterMembers(toComplete) + } + + if len(args) == 1 { + return c.global.cmpClusterGroupNames(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -165,6 +177,14 @@ lxc cluster group create g1 < config.yaml cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpRemotes(false) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -238,6 +258,14 @@ func (c *cmdClusterGroupDelete) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpClusterGroups(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -290,6 +318,14 @@ func (c *cmdClusterGroupEdit) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpClusterGroups(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -407,6 +443,14 @@ func (c *cmdClusterGroupList) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpRemotes(false) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -480,6 +524,18 @@ func (c *cmdClusterGroupRemove) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpClusterMembers(toComplete) + } + + if len(args) == 1 { + return c.global.cmpClusterGroupNames(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -553,6 +609,14 @@ func (c *cmdClusterGroupRename) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpClusterGroups(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -601,6 +665,14 @@ func (c *cmdClusterGroupShow) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpClusterGroups(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -655,6 +727,18 @@ func (c *cmdClusterGroupAdd) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpClusterMembers(toComplete) + } + + if len(args) == 1 { + return c.global.cmpClusterGroupNames(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } diff --git a/lxc/cluster_role.go b/lxc/cluster_role.go index ef2d3a4181b3..911bd9a4e716 100644 --- a/lxc/cluster_role.go +++ b/lxc/cluster_role.go @@ -53,6 +53,14 @@ func (c *cmdClusterRoleAdd) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpClusterMembers(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -109,6 +117,18 @@ func (c *cmdClusterRoleRemove) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpClusterMembers(toComplete) + } + + if len(args) == 1 { + return c.global.cmpClusterMemberRoles(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } diff --git a/lxc/completion.go b/lxc/completion.go new file mode 100644 index 000000000000..4cfdd675a608 --- /dev/null +++ b/lxc/completion.go @@ -0,0 +1,1573 @@ +package main + +import ( + "regexp" + "strings" + + "github.com/spf13/cobra" + + "github.com/canonical/lxd/shared" + "github.com/canonical/lxd/shared/api" +) + +// cmpClusterGroupNames provides shell completion for cluster group names. +// It takes a partial input string and returns a list of matching names along with a shell completion directive. +func (g *cmdGlobal) cmpClusterGroupNames(toComplete string) ([]string, cobra.ShellCompDirective) { + var results []string + cmpDirectives := cobra.ShellCompDirectiveNoFileComp + + resources, _ := g.ParseServers(toComplete) + + if len(resources) <= 0 { + return nil, cobra.ShellCompDirectiveError + } + + resource := resources[0] + + cluster, _, err := resource.server.GetCluster() + if err != nil || !cluster.Enabled { + return nil, cobra.ShellCompDirectiveError + } + + results, err = resource.server.GetClusterGroupNames() + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + return results, cmpDirectives +} + +// cmpClusterGroups provides shell completion for cluster groups and their remotes. +// It takes a partial input string and returns a list of matching cluster groups along with a shell completion directive. +func (g *cmdGlobal) cmpClusterGroups(toComplete string) ([]string, cobra.ShellCompDirective) { + var results []string + cmpDirectives := cobra.ShellCompDirectiveNoFileComp + + resources, _ := g.ParseServers(toComplete) + + if len(resources) <= 0 { + return nil, cobra.ShellCompDirectiveError + } + + resource := resources[0] + + cluster, _, err := resource.server.GetCluster() + if err != nil || !cluster.Enabled { + return nil, cobra.ShellCompDirectiveError + } + + groups, err := resource.server.GetClusterGroupNames() + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + for _, group := range groups { + var name string + + if resource.remote == g.conf.DefaultRemote && !strings.Contains(toComplete, g.conf.DefaultRemote) { + name = group + } else { + name = resource.remote + ":" + group + } + + results = append(results, name) + } + + if !strings.Contains(toComplete, ":") { + remotes, directives := g.cmpRemotes(false) + results = append(results, remotes...) + cmpDirectives |= directives + } + + return results, cmpDirectives +} + +// cmpClusterMemberConfigs provides shell completion for cluster member configs. +// It takes a partial input string (member name) and returns a list of matching cluster member configs along with a shell completion directive. +func (g *cmdGlobal) cmpClusterMemberConfigs(memberName string) ([]string, cobra.ShellCompDirective) { + // Parse remote + resources, err := g.ParseServers(memberName) + if err != nil || len(resources) == 0 { + return nil, cobra.ShellCompDirectiveError + } + + resource := resources[0] + client := resource.server + + cluster, _, err := client.GetCluster() + if err != nil || !cluster.Enabled { + return nil, cobra.ShellCompDirectiveError + } + + member, _, err := client.GetClusterMember(memberName) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + var results []string + for k := range member.Config { + results = append(results, k) + } + + return results, cobra.ShellCompDirectiveNoFileComp +} + +// cmpClusterMemberRoles provides shell completion for cluster member roles. +// It takes a member name and returns a list of matching cluster member roles along with a shell completion directive. +func (g *cmdGlobal) cmpClusterMemberRoles(memberName string) ([]string, cobra.ShellCompDirective) { + // Parse remote + resources, err := g.ParseServers(memberName) + if err != nil || len(resources) == 0 { + return nil, cobra.ShellCompDirectiveError + } + + resource := resources[0] + client := resource.server + + cluster, _, err := client.GetCluster() + if err != nil || !cluster.Enabled { + return nil, cobra.ShellCompDirectiveError + } + + member, _, err := client.GetClusterMember(memberName) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + return member.Roles, cobra.ShellCompDirectiveNoFileComp +} + +// cmpClusterMembers provides shell completion for cluster members. +// It takes a partial input string and returns a list of matching cluster members along with a shell completion directive. +func (g *cmdGlobal) cmpClusterMembers(toComplete string) ([]string, cobra.ShellCompDirective) { + var results []string + cmpDirectives := cobra.ShellCompDirectiveNoFileComp + + resources, _ := g.ParseServers(toComplete) + + if len(resources) > 0 { + resource := resources[0] + + cluster, _, err := resource.server.GetCluster() + if err != nil || !cluster.Enabled { + return nil, cobra.ShellCompDirectiveError + } + + // Get the cluster members + members, err := resource.server.GetClusterMembers() + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + for _, member := range members { + var name string + + if resource.remote == g.conf.DefaultRemote && !strings.Contains(toComplete, g.conf.DefaultRemote) { + name = member.ServerName + } else { + name = resource.remote + ":" + member.ServerName + } + + results = append(results, name) + } + } + + if !strings.Contains(toComplete, ":") { + remotes, directives := g.cmpRemotes(false) + results = append(results, remotes...) + cmpDirectives |= directives + } + + return results, cmpDirectives +} + +// cmpImages provides shell completion for image aliases. +// It takes a partial input string and returns a list of matching image aliases along with a shell completion directive. +func (g *cmdGlobal) cmpImages(toComplete string) ([]string, cobra.ShellCompDirective) { + var results []string + var remote string + cmpDirectives := cobra.ShellCompDirectiveNoFileComp + + if strings.Contains(toComplete, ":") { + remote = strings.Split(toComplete, ":")[0] + } else { + remote = g.conf.DefaultRemote + } + + remoteServer, _ := g.conf.GetImageServer(remote) + + images, _ := remoteServer.GetImages() + + for _, image := range images { + for _, alias := range image.Aliases { + var name string + + if remote == g.conf.DefaultRemote && !strings.Contains(toComplete, g.conf.DefaultRemote) { + name = alias.Name + } else { + name = remote + ":" + alias.Name + } + + results = append(results, name) + } + } + + if !strings.Contains(toComplete, ":") { + remotes, directives := g.cmpRemotes(true) + results = append(results, remotes...) + cmpDirectives |= directives + } + + return results, cmpDirectives +} + +// cmpInstanceKeys provides shell completion for all instance configuration keys. +// It takes an instance name to determine instance type and returns a list of all instance configuration keys along with a shell completion directive. +func (g *cmdGlobal) cmpInstanceKeys(instanceName string) ([]string, cobra.ShellCompDirective) { + cmpDirectives := cobra.ShellCompDirectiveNoFileComp + + // Early return when completing server keys. + _, instanceNameOnly, found := strings.Cut(instanceName, ":") + if instanceNameOnly == "" && found { + return g.cmpServerAllKeys(instanceName) + } + + resources, err := g.ParseServers(instanceName) + if err != nil || len(resources) == 0 { + return nil, cobra.ShellCompDirectiveError + } + + resource := resources[0] + client := resource.server + + instance, _, err := client.GetInstance(instanceName) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + // Complete keys based on instance type. + instanceType := instance.Type + + metadataConfiguration, err := client.GetMetadataConfiguration() + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + instanceConfig, ok := metadataConfiguration.Configs["instance"] + if !ok { + return nil, cobra.ShellCompDirectiveError + } + + // Pre-allocate configKeys slice capacity. + keyCount := 0 + for _, field := range instanceConfig { + keyCount += len(field.Keys) + } + + configKeys := make([]string, 0, keyCount) + + for _, field := range instanceConfig { + for _, key := range field.Keys { + for configKey, configKeyField := range key { + configKey = strings.TrimSuffix(configKey, "*") + + // InstanceTypeAny config keys. + if configKeyField.Condition == "" { + configKeys = append(configKeys, configKey) + continue + } + + if instanceType == string(api.InstanceTypeContainer) && configKeyField.Condition == "container" { + configKeys = append(configKeys, configKey) + } else if instanceType == string(api.InstanceTypeVM) && configKeyField.Condition == "virtual machine" { + configKeys = append(configKeys, configKey) + } + } + } + } + + return configKeys, cmpDirectives | cobra.ShellCompDirectiveNoSpace +} + +// cmpInstanceAllKeys provides shell completion for all possible instance configuration keys. +// It returns a list of all possible instance configuration keys along with a shell completion directive. +func (g *cmdGlobal) cmpInstanceAllKeys(profileName string) ([]string, cobra.ShellCompDirective) { + cmpDirectives := cobra.ShellCompDirectiveNoFileComp + + // Parse remote + resources, err := g.ParseServers(profileName) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + resource := resources[0] + client := resource.server + + metadataConfiguration, err := client.GetMetadataConfiguration() + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + instanceConfig, ok := metadataConfiguration.Configs["instance"] + if !ok { + return nil, cobra.ShellCompDirectiveError + } + + // Pre-allocate configKeys slice capacity. + keyCount := 0 + for _, field := range instanceConfig { + keyCount += len(field.Keys) + } + + configKeys := make([]string, 0, keyCount) + + for _, field := range instanceConfig { + for _, key := range field.Keys { + for configKey := range key { + configKey = strings.TrimSuffix(configKey, "*") + configKeys = append(configKeys, configKey) + } + } + } + + return configKeys, cmpDirectives | cobra.ShellCompDirectiveNoSpace +} + +// cmpInstanceSetKeys provides shell completion for instance configuration keys which are currently set. +// It takes an instance name to determine instance type and returns a list of instance configuration keys along with a shell completion directive. +func (g *cmdGlobal) cmpInstanceSetKeys(instanceName string) ([]string, cobra.ShellCompDirective) { + cmpDirectives := cobra.ShellCompDirectiveNoFileComp + + // Early return when completing server keys. + _, instanceNameOnly, found := strings.Cut(instanceName, ":") + if instanceNameOnly == "" && found { + return g.cmpServerAllKeys(instanceName) + } + + resources, err := g.ParseServers(instanceName) + if err != nil || len(resources) == 0 { + return nil, cobra.ShellCompDirectiveError + } + + resource := resources[0] + client := resource.server + + instance, _, err := client.GetInstance(instanceName) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + // Fetch all config keys that can be set by a user. + allInstanceConfigKeys, _ := g.cmpInstanceAllKeys(instanceName) + + // Convert slice to map[string]struct{} for O(1) lookups. + keySet := make(map[string]struct{}, len(allInstanceConfigKeys)) + for _, key := range allInstanceConfigKeys { + keySet[key] = struct{}{} + } + + // Pre-allocate configKeys slice capacity. + keyCount := len(instance.Config) + configKeys := make([]string, 0, keyCount) + + for configKey := range instance.Config { + // We only want to return the intersection between allInstanceConfigKeys and configKeys to avoid returning the full instance config. + _, exists := keySet[configKey] + if exists { + configKeys = append(configKeys, configKey) + } + } + + return configKeys, cmpDirectives | cobra.ShellCompDirectiveNoSpace +} + +// cmpServerAllKeys provides shell completion for all server configuration keys. +// It takes an instance name and returns a list of all server configuration keys along with a shell completion directive. +func (g *cmdGlobal) cmpServerAllKeys(instanceName string) ([]string, cobra.ShellCompDirective) { + resources, err := g.ParseServers(instanceName) + if err != nil || len(resources) == 0 { + return nil, cobra.ShellCompDirectiveError + } + + resource := resources[0] + client := resource.server + + metadataConfiguration, err := client.GetMetadataConfiguration() + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + server, ok := metadataConfiguration.Configs["server"] + if !ok { + return nil, cobra.ShellCompDirectiveError + } + + keyCount := 0 + for _, field := range server { + keyCount += len(field.Keys) + } + + keys := make([]string, 0, keyCount) + + for _, field := range server { + for _, keyMap := range field.Keys { + for key := range keyMap { + keys = append(keys, key) + } + } + } + + return keys, cobra.ShellCompDirectiveNoFileComp +} + +// cmpInstanceConfigTemplates provides shell completion for instance config templates. +// It takes an instance name and returns a list of instance config templates along with a shell completion directive. +func (g *cmdGlobal) cmpInstanceConfigTemplates(instanceName string) ([]string, cobra.ShellCompDirective) { + // Parse remote + resources, err := g.ParseServers(instanceName) + if err != nil || len(resources) == 0 { + return nil, cobra.ShellCompDirectiveError + } + + resource := resources[0] + client := resource.server + + var instanceNameOnly = instanceName + if strings.Contains(instanceName, ":") { + instanceNameOnly = strings.Split(instanceName, ":")[1] + } + + results, err := client.GetInstanceTemplateFiles(instanceNameOnly) + if err != nil { + cobra.CompDebug(err.Error(), true) + return nil, cobra.ShellCompDirectiveError + } + + return results, cobra.ShellCompDirectiveNoFileComp +} + +// cmpInstanceDeviceNames provides shell completion for instance devices. +// It takes an instance name and returns a list of instance device names along with a shell completion directive. +func (g *cmdGlobal) cmpInstanceDeviceNames(instanceName string) ([]string, cobra.ShellCompDirective) { + // Parse remote + resources, err := g.ParseServers(instanceName) + if err != nil || len(resources) == 0 { + return nil, cobra.ShellCompDirectiveError + } + + resource := resources[0] + client := resource.server + + instanceNameOnly, _, err := client.GetInstance(instanceName) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + var results []string + for k := range instanceNameOnly.Devices { + results = append(results, k) + } + + return results, cobra.ShellCompDirectiveNoFileComp +} + +// cmpInstanceAllDevices provides shell completion for all instance devices. +// It takes an instance name and returns a list of all possible instance devices along with a shell completion directive. +func (g *cmdGlobal) cmpInstanceAllDevices(instanceName string) ([]string, cobra.ShellCompDirective) { + resources, err := g.ParseServers(instanceName) + if err != nil || len(resources) == 0 { + return nil, cobra.ShellCompDirectiveError + } + + resource := resources[0] + client := resource.server + + metadataConfiguration, err := client.GetMetadataConfiguration() + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + devices := make([]string, 0, len(metadataConfiguration.Configs)) + + for key := range metadataConfiguration.Configs { + if strings.HasPrefix(key, "device-") { + parts := strings.Split(key, "-") + deviceName := parts[1] + devices = append(devices, deviceName) + } + } + + return devices, cobra.ShellCompDirectiveNoFileComp +} + +// cmpInstanceAllDeviceOptions provides shell completion for all instance device options. +// It takes an instance name and device name and returns a list of all possible instance device options along with a shell completion directive. +func (g *cmdGlobal) cmpInstanceAllDeviceOptions(instanceName string, deviceName string) ([]string, cobra.ShellCompDirective) { + resources, err := g.ParseServers(instanceName) + if err != nil || len(resources) == 0 { + return nil, cobra.ShellCompDirectiveError + } + + resource := resources[0] + client := resource.server + + metadataConfiguration, err := client.GetMetadataConfiguration() + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + deviceOptions := make([]string, 0, len(metadataConfiguration.Configs)) + + for key, device := range metadataConfiguration.Configs { + parts := strings.Split(key, "-") + if strings.HasPrefix(key, "device-") && parts[1] == deviceName { + conf := device["device-conf"] + for _, keyMap := range conf.Keys { + for option := range keyMap { + deviceOptions = append(deviceOptions, option) + } + } + } + } + + return deviceOptions, cobra.ShellCompDirectiveNoFileComp +} + +// cmpInstances provides shell completion for all instances. +// It takes a partial input string and returns a list of matching instances along with a shell completion directive. +func (g *cmdGlobal) cmpInstances(toComplete string) ([]string, cobra.ShellCompDirective) { + var results []string + cmpDirectives := cobra.ShellCompDirectiveNoFileComp + + resources, _ := g.ParseServers(toComplete) + + if len(resources) > 0 { + resource := resources[0] + + instances, _ := resource.server.GetInstanceNames("") + + for _, instance := range instances { + var name string + + if resource.remote == g.conf.DefaultRemote && !strings.Contains(toComplete, g.conf.DefaultRemote) { + name = instance + } else { + name = resource.remote + ":" + instance + } + + results = append(results, name) + } + } + + if !strings.Contains(toComplete, ":") { + remotes, _ := g.cmpRemotes(false) + results = append(results, remotes...) + } + + return results, cmpDirectives +} + +// cmpInstancesAction provides shell completion for all instance actions (start, pause, exec, stop and delete). +// It takes a partial input string, an action, and a boolean indicating if the force flag has been passed in. It returns a list of applicable instances based on their state and the requested action, along with a shell completion directive. +func (g *cmdGlobal) cmpInstancesAction(toComplete string, action string, flagForce bool) ([]string, cobra.ShellCompDirective) { + var results []string + cmpDirectives := cobra.ShellCompDirectiveNoFileComp + + resources, _ := g.ParseServers(toComplete) + + var filteredInstanceStatuses []string + + switch action { + case "start": + filteredInstanceStatuses = append(filteredInstanceStatuses, "Stopped", "Frozen") + case "pause", "exec": + filteredInstanceStatuses = append(filteredInstanceStatuses, "Running") + case "stop": + if flagForce { + filteredInstanceStatuses = append(filteredInstanceStatuses, "Running", "Frozen") + } else { + filteredInstanceStatuses = append(filteredInstanceStatuses, "Running") + } + + case "delete": + if flagForce { + filteredInstanceStatuses = append(filteredInstanceStatuses, api.GetAllStatusCodeStrings()...) + } else { + filteredInstanceStatuses = append(filteredInstanceStatuses, "Stopped") + } + + default: + filteredInstanceStatuses = append(filteredInstanceStatuses, api.GetAllStatusCodeStrings()...) + } + + if len(resources) > 0 { + resource := resources[0] + + instances, _ := resource.server.GetInstances("") + + for _, instance := range instances { + var name string + + if shared.ValueInSlice(instance.Status, filteredInstanceStatuses) { + if resource.remote == g.conf.DefaultRemote && !strings.Contains(toComplete, g.conf.DefaultRemote) { + name = instance.Name + } else { + name = resource.remote + ":" + instance.Name + } + + results = append(results, name) + } + } + + if !strings.Contains(toComplete, ":") { + remotes, directives := g.cmpRemotes(false) + results = append(results, remotes...) + cmpDirectives |= directives + } + } + + return results, cmpDirectives +} + +// cmpInstancesAndSnapshots provides shell completion for instances and their snapshots. +// It takes a partial input string and returns a list of matching instances and their snapshots, along with a shell completion directive. +func (g *cmdGlobal) cmpInstancesAndSnapshots(toComplete string) ([]string, cobra.ShellCompDirective) { + results := []string{} + cmpDirectives := cobra.ShellCompDirectiveNoFileComp + + resources, _ := g.ParseServers(toComplete) + + if len(resources) > 0 { + resource := resources[0] + + if strings.Contains(resource.name, shared.SnapshotDelimiter) { + instName, _, _ := strings.Cut(resource.name, shared.SnapshotDelimiter) + snapshots, _ := resource.server.GetInstanceSnapshotNames(instName) + for _, snapshot := range snapshots { + results = append(results, instName+"/"+snapshot) + } + } else { + instances, _ := resource.server.GetInstanceNames("") + for _, instance := range instances { + var name string + + if resource.remote == g.conf.DefaultRemote && !strings.Contains(toComplete, g.conf.DefaultRemote) { + name = instance + } else { + name = resource.remote + ":" + instance + } + + results = append(results, name) + } + } + } + + if !strings.Contains(toComplete, ":") { + remotes, directives := g.cmpRemotes(false) + results = append(results, remotes...) + cmpDirectives |= directives + } + + return results, cmpDirectives +} + +// cmpInstanceNamesFromRemote provides shell completion for instances for a specific remote. +// It takes a partial input string and returns a list of matching instances along with a shell completion directive. +func (g *cmdGlobal) cmpInstanceNamesFromRemote(toComplete string) ([]string, cobra.ShellCompDirective) { + var results []string + + resources, _ := g.ParseServers(toComplete) + + if len(resources) > 0 { + resource := resources[0] + + containers, _ := resource.server.GetInstanceNames("container") + results = append(results, containers...) + vms, _ := resource.server.GetInstanceNames("virtual-machine") + results = append(results, vms...) + } + + return results, cobra.ShellCompDirectiveNoFileComp +} + +// cmpNetworkACLConfigs provides shell completion for network ACL configs. +// It takes an ACL name and returns a list of network ACL configs along with a shell completion directive. +func (g *cmdGlobal) cmpNetworkACLConfigs(aclName string) ([]string, cobra.ShellCompDirective) { + // Parse remote + resources, err := g.ParseServers(aclName) + if err != nil || len(resources) == 0 { + return nil, cobra.ShellCompDirectiveError + } + + resource := resources[0] + client := resource.server + + acl, _, err := client.GetNetworkACL(resource.name) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + var results []string + for k := range acl.Config { + results = append(results, k) + } + + return results, cobra.ShellCompDirectiveNoFileComp +} + +// cmpNetworkACLs provides shell completion for network ACL's. +// It takes a partial input string and returns a list of matching network ACL's along with a shell completion directive. +func (g *cmdGlobal) cmpNetworkACLs(toComplete string) ([]string, cobra.ShellCompDirective) { + var results []string + cmpDirectives := cobra.ShellCompDirectiveNoFileComp + + resources, _ := g.ParseServers(toComplete) + + if len(resources) <= 0 { + return nil, cobra.ShellCompDirectiveError + } + + resource := resources[0] + + acls, err := resource.server.GetNetworkACLNames() + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + for _, acl := range acls { + var name string + + if resource.remote == g.conf.DefaultRemote && !strings.Contains(toComplete, g.conf.DefaultRemote) { + name = acl + } else { + name = resource.remote + ":" + acl + } + + results = append(results, name) + } + + if !strings.Contains(toComplete, ":") { + remotes, directives := g.cmpRemotes(false) + results = append(results, remotes...) + cmpDirectives |= directives + } + + return results, cmpDirectives +} + +// cmpNetworkACLRuleProperties provides shell completion for network ACL rule properties. +// It returns a list of network ACL rules provided by `networkACLRuleJSONStructFieldMap()“ along with a shell completion directive. +func (g *cmdGlobal) cmpNetworkACLRuleProperties() ([]string, cobra.ShellCompDirective) { + var results []string + + allowedKeys := networkACLRuleJSONStructFieldMap() + for key := range allowedKeys { + results = append(results, key+"=") + } + + return results, cobra.ShellCompDirectiveNoSpace +} + +// cmpNetworkForwardConfigs provides shell completion for network forward configs. +// It takes a network name and listen address, and returns a list of network forward configs along with a shell completion directive. +func (g *cmdGlobal) cmpNetworkForwardConfigs(networkName string, listenAddress string) ([]string, cobra.ShellCompDirective) { + // Parse remote + resources, err := g.ParseServers(networkName) + if err != nil || len(resources) == 0 { + return nil, cobra.ShellCompDirectiveError + } + + resource := resources[0] + client := resource.server + + forward, _, err := client.GetNetworkForward(networkName, listenAddress) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + var results []string + for k := range forward.Config { + results = append(results, k) + } + + return results, cobra.ShellCompDirectiveNoFileComp +} + +// cmpNetworkForwards provides shell completion for network forwards. +// It takes a network name and returns a list of network forwards along with a shell completion directive. +func (g *cmdGlobal) cmpNetworkForwards(networkName string) ([]string, cobra.ShellCompDirective) { + var results []string + cmpDirectives := cobra.ShellCompDirectiveNoFileComp + + resources, _ := g.ParseServers(networkName) + + if len(resources) <= 0 { + return nil, cobra.ShellCompDirectiveError + } + + resource := resources[0] + + results, err := resource.server.GetNetworkForwardAddresses(networkName) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + return results, cmpDirectives +} + +// cmpNetworkLoadBalancers provides shell completion for network load balancers. +// It takes a network name and returns a list of network load balancers along with a shell completion directive. +func (g *cmdGlobal) cmpNetworkLoadBalancers(networkName string) ([]string, cobra.ShellCompDirective) { + var results []string + cmpDirectives := cobra.ShellCompDirectiveNoFileComp + + resources, _ := g.ParseServers(networkName) + + if len(resources) <= 0 { + return nil, cobra.ShellCompDirectiveError + } + + resource := resources[0] + + results, err := resource.server.GetNetworkForwardAddresses(networkName) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + return results, cmpDirectives +} + +// cmpNetworkPeerConfigs provides shell completion for network peer configs. +// It takes a network name and peer name, and returns a list of network peer configs along with a shell completion directive. +func (g *cmdGlobal) cmpNetworkPeerConfigs(networkName string, peerName string) ([]string, cobra.ShellCompDirective) { + var results []string + cmpDirectives := cobra.ShellCompDirectiveNoFileComp + + resources, _ := g.ParseServers(networkName) + + if len(resources) <= 0 { + return nil, cobra.ShellCompDirectiveError + } + + resource := resources[0] + + peer, _, err := resource.server.GetNetworkPeer(resource.name, peerName) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + for k := range peer.Config { + results = append(results, k) + } + + return results, cmpDirectives +} + +// cmpNetworkPeers provides shell completion for network peers. +// It takes a network name and returns a list of network peers along with a shell completion directive. +func (g *cmdGlobal) cmpNetworkPeers(networkName string) ([]string, cobra.ShellCompDirective) { + var results []string + cmpDirectives := cobra.ShellCompDirectiveNoFileComp + + resources, _ := g.ParseServers(networkName) + + if len(resources) <= 0 { + return nil, cobra.ShellCompDirectiveError + } + + resource := resources[0] + + results, err := resource.server.GetNetworkPeerNames(networkName) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + return results, cmpDirectives +} + +// cmpNetworks provides shell completion for networks. +// It takes a partial input string and returns a list of matching networks along with a shell completion directive. +func (g *cmdGlobal) cmpNetworks(toComplete string) ([]string, cobra.ShellCompDirective) { + var results []string + cmpDirectives := cobra.ShellCompDirectiveNoFileComp + + resources, _ := g.ParseServers(toComplete) + + if len(resources) > 0 { + resource := resources[0] + + networks, err := resource.server.GetNetworkNames() + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + for _, network := range networks { + var name string + + if resource.remote == g.conf.DefaultRemote && !strings.Contains(toComplete, g.conf.DefaultRemote) { + name = network + } else { + name = resource.remote + ":" + network + } + + results = append(results, name) + } + } + + if !strings.Contains(toComplete, ":") { + remotes, directives := g.cmpRemotes(false) + results = append(results, remotes...) + cmpDirectives |= directives + } + + return results, cmpDirectives +} + +// cmpNetworkConfigs provides shell completion for network configs. +// It takes a network name and returns a list of network configs along with a shell completion directive. +func (g *cmdGlobal) cmpNetworkConfigs(networkName string) ([]string, cobra.ShellCompDirective) { + // Parse remote + resources, err := g.ParseServers(networkName) + if err != nil || len(resources) == 0 { + return nil, cobra.ShellCompDirectiveError + } + + resource := resources[0] + client := resource.server + + network, _, err := client.GetNetwork(networkName) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + var results []string + for k := range network.Config { + results = append(results, k) + } + + return results, cobra.ShellCompDirectiveNoFileComp +} + +// cmpNetworkInstances provides shell completion for network instances. +// It takes a network name and returns a list of instances along with a shell completion directive. +func (g *cmdGlobal) cmpNetworkInstances(networkName string) ([]string, cobra.ShellCompDirective) { + // Parse remote + resources, err := g.ParseServers(networkName) + if err != nil || len(resources) == 0 { + return nil, cobra.ShellCompDirectiveError + } + + resource := resources[0] + client := resource.server + + network, _, err := client.GetNetwork(networkName) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + var results []string + for _, i := range network.UsedBy { + r := regexp.MustCompile(`/1.0/instances/(.*)`) + match := r.FindStringSubmatch(i) + + if len(match) == 2 { + results = append(results, match[1]) + } + } + + return results, cobra.ShellCompDirectiveNoFileComp +} + +// cmpNetworkProfiles provides shell completion for network profiles. +// It takes a network name and returns a list of network profiles along with a shell completion directive. +func (g *cmdGlobal) cmpNetworkProfiles(networkName string) ([]string, cobra.ShellCompDirective) { + // Parse remote + resources, err := g.ParseServers(networkName) + if err != nil || len(resources) == 0 { + return nil, cobra.ShellCompDirectiveError + } + + resource := resources[0] + client := resource.server + + network, _, err := client.GetNetwork(networkName) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + var results []string + for _, i := range network.UsedBy { + r := regexp.MustCompile(`/1.0/profiles/(.*)`) + match := r.FindStringSubmatch(i) + + if len(match) == 2 { + results = append(results, match[1]) + } + } + + return results, cobra.ShellCompDirectiveNoFileComp +} + +// cmpNetworkZoneConfigs provides shell completion for network zone configs. +// It takes a zone name and returns a list of network zone configs, along with a shell completion directive. +func (g *cmdGlobal) cmpNetworkZoneConfigs(zoneName string) ([]string, cobra.ShellCompDirective) { + // Parse remote + resources, err := g.ParseServers(zoneName) + if err != nil || len(resources) == 0 { + return nil, cobra.ShellCompDirectiveError + } + + resource := resources[0] + client := resource.server + + zone, _, err := client.GetNetworkZone(zoneName) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + var results []string + for k := range zone.Config { + results = append(results, k) + } + + return results, cobra.ShellCompDirectiveNoFileComp +} + +// cmpNetworkZoneRecordConfigs provides shell completion for network zone record configs. +// It takes a zone name and record name, and returns a list of network zone record configs along with a shell completion directive. +func (g *cmdGlobal) cmpNetworkZoneRecordConfigs(zoneName string, recordName string) ([]string, cobra.ShellCompDirective) { + var results []string + cmpDirectives := cobra.ShellCompDirectiveNoFileComp + + resources, _ := g.ParseServers(zoneName) + + if len(resources) <= 0 { + return nil, cobra.ShellCompDirectiveError + } + + resource := resources[0] + + peer, _, err := resource.server.GetNetworkZoneRecord(resource.name, recordName) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + for k := range peer.Config { + results = append(results, k) + } + + return results, cmpDirectives +} + +// cmpNetworkZoneRecords provides shell completion for network zone records. +// It takes a zone name and returns a list of network zone records along with a shell completion directive. +func (g *cmdGlobal) cmpNetworkZoneRecords(zoneName string) ([]string, cobra.ShellCompDirective) { + var results []string + cmpDirectives := cobra.ShellCompDirectiveNoFileComp + + resources, _ := g.ParseServers(zoneName) + + if len(resources) <= 0 { + return nil, cobra.ShellCompDirectiveError + } + + resource := resources[0] + + results, err := resource.server.GetNetworkZoneRecordNames(zoneName) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + return results, cmpDirectives +} + +// cmpNetworkZones provides shell completion for network zones. +// It takes a partial input string and returns a list of network zones along with a shell completion directive. +func (g *cmdGlobal) cmpNetworkZones(toComplete string) ([]string, cobra.ShellCompDirective) { + var results []string + cmpDirectives := cobra.ShellCompDirectiveNoFileComp + + resources, _ := g.ParseServers(toComplete) + + if len(resources) > 0 { + resource := resources[0] + + zones, err := resource.server.GetNetworkZoneNames() + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + for _, project := range zones { + var name string + + if resource.remote == g.conf.DefaultRemote && !strings.Contains(toComplete, g.conf.DefaultRemote) { + name = project + } else { + name = resource.remote + ":" + project + } + + results = append(results, name) + } + } + + if !strings.Contains(toComplete, ":") { + remotes, directives := g.cmpRemotes(false) + results = append(results, remotes...) + cmpDirectives |= directives + } + + return results, cmpDirectives +} + +// cmpProfileConfigs provides shell completion for profile configs. +// It takes a profile name and returns a list of profile configs along with a shell completion directive. +func (g *cmdGlobal) cmpProfileConfigs(profileName string) ([]string, cobra.ShellCompDirective) { + resources, err := g.ParseServers(profileName) + if err != nil || len(resources) == 0 { + return nil, cobra.ShellCompDirectiveError + } + + resource := resources[0] + client := resource.server + + profile, _, err := client.GetProfile(resource.name) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + var configs []string + for c := range profile.Config { + configs = append(configs, c) + } + + return configs, cobra.ShellCompDirectiveNoFileComp +} + +// cmpProfileDeviceNames provides shell completion for profile device names. +// It takes an instance name and returns a list of profile device names along with a shell completion directive. +func (g *cmdGlobal) cmpProfileDeviceNames(instanceName string) ([]string, cobra.ShellCompDirective) { + // Parse remote + resources, err := g.ParseServers(instanceName) + if err != nil || len(resources) == 0 { + return nil, cobra.ShellCompDirectiveError + } + + resource := resources[0] + client := resource.server + + profile, _, err := client.GetProfile(resource.name) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + var results []string + for k := range profile.Devices { + results = append(results, k) + } + + return results, cobra.ShellCompDirectiveNoFileComp +} + +// cmpProfileNamesFromRemote provides shell completion for profile names from a remote. +// It takes a partial input string and returns a list of profile names along with a shell completion directive. +func (g *cmdGlobal) cmpProfileNamesFromRemote(toComplete string) ([]string, cobra.ShellCompDirective) { + var results []string + + resources, _ := g.ParseServers(toComplete) + + if len(resources) > 0 { + resource := resources[0] + + profiles, _ := resource.server.GetProfileNames() + results = append(results, profiles...) + } + + return results, cobra.ShellCompDirectiveNoFileComp +} + +// cmpProfiles provides shell completion for profiles. +// It takes a partial input string and a boolean specifying whether to include remotes or not, and returns a list of profiles along with a shell completion directive. +func (g *cmdGlobal) cmpProfiles(toComplete string, includeRemotes bool) ([]string, cobra.ShellCompDirective) { + var results []string + cmpDirectives := cobra.ShellCompDirectiveNoFileComp + + resources, _ := g.ParseServers(toComplete) + + if len(resources) > 0 { + resource := resources[0] + + profiles, _ := resource.server.GetProfileNames() + + for _, profile := range profiles { + var name string + + if resource.remote == g.conf.DefaultRemote && !strings.Contains(toComplete, g.conf.DefaultRemote) { + name = profile + } else { + name = resource.remote + ":" + profile + } + + results = append(results, name) + } + } + + if includeRemotes && !strings.Contains(toComplete, ":") { + remotes, directives := g.cmpRemotes(false) + results = append(results, remotes...) + cmpDirectives |= directives + } + + return results, cmpDirectives +} + +// cmpProjectConfigs provides shell completion for project configs. +// It takes a project name and returns a list of project configs along with a shell completion directive. +func (g *cmdGlobal) cmpProjectConfigs(projectName string) ([]string, cobra.ShellCompDirective) { + resources, err := g.ParseServers(projectName) + if err != nil || len(resources) == 0 { + return nil, cobra.ShellCompDirectiveError + } + + resource := resources[0] + client := resource.server + + project, _, err := client.GetProject(resource.name) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + var configs []string + for c := range project.Config { + configs = append(configs, c) + } + + return configs, cobra.ShellCompDirectiveNoFileComp +} + +// cmpProjects provides shell completion for projects. +// It takes a partial input string and returns a list of projects along with a shell completion directive. +func (g *cmdGlobal) cmpProjects(toComplete string) ([]string, cobra.ShellCompDirective) { + var results []string + cmpDirectives := cobra.ShellCompDirectiveNoFileComp + + resources, _ := g.ParseServers(toComplete) + + if len(resources) > 0 { + resource := resources[0] + + projects, err := resource.server.GetProjectNames() + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + for _, project := range projects { + var name string + + if resource.remote == g.conf.DefaultRemote && !strings.Contains(toComplete, g.conf.DefaultRemote) { + name = project + } else { + name = resource.remote + ":" + project + } + + results = append(results, name) + } + } + + if !strings.Contains(toComplete, ":") { + remotes, directives := g.cmpRemotes(false) + results = append(results, remotes...) + cmpDirectives |= directives + } + + return results, cmpDirectives +} + +// cmpRemotes provides shell completion for remotes. +// It takes a boolean specifying whether to include all remotes or not and returns a list of remotes along with a shell completion directive. +func (g *cmdGlobal) cmpRemotes(includeAll bool) ([]string, cobra.ShellCompDirective) { + results := make([]string, 0, len(g.conf.Remotes)) + + for remoteName, rc := range g.conf.Remotes { + if remoteName == "local" || (!includeAll && rc.Protocol != "lxd" && rc.Protocol != "") { + continue + } + + results = append(results, remoteName+":") + } + + return results, cobra.ShellCompDirectiveNoSpace +} + +// cmpRemoteNames provides shell completion for remote names. +// It returns a list of remote names provided by `g.conf.Remotes` along with a shell completion directive. +func (g *cmdGlobal) cmpRemoteNames() ([]string, cobra.ShellCompDirective) { + var results []string + + for remoteName := range g.conf.Remotes { + results = append(results, remoteName) + } + + return results, cobra.ShellCompDirectiveNoFileComp +} + +// cmpStoragePoolConfigs provides shell completion for storage pool configs. +// It takes a storage pool name and returns a list of storage pool configs, along with a shell completion directive. +func (g *cmdGlobal) cmpStoragePoolConfigs(poolName string) ([]string, cobra.ShellCompDirective) { + // Parse remote + resources, err := g.ParseServers(poolName) + if err != nil || len(resources) == 0 { + return nil, cobra.ShellCompDirectiveError + } + + resource := resources[0] + client := resource.server + + if strings.Contains(poolName, ":") { + poolName = strings.Split(poolName, ":")[1] + } + + pool, _, err := client.GetStoragePool(poolName) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + var results []string + for k := range pool.Config { + results = append(results, k) + } + + return results, cobra.ShellCompDirectiveNoFileComp +} + +// cmpStoragePoolWithVolume provides shell completion for storage pools and their volumes. +// It takes a partial input string and returns a list of storage pools and their volumes, along with a shell completion directive. +func (g *cmdGlobal) cmpStoragePoolWithVolume(toComplete string) ([]string, cobra.ShellCompDirective) { + if !strings.Contains(toComplete, "/") { + pools, compdir := g.cmpStoragePools(toComplete, false) + if compdir == cobra.ShellCompDirectiveError { + return nil, compdir + } + + var results []string + for _, pool := range pools { + if strings.HasSuffix(pool, ":") { + results = append(results, pool) + } else { + results = append(results, pool+"/") + } + } + + return results, cobra.ShellCompDirectiveNoSpace + } + + pool := strings.Split(toComplete, "/")[0] + volumes, compdir := g.cmpStoragePoolVolumes(pool) + if compdir == cobra.ShellCompDirectiveError { + return nil, compdir + } + + var results []string + for _, volume := range volumes { + volName, volType := parseVolume("custom", volume) + if volType == "custom" { + results = append(results, pool+"/"+volName) + } + } + + return results, cobra.ShellCompDirectiveNoFileComp +} + +// cmpStoragePools provides shell completion for storage pool names. +// It takes a partial input string and a boolean indicating whether to avoid appending a space after the completion. The function returns a list of matching storage pool names and a shell completion directive. +func (g *cmdGlobal) cmpStoragePools(toComplete string, noSpace bool) ([]string, cobra.ShellCompDirective) { + var results []string + + resources, _ := g.ParseServers(toComplete) + + if len(resources) > 0 { + resource := resources[0] + + storagePools, _ := resource.server.GetStoragePoolNames() + + for _, storage := range storagePools { + var name string + + if resource.remote == g.conf.DefaultRemote && !strings.Contains(toComplete, g.conf.DefaultRemote) { + name = storage + } else { + name = resource.remote + ":" + storage + } + + results = append(results, name) + } + } + + if !strings.Contains(toComplete, ":") { + remotes, _ := g.cmpRemotes(false) + results = append(results, remotes...) + } + + if noSpace { + return results, cobra.ShellCompDirectiveNoSpace + } + + return results, cobra.ShellCompDirectiveNoFileComp +} + +// cmpStoragePoolVolumeConfigs provides shell completion for storage pool volume configs. +// It takes a storage pool name and volume name, returns a list of storage pool volume configs, along with a shell completion directive. +func (g *cmdGlobal) cmpStoragePoolVolumeConfigs(poolName string, volumeName string) ([]string, cobra.ShellCompDirective) { + // Parse remote + resources, err := g.ParseServers(poolName) + if err != nil || len(resources) == 0 { + return nil, cobra.ShellCompDirectiveError + } + + resource := resources[0] + client := resource.server + + var pool = poolName + if strings.Contains(poolName, ":") { + pool = strings.Split(poolName, ":")[1] + } + + volName, volType := parseVolume("custom", volumeName) + + volume, _, err := client.GetStoragePoolVolume(pool, volType, volName) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + var results []string + for k := range volume.Config { + results = append(results, k) + } + + return results, cobra.ShellCompDirectiveNoFileComp +} + +// cmpStoragePoolVolumeInstances provides shell completion for storage pool volume instances. +// It takes a storage pool name and volume name, returns a list of storage pool volume instances, along with a shell completion directive. +func (g *cmdGlobal) cmpStoragePoolVolumeInstances(poolName string, volumeName string) ([]string, cobra.ShellCompDirective) { + // Parse remote + resources, err := g.ParseServers(poolName) + if err != nil || len(resources) == 0 { + return nil, cobra.ShellCompDirectiveError + } + + resource := resources[0] + client := resource.server + + var pool = poolName + if strings.Contains(poolName, ":") { + pool = strings.Split(poolName, ":")[1] + } + + volName, volType := parseVolume("custom", volumeName) + + volume, _, err := client.GetStoragePoolVolume(pool, volType, volName) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + var results []string + for _, i := range volume.UsedBy { + r := regexp.MustCompile(`/1.0/instances/(.*)`) + match := r.FindStringSubmatch(i) + + if len(match) == 2 { + results = append(results, match[1]) + } + } + + return results, cobra.ShellCompDirectiveNoFileComp +} + +// cmpStoragePoolVolumeProfiles provides shell completion for storage pool volume instances. +// It takes a storage pool name and volume name, returns a list of storage pool volume profiles, along with a shell completion directive. +func (g *cmdGlobal) cmpStoragePoolVolumeProfiles(poolName string, volumeName string) ([]string, cobra.ShellCompDirective) { + // Parse remote + resources, err := g.ParseServers(poolName) + if err != nil || len(resources) == 0 { + return nil, cobra.ShellCompDirectiveError + } + + resource := resources[0] + client := resource.server + + var pool = poolName + if strings.Contains(poolName, ":") { + pool = strings.Split(poolName, ":")[1] + } + + volName, volType := parseVolume("custom", volumeName) + + volume, _, err := client.GetStoragePoolVolume(pool, volType, volName) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + var results []string + for _, i := range volume.UsedBy { + r := regexp.MustCompile(`/1.0/profiles/(.*)`) + match := r.FindStringSubmatch(i) + + if len(match) == 2 { + results = append(results, match[1]) + } + } + + return results, cobra.ShellCompDirectiveNoFileComp +} + +// cmpStoragePoolVolumeSnapshots provides shell completion for storage pool volume snapshots. +// It takes a storage pool name and volume name, returns a list of storage pool volume snapshots, along with a shell completion directive. +func (g *cmdGlobal) cmpStoragePoolVolumeSnapshots(poolName string, volumeName string) ([]string, cobra.ShellCompDirective) { + // Parse remote + resources, err := g.ParseServers(poolName) + if err != nil || len(resources) == 0 { + return nil, cobra.ShellCompDirectiveError + } + + resource := resources[0] + client := resource.server + + var pool = poolName + if strings.Contains(poolName, ":") { + pool = strings.Split(poolName, ":")[1] + } + + volName, volType := parseVolume("custom", volumeName) + + snapshots, err := client.GetStoragePoolVolumeSnapshotNames(pool, volType, volName) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + return snapshots, cobra.ShellCompDirectiveNoFileComp +} + +// cmpStoragePoolVolumes provides shell completion for storage pool volumes. +// It takes a storage pool name and returns a list of storage pool volumes along with a shell completion directive. +func (g *cmdGlobal) cmpStoragePoolVolumes(poolName string) ([]string, cobra.ShellCompDirective) { + // Parse remote + resources, err := g.ParseServers(poolName) + if err != nil || len(resources) == 0 { + return nil, cobra.ShellCompDirectiveError + } + + resource := resources[0] + client := resource.server + + var pool = poolName + if strings.Contains(poolName, ":") { + pool = strings.Split(poolName, ":")[1] + } + + volumes, err := client.GetStoragePoolVolumeNames(pool) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + return volumes, cobra.ShellCompDirectiveNoFileComp +} diff --git a/lxc/config.go b/lxc/config.go index 6d056dffb85c..c0f371c166c3 100644 --- a/lxc/config.go +++ b/lxc/config.go @@ -106,6 +106,14 @@ func (c *cmdConfigEdit) command() *cobra.Command { cmd.Flags().StringVar(&c.config.flagTarget, "target", "", i18n.G("Cluster member name")+"``") cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpInstances(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -390,6 +398,22 @@ func (c *cmdConfigGet) command() *cobra.Command { cmd.Flags().StringVar(&c.config.flagTarget, "target", "", i18n.G("Cluster member name")+"``") cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + if strings.Contains(toComplete, ".") { + return c.global.cmpServerAllKeys(toComplete) + } + + return c.global.cmpInstances(toComplete) + } + + if len(args) == 1 { + return c.global.cmpInstanceKeys(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -534,6 +558,22 @@ lxc config set core.trust_password=blah cmd.Flags().BoolVarP(&c.flagIsProperty, "property", "p", false, i18n.G("Set the key as an instance property")) cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + if strings.Contains(toComplete, ".") { + return c.global.cmpServerAllKeys(toComplete) + } + + return c.global.cmpInstances(toComplete) + } + + if len(args) == 1 { + return c.global.cmpInstanceKeys(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -739,6 +779,14 @@ func (c *cmdConfigShow) command() *cobra.Command { cmd.Flags().StringVar(&c.config.flagTarget, "target", "", i18n.G("Cluster member name")+"``") cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + return c.global.cmpInstances(toComplete) + } + return cmd } @@ -863,6 +911,23 @@ func (c *cmdConfigUnset) command() *cobra.Command { cmd.Flags().BoolVarP(&c.flagIsProperty, "property", "p", false, i18n.G("Unset the key as an instance property")) cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + if strings.Contains(toComplete, ".") { + return c.global.cmpServerAllKeys(toComplete) + } + + return c.global.cmpInstances(toComplete) + } + + if len(args) == 1 { + // Only complete config keys which are currently set. + return c.global.cmpInstanceSetKeys(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } diff --git a/lxc/config_device.go b/lxc/config_device.go index e31f7772c770..d8f00c7f320e 100644 --- a/lxc/config_device.go +++ b/lxc/config_device.go @@ -98,6 +98,26 @@ lxc profile device add [:]profile1 disk pool=some-pool sou cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + if c.config != nil { + return c.global.cmpInstances(toComplete) + } else if c.profile != nil { + return c.global.cmpProfiles(toComplete, true) + } + } + + if len(args) == 1 { + return c.global.cmpInstanceAllDevices(toComplete) + } + + if len(args) == 2 { + return c.global.cmpInstanceAllDeviceOptions(args[0], args[1]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -211,6 +231,26 @@ func (c *cmdConfigDeviceGet) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + if c.config != nil { + return c.global.cmpInstances(toComplete) + } else if c.profile != nil { + return c.global.cmpProfiles(toComplete, true) + } + } + + if len(args) == 1 { + if c.config != nil { + return c.global.cmpInstanceDeviceNames(args[0]) + } else if c.profile != nil { + return c.global.cmpProfileDeviceNames(args[0]) + } + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -293,6 +333,18 @@ func (c *cmdConfigDeviceList) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + if c.config != nil { + return c.global.cmpInstances(toComplete) + } else if c.profile != nil { + return c.global.cmpProfiles(toComplete, true) + } + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -359,6 +411,14 @@ func (c *cmdConfigDeviceOverride) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpInstances(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -453,6 +513,24 @@ func (c *cmdConfigDeviceRemove) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + if c.config != nil { + return c.global.cmpInstances(toComplete) + } else if c.profile != nil { + return c.global.cmpProfiles(toComplete, true) + } + } + + if c.config != nil { + return c.global.cmpInstanceDeviceNames(args[0]) + } else if c.profile != nil { + return c.global.cmpProfileDeviceNames(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -562,6 +640,26 @@ For backward compatibility, a single configuration key may still be set with: cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + if c.config != nil { + return c.global.cmpInstances(toComplete) + } else if c.profile != nil { + return c.global.cmpProfiles(toComplete, true) + } + } + + if len(args) == 1 { + if c.config != nil { + return c.global.cmpInstanceDeviceNames(args[0]) + } else if c.profile != nil { + return c.global.cmpProfileDeviceNames(args[0]) + } + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -671,6 +769,18 @@ func (c *cmdConfigDeviceShow) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + if c.config != nil { + return c.global.cmpInstances(toComplete) + } else if c.profile != nil { + return c.global.cmpProfiles(toComplete, true) + } + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -744,6 +854,26 @@ func (c *cmdConfigDeviceUnset) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + if c.config != nil { + return c.global.cmpInstances(toComplete) + } else if c.profile != nil { + return c.global.cmpProfiles(toComplete, true) + } + } + + if len(args) == 1 { + if c.config != nil { + return c.global.cmpInstanceDeviceNames(args[0]) + } else if c.profile != nil { + return c.global.cmpProfileDeviceNames(args[0]) + } + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } diff --git a/lxc/config_metadata.go b/lxc/config_metadata.go index 88d9958e16db..119ccf9a5ded 100644 --- a/lxc/config_metadata.go +++ b/lxc/config_metadata.go @@ -58,6 +58,14 @@ func (c *cmdConfigMetadataEdit) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpInstances(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -183,6 +191,14 @@ func (c *cmdConfigMetadataShow) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpInstances(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } diff --git a/lxc/config_template.go b/lxc/config_template.go index 74159c1279e1..7b78ed6ddea7 100644 --- a/lxc/config_template.go +++ b/lxc/config_template.go @@ -70,6 +70,14 @@ func (c *cmdConfigTemplateCreate) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpInstances(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -113,6 +121,18 @@ func (c *cmdConfigTemplateDelete) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpInstances(toComplete) + } + + if len(args) == 1 { + return c.global.cmpInstanceConfigTemplates(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -155,6 +175,18 @@ func (c *cmdConfigTemplateEdit) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpInstances(toComplete) + } + + if len(args) == 1 { + return c.global.cmpInstanceConfigTemplates(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -244,6 +276,14 @@ func (c *cmdConfigTemplateList) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpInstances(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -303,6 +343,18 @@ func (c *cmdConfigTemplateShow) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpInstances(toComplete) + } + + if len(args) == 1 { + return c.global.cmpInstanceConfigTemplates(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } diff --git a/lxc/console.go b/lxc/console.go index 57253b50116c..4da78954a595 100644 --- a/lxc/console.go +++ b/lxc/console.go @@ -1,15 +1,18 @@ package main import ( + "context" "errors" "fmt" "io" "net" "os" "os/exec" + "os/signal" "runtime" "strconv" "sync" + "syscall" "github.com/gorilla/websocket" "github.com/spf13/cobra" @@ -44,6 +47,10 @@ as well as retrieve past log entries from it.`)) cmd.Flags().BoolVar(&c.flagShowLog, "show-log", false, i18n.G("Retrieve the container's console log")) cmd.Flags().StringVarP(&c.flagType, "type", "t", "console", i18n.G("Type of connection to establish: 'console' for serial console, 'vga' for SPICE graphical output")+"``") + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return c.global.cmpInstances(toComplete) + } + return cmd } @@ -233,6 +240,12 @@ func (c *cmdConsole) vga(d lxd.InstanceServer, name string) error { var err error conf := c.global.conf + // Create a context that is canceled on signal reception. + // This is used to enable the function to execute any cleanup defer statements + // before exiting. + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) + defer stop() + // We currently use the control websocket just to abort in case of errors. controlDone := make(chan struct{}, 1) handler := func(control *websocket.Conn) { @@ -280,7 +293,8 @@ func (c *cmdConsole) vga(d lxd.InstanceServer, name string) error { } // Listen on the socket. - listener, err = net.Listen("unix", path.Name()) + lc := net.ListenConfig{} + listener, err = lc.Listen(ctx, "unix", path.Name()) if err != nil { return err } diff --git a/lxc/copy.go b/lxc/copy.go index 81caca484910..7c012a68e618 100644 --- a/lxc/copy.go +++ b/lxc/copy.go @@ -65,6 +65,18 @@ The pull transfer mode is the default as it is compatible with all LXD versions. cmd.Flags().BoolVar(&c.flagRefresh, "refresh", false, i18n.G("Perform an incremental copy")) cmd.Flags().BoolVar(&c.flagAllowInconsistent, "allow-inconsistent", false, i18n.G("Ignore copy errors for volatile files")) + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpInstances(toComplete) + } + + if len(args) == 1 { + return c.global.cmpRemotes(false) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } diff --git a/lxc/delete.go b/lxc/delete.go index a6ddbe22dabd..3ae4c64a5d62 100644 --- a/lxc/delete.go +++ b/lxc/delete.go @@ -36,6 +36,10 @@ func (c *cmdDelete) command() *cobra.Command { cmd.Flags().BoolVarP(&c.flagForce, "force", "f", false, i18n.G("Force the removal of running instances")) cmd.Flags().BoolVarP(&c.flagInteractive, "interactive", "i", false, i18n.G("Require user confirmation")) + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return c.global.cmpInstancesAction(toComplete, "delete", c.flagForce) + } + return cmd } diff --git a/lxc/exec.go b/lxc/exec.go index d17e5975cd45..c2f230ed0be3 100644 --- a/lxc/exec.go +++ b/lxc/exec.go @@ -60,6 +60,14 @@ Mode defaults to non-interactive, interactive mode is selected if both stdin AND cmd.Flags().Uint32Var(&c.flagGroup, "group", 0, i18n.G("Group ID to run the command as (default 0)")+"``") cmd.Flags().StringVar(&c.flagCwd, "cwd", "", i18n.G("Directory to run the command in (default /root)")+"``") + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpInstancesAction(toComplete, "exec", false) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } diff --git a/lxc/file.go b/lxc/file.go index f1304f2e8c47..8c459d47ac2a 100644 --- a/lxc/file.go +++ b/lxc/file.go @@ -148,6 +148,14 @@ lxc file create --type=symlink foo/bar baz cmd.Flags().StringVar(&c.flagType, "type", "file", i18n.G("The type to create (file, symlink, or directory)")+"``") cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpInstances(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -315,6 +323,14 @@ func (c *cmdFileDelete) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpInstances(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -364,6 +380,14 @@ func (c *cmdFileEdit) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpInstances(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -439,6 +463,14 @@ func (c *cmdFilePull) command() *cobra.Command { cmd.Flags().BoolVarP(&c.file.flagRecursive, "recursive", "r", false, i18n.G("Recursively transfer files")) cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpInstances(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -1184,6 +1216,14 @@ func (c *cmdFileMount) command() *cobra.Command { cmd.Flags().BoolVar(&c.flagAuthNone, "no-auth", false, i18n.G("Disable authentication when using SSH SFTP listener")) cmd.Flags().StringVar(&c.flagAuthUser, "auth-user", "", i18n.G("Set authentication user when using SSH SFTP listener")) + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpInstances(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } diff --git a/lxc/image.go b/lxc/image.go index ee0fd8a94f00..70825fdb9cd3 100644 --- a/lxc/image.go +++ b/lxc/image.go @@ -172,6 +172,18 @@ It requires the source to be an alias and for it to be public.`)) cmd.Flags().StringArrayVarP(&c.flagProfile, "profile", "p", nil, i18n.G("Profile to apply to the new image")+"``") cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpImages(toComplete) + } + + if len(args) == 1 { + return c.global.cmpRemotes(false) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -327,6 +339,10 @@ func (c *cmdImageDelete) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return c.global.cmpImages(toComplete) + } + return cmd } @@ -388,6 +404,14 @@ lxc image edit < image.yaml cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpImages(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -506,6 +530,14 @@ The output target is optional and defaults to the working directory.`)) cmd.Flags().BoolVar(&c.flagVM, "vm", false, i18n.G("Query virtual machine images")) cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpImages(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -673,6 +705,18 @@ Descriptive properties can be set by providing key=value pairs. Example: os=Ubun cmd.Flags().StringArrayVar(&c.flagAliases, "alias", nil, i18n.G("New aliases to add to the image")+"``") cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return nil, cobra.ShellCompDirectiveDefault + } + + if len(args) == 1 { + return c.global.cmpRemotes(false) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -907,6 +951,14 @@ func (c *cmdImageInfo) command() *cobra.Command { cmd.Flags().BoolVar(&c.flagVM, "vm", false, i18n.G("Query virtual machine images")) cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpImages(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -1026,8 +1078,9 @@ type cmdImageList struct { global *cmdGlobal image *cmdImage - flagFormat string - flagColumns string + flagFormat string + flagColumns string + flagAllProjects bool } func (c *cmdImageList) command() *cobra.Command { @@ -1055,35 +1108,54 @@ Column shorthand chars: F - Fingerprint (long) p - Whether image is public d - Description + e - Project a - Architecture s - Size u - Upload date t - Type`)) - cmd.Flags().StringVarP(&c.flagColumns, "columns", "c", "lfpdatsu", i18n.G("Columns")+"``") + cmd.Flags().StringVarP(&c.flagColumns, "columns", "c", defaultImagesColumns, i18n.G("Columns")+"``") cmd.Flags().StringVarP(&c.flagFormat, "format", "f", "table", i18n.G("Format (csv|json|table|yaml|compact)")+"``") + cmd.Flags().BoolVar(&c.flagAllProjects, "all-projects", false, i18n.G("Display images from all projects")) cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + return c.global.cmpImages(toComplete) + } + return cmd } +const defaultImagesColumns = "lfpdatsu" +const defaultImagesColumnsAllProjects = "elfpdatsu" + func (c *cmdImageList) parseColumns() ([]imageColumn, error) { columnsShorthandMap := map[rune]imageColumn{ - 'l': {i18n.G("ALIAS"), c.aliasColumnData}, - 'L': {i18n.G("ALIASES"), c.aliasesColumnData}, + 'a': {i18n.G("ARCHITECTURE"), c.architectureColumnData}, + 'd': {i18n.G("DESCRIPTION"), c.descriptionColumnData}, + 'e': {i18n.G("PROJECT"), c.projectColumnData}, 'f': {i18n.G("FINGERPRINT"), c.fingerprintColumnData}, 'F': {i18n.G("FINGERPRINT"), c.fingerprintFullColumnData}, + 'l': {i18n.G("ALIAS"), c.aliasColumnData}, + 'L': {i18n.G("ALIASES"), c.aliasesColumnData}, 'p': {i18n.G("PUBLIC"), c.publicColumnData}, - 'd': {i18n.G("DESCRIPTION"), c.descriptionColumnData}, - 'a': {i18n.G("ARCHITECTURE"), c.architectureColumnData}, 's': {i18n.G("SIZE"), c.sizeColumnData}, - 'u': {i18n.G("UPLOAD DATE"), c.uploadDateColumnData}, 't': {i18n.G("TYPE"), c.typeColumnData}, + 'u': {i18n.G("UPLOAD DATE"), c.uploadDateColumnData}, } - columnList := strings.Split(c.flagColumns, ",") + // Add project column if --all-projects flag specified and custom columns are not specified. + if c.flagAllProjects && c.flagColumns == defaultImagesColumns { + c.flagColumns = defaultImagesColumnsAllProjects + } + columnList := strings.Split(c.flagColumns, ",") columns := []imageColumn{} + for _, columnEntry := range columnList { if columnEntry == "" { return nil, fmt.Errorf(i18n.G("Empty column entry (redundant, leading or trailing command) in '%s'"), c.flagColumns) @@ -1141,6 +1213,10 @@ func (c *cmdImageList) descriptionColumnData(image api.Image) string { return c.findDescription(image.Properties) } +func (c *cmdImageList) projectColumnData(image api.Image) string { + return image.Project +} + func (c *cmdImageList) architectureColumnData(image api.Image) string { return image.Architecture } @@ -1277,6 +1353,11 @@ func (c *cmdImageList) run(cmd *cobra.Command, args []string) error { return err } + d, err := c.global.conf.GetInstanceServer(remoteName) + if err != nil { + return err + } + // Process the filters filters := []string{} if name != "" { @@ -1295,15 +1376,27 @@ func (c *cmdImageList) run(cmd *cobra.Command, args []string) error { serverFilters, clientFilters := getServerSupportedFilters(filters, api.Image{}) - var images []api.Image - allImages, err := remoteServer.GetImagesWithFilter(serverFilters) - if err != nil { - allImages, err = remoteServer.GetImages() + var allImages, images []api.Image + if c.flagAllProjects { + allImages, err = d.GetImagesAllProjectsWithFilter(serverFilters) if err != nil { - return err + allImages, err = d.GetImagesAllProjects() + if err != nil { + return err + } + + clientFilters = filters } + } else { + allImages, err = remoteServer.GetImagesWithFilter(serverFilters) + if err != nil { + allImages, err = remoteServer.GetImages() + if err != nil { + return err + } - clientFilters = filters + clientFilters = filters + } } for _, image := range allImages { @@ -1359,6 +1452,10 @@ func (c *cmdImageRefresh) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return c.global.cmpImages(toComplete) + } + return cmd } @@ -1447,6 +1544,14 @@ func (c *cmdImageShow) command() *cobra.Command { cmd.Flags().BoolVar(&c.flagVM, "vm", false, i18n.G("Query virtual machine images")) cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpImages(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -1504,6 +1609,19 @@ func (c *cmdImageGetProp) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpImages(toComplete) + } + + if len(args) == 1 { + // individual image prop could complete here + return nil, cobra.ShellCompDirectiveNoFileComp + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -1555,6 +1673,14 @@ func (c *cmdImageSetProp) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpImages(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -1610,6 +1736,14 @@ func (c *cmdImageUnsetProp) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpImages(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } diff --git a/lxc/info.go b/lxc/info.go index 9af2567dbf6e..02a84bfe4447 100644 --- a/lxc/info.go +++ b/lxc/info.go @@ -44,6 +44,14 @@ lxc info [:] [--resources] cmd.Flags().BoolVar(&c.flagResources, "resources", false, i18n.G("Show the resources available to the server")) cmd.Flags().StringVar(&c.flagTarget, "target", "", i18n.G("Cluster member name")+"``") + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpInstances(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } diff --git a/lxc/init.go b/lxc/init.go index cbcbe6b18855..7a75df78a36a 100644 --- a/lxc/init.go +++ b/lxc/init.go @@ -67,6 +67,14 @@ lxc init ubuntu:24.04 v1 --vm -c limits.cpu=2 -c limits.memory=8GiB -d root,size cmd.Flags().BoolVar(&c.flagEmpty, "empty", false, i18n.G("Create an empty instance")) cmd.Flags().BoolVar(&c.flagVM, "vm", false, i18n.G("Create a virtual machine")) + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + return c.global.cmpImages(toComplete) + } + return cmd } @@ -153,10 +161,6 @@ func (c *cmdInit) create(conf *config.Config, args []string, launch bool) (lxd.I return nil, "", err } - if c.flagTarget != "" { - d = d.UseTarget(c.flagTarget) - } - // Overwrite profiles. if c.flagProfile != nil { profiles = c.flagProfile @@ -256,6 +260,11 @@ func (c *cmdInit) create(conf *config.Config, args []string, launch bool) (lxd.I instanceDBType = api.InstanceTypeVM } + // Set the target if provided. + if c.flagTarget != "" { + d = d.UseTarget(c.flagTarget) + } + // Setup instance creation request req := api.InstancesPost{ Name: name, @@ -371,7 +380,7 @@ func (c *cmdInit) create(conf *config.Config, args []string, launch bool) (lxd.I opInfo = *info } else { - req.Source.Type = "none" + req.Source.Type = api.SourceTypeNone op, err := d.CreateInstance(req) if err != nil { diff --git a/lxc/launch.go b/lxc/launch.go index 50cc5ec4e337..e5b12d0590bb 100644 --- a/lxc/launch.go +++ b/lxc/launch.go @@ -45,6 +45,14 @@ lxc launch ubuntu:24.04 v1 --vm -c limits.cpu=2 -c limits.memory=8GiB -d root,si cmd.Flags().StringVar(&c.flagConsole, "console", "", i18n.G("Immediately attach to the console")+"``") cmd.Flags().Lookup("console").NoOptDefVal = "console" + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + return c.global.cmpImages(toComplete) + } + return cmd } @@ -63,64 +71,62 @@ func (c *cmdLaunch) run(cmd *cobra.Command, args []string) error { return err } - // Check if the instance was started by the server. - if d.HasExtension("instance_create_start") { - return nil - } + // Start the instance if it wasn't started by the server + if !d.HasExtension("instance_create_start") { + // Get the remote + var remote string + if len(args) == 2 { + remote, _, err = conf.ParseRemote(args[1]) + if err != nil { + return err + } + } else { + remote, _, err = conf.ParseRemote("") + if err != nil { + return err + } + } - // Get the remote - var remote string - if len(args) == 2 { - remote, _, err = conf.ParseRemote(args[1]) - if err != nil { - return err + // Start the instance + if !c.global.flagQuiet { + fmt.Printf(i18n.G("Starting %s")+"\n", name) } - } else { - remote, _, err = conf.ParseRemote("") + + req := api.InstanceStatePut{ + Action: "start", + Timeout: -1, + } + + op, err := d.UpdateInstanceState(name, req, "") if err != nil { return err } - } - - // Start the instance - if !c.global.flagQuiet { - fmt.Printf(i18n.G("Starting %s")+"\n", name) - } - - req := api.InstanceStatePut{ - Action: "start", - Timeout: -1, - } - op, err := d.UpdateInstanceState(name, req, "") - if err != nil { - return err - } + progress := cli.ProgressRenderer{ + Quiet: c.global.flagQuiet, + } - progress := cli.ProgressRenderer{ - Quiet: c.global.flagQuiet, - } + _, err = op.AddHandler(progress.UpdateOp) + if err != nil { + progress.Done("") + return err + } - _, err = op.AddHandler(progress.UpdateOp) - if err != nil { - progress.Done("") - return err - } + // Wait for operation to finish + err = cli.CancelableWait(op, &progress) + if err != nil { + progress.Done("") + prettyName := name + if remote != "" { + prettyName = fmt.Sprintf("%s:%s", remote, name) + } - // Wait for operation to finish - err = cli.CancelableWait(op, &progress) - if err != nil { - progress.Done("") - prettyName := name - if remote != "" { - prettyName = fmt.Sprintf("%s:%s", remote, name) + return fmt.Errorf("%s\n"+i18n.G("Try `lxc info --show-log %s` for more info"), err, prettyName) } - return fmt.Errorf("%s\n"+i18n.G("Try `lxc info --show-log %s` for more info"), err, prettyName) + progress.Done("") } - progress.Done("") - // Handle console attach if c.flagConsole != "" { console := cmdConsole{} diff --git a/lxc/list.go b/lxc/list.go index 6b2693e9c993..3035a9d38bc0 100644 --- a/lxc/list.go +++ b/lxc/list.go @@ -134,6 +134,14 @@ lxc list -c ns,user.comment:comment cmd.Flags().BoolVar(&c.flagFast, "fast", false, i18n.G("Fast mode (same as --columns=nsacPt)")) cmd.Flags().BoolVar(&c.flagAllProjects, "all-projects", false, i18n.G("Display instances from all projects")) + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpRemotes(false) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } diff --git a/lxc/main.go b/lxc/main.go index f95e710350f6..edc09882a960 100644 --- a/lxc/main.go +++ b/lxc/main.go @@ -87,7 +87,6 @@ All of LXD's features can be driven through the various commands below. For help with any of those, simply call them with --help.`)) app.SilenceUsage = true app.SilenceErrors = true - app.CompletionOptions = cobra.CompletionOptions{DisableDefaultCmd: true} // Global flags globalCmd := cmdGlobal{cmd: app, asker: cli.NewAsker(bufio.NewReader(os.Stdin), nil)} @@ -278,9 +277,14 @@ For help with any of those, simply call them with --help.`)) if globalCmd.flagHelpAll { // Show all commands for _, cmd := range app.Commands() { + if cmd.Name() == "completion" { + continue + } + cmd.Hidden = false } } + if globalCmd.flagSubCmds { app.SetUsageTemplate(usageTemplateSubCmds()) } diff --git a/lxc/main_aliases.go b/lxc/main_aliases.go index ece07ba1c1ae..5324d2f52524 100644 --- a/lxc/main_aliases.go +++ b/lxc/main_aliases.go @@ -49,6 +49,8 @@ func findAlias(aliases map[string]string, origArgs []string) ([]string, []string } func expandAlias(conf *config.Config, args []string) ([]string, bool, error) { + var completion = false + var completionFragment string var newArgs []string var origArgs []string @@ -62,6 +64,13 @@ func expandAlias(conf *config.Config, args []string) ([]string, bool, error) { origArgs = append([]string{args[0]}, args[len(newArgs)+1:]...) + // strip out completion subcommand and fragment from end + if len(origArgs) >= 3 && origArgs[1] == "__complete" { + completion = true + completionFragment = origArgs[len(origArgs)-1] + origArgs = append(origArgs[:1], origArgs[2:len(origArgs)-1]...) + } + aliasKey, aliasValue, foundAlias := findAlias(conf.Aliases, origArgs) if !foundAlias { aliasKey, aliasValue, foundAlias = findAlias(defaultAliases, origArgs) @@ -116,7 +125,13 @@ func expandAlias(conf *config.Config, args []string) ([]string, bool, error) { for _, aliasArg := range aliasValue { // Only replace all @ARGS@ when it is not part of another string if aliasArg == "@ARGS@" { - newArgs = append(newArgs, atArgs...) + // if completing we want to stop on @ARGS@ and append the completion below + if completion { + break + } else { + newArgs = append(newArgs, atArgs...) + } + hasReplacedArgsVar = true continue } @@ -143,6 +158,12 @@ func expandAlias(conf *config.Config, args []string) ([]string, bool, error) { newArgs = append(newArgs, aliasArg) } + // add back in completion if it was stripped before + if completion { + newArgs = append([]string{newArgs[0], "__complete"}, newArgs[1:]...) + newArgs = append(newArgs, completionFragment) + } + // Add the rest of the arguments only if @ARGS@ wasn't used. if !hasReplacedArgsVar { newArgs = append(newArgs, atArgs...) diff --git a/lxc/manpage.go b/lxc/manpage.go index 360d531ca451..246be52d966b 100644 --- a/lxc/manpage.go +++ b/lxc/manpage.go @@ -36,6 +36,15 @@ func (c *cmdManpage) run(cmd *cobra.Command, args []string) error { return err } + // If asked to do all commands, mark them all visible. + for _, c := range c.global.cmd.Commands() { + if c.Name() == "completion" { + continue + } + + c.Hidden = false + } + // Generate the documentation. switch c.flagFormat { case "man": diff --git a/lxc/move.go b/lxc/move.go index e16af7a5f576..700b5d1d9922 100644 --- a/lxc/move.go +++ b/lxc/move.go @@ -68,6 +68,18 @@ lxc move / / cmd.Flags().StringVar(&c.flagTargetProject, "target-project", "", i18n.G("Copy to a project different from the source")+"``") cmd.Flags().BoolVar(&c.flagAllowInconsistent, "allow-inconsistent", false, i18n.G("Ignore copy errors for volatile files")) + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpInstances(toComplete) + } + + if len(args) == 1 { + return c.global.cmpRemotes(false) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } diff --git a/lxc/network.go b/lxc/network.go index 2347a65418db..dfaf2fc71752 100644 --- a/lxc/network.go +++ b/lxc/network.go @@ -138,6 +138,18 @@ func (c *cmdNetworkAttach) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworks(toComplete) + } + + if len(args) == 1 { + return c.global.cmpInstances(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -223,6 +235,18 @@ func (c *cmdNetworkAttachProfile) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworks(toComplete) + } + + if len(args) == 1 { + return c.global.cmpProfiles(args[0], false) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -303,6 +327,14 @@ lxc network create bar network=baz --type ovn cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + return c.global.cmpRemotes(false) + } + return cmd } @@ -375,6 +407,14 @@ func (c *cmdNetworkDelete) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + return c.global.cmpNetworks(toComplete) + } + return cmd } @@ -425,6 +465,18 @@ func (c *cmdNetworkDetach) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworks(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkInstances(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -510,6 +562,18 @@ func (c *cmdNetworkDetachProfile) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworks(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkProfiles(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -595,6 +659,14 @@ func (c *cmdNetworkEdit) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + return c.global.cmpNetworks(toComplete) + } + return cmd } @@ -725,6 +797,18 @@ func (c *cmdNetworkGet) command() *cobra.Command { cmd.Flags().BoolVarP(&c.flagIsProperty, "property", "p", false, i18n.G("Get the key as a network property")) cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworks(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkConfigs(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -793,6 +877,14 @@ func (c *cmdNetworkInfo) command() *cobra.Command { cmd.Flags().StringVar(&c.network.flagTarget, "target", "", i18n.G("Cluster member name")+"``") cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + return c.global.cmpNetworks(toComplete) + } + return cmd } @@ -916,6 +1008,14 @@ func (c *cmdNetworkList) command() *cobra.Command { cmd.RunE = c.run cmd.Flags().StringVarP(&c.flagFormat, "format", "f", "table", i18n.G("Format (csv|json|table|yaml|compact)")+"``") + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + return c.global.cmpRemotes(false) + } + return cmd } @@ -1009,6 +1109,14 @@ func (c *cmdNetworkListLeases) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + return c.global.cmpNetworks(toComplete) + } + return cmd } @@ -1079,6 +1187,14 @@ func (c *cmdNetworkRename) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + return c.global.cmpNetworks(toComplete) + } + return cmd } @@ -1136,6 +1252,14 @@ For backward compatibility, a single configuration key may still be set with: cmd.Flags().BoolVarP(&c.flagIsProperty, "property", "p", false, i18n.G("Set the key as a network property")) cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + return c.global.cmpNetworks(toComplete) + } + return cmd } @@ -1220,6 +1344,14 @@ func (c *cmdNetworkShow) command() *cobra.Command { cmd.Flags().StringVar(&c.network.flagTarget, "target", "", i18n.G("Cluster member name")+"``") cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + return c.global.cmpNetworks(toComplete) + } + return cmd } @@ -1285,6 +1417,18 @@ func (c *cmdNetworkUnset) command() *cobra.Command { cmd.Flags().BoolVarP(&c.flagIsProperty, "property", "p", false, i18n.G("Unset the key as a network property")) cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworks(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkConfigs(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } diff --git a/lxc/network_acl.go b/lxc/network_acl.go index 1a207119d388..d1cd571a8f0e 100644 --- a/lxc/network_acl.go +++ b/lxc/network_acl.go @@ -97,6 +97,14 @@ func (c *cmdNetworkACLList) command() *cobra.Command { cmd.RunE = c.run cmd.Flags().StringVarP(&c.flagFormat, "format", "f", "table", i18n.G("Format (csv|json|table|yaml|compact)")+"``") + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpRemotes(false) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -166,6 +174,14 @@ func (c *cmdNetworkACLShow) command() *cobra.Command { cmd.Long = cli.FormatSection(i18n.G("Description"), i18n.G("Show network ACL configurations")) cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworkACLs(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -219,6 +235,14 @@ func (c *cmdNetworkACLShowLog) command() *cobra.Command { cmd.Long = cli.FormatSection(i18n.G("Description"), i18n.G("Show network ACL log")) cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworkACLs(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -269,6 +293,18 @@ func (c *cmdNetworkACLGet) command() *cobra.Command { cmd.Flags().BoolVarP(&c.flagIsProperty, "property", "p", false, i18n.G("Get the key as a network ACL property")) cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworkACLs(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkACLConfigs(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -333,6 +369,14 @@ lxc network acl create a1 < config.yaml cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworkACLs(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -423,6 +467,14 @@ For backward compatibility, a single configuration key may still be set with: cmd.Flags().BoolVarP(&c.flagIsProperty, "property", "p", false, i18n.G("Set the key as a network ACL property")) cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworkACLs(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -498,6 +550,19 @@ func (c *cmdNetworkACLUnset) command() *cobra.Command { cmd.RunE = c.run cmd.Flags().BoolVarP(&c.flagIsProperty, "property", "p", false, i18n.G("Unset the key as a network ACL property")) + + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworkACLs(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkACLConfigs(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -528,6 +593,14 @@ func (c *cmdNetworkACLEdit) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworkACLs(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -658,6 +731,14 @@ func (c *cmdNetworkACLRename) command() *cobra.Command { cmd.Long = cli.FormatSection(i18n.G("Description"), i18n.G("Rename network ACLs")) cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworkACLs(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -707,6 +788,14 @@ func (c *cmdNetworkACLDelete) command() *cobra.Command { cmd.Long = cli.FormatSection(i18n.G("Description"), i18n.G("Delete network ACLs")) cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworkACLs(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -771,11 +860,27 @@ func (c *cmdNetworkACLRule) commandAdd() *cobra.Command { cmd.Long = cli.FormatSection(i18n.G("Description"), i18n.G("Add rules to an ACL")) cmd.RunE = c.runAdd + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworkACLs(toComplete) + } + + if len(args) == 1 { + return []string{"ingress", "egress"}, cobra.ShellCompDirectiveNoFileComp + } + + if len(args) == 2 { + return c.global.cmpNetworkACLRuleProperties() + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } -// ruleJSONStructFieldMap returns a map of JSON tag names to struct field indices for api.NetworkACLRule. -func (c *cmdNetworkACLRule) ruleJSONStructFieldMap() map[string]int { +// networkACLRuleJSONStructFieldMap returns a map of JSON tag names to struct field indices for api.NetworkACLRule. +func networkACLRuleJSONStructFieldMap() map[string]int { // Use reflect to get field names in rule from json tags. ruleType := reflect.TypeOf(api.NetworkACLRule{}) allowedKeys := make(map[string]int, ruleType.NumField()) @@ -807,7 +912,7 @@ func (c *cmdNetworkACLRule) ruleJSONStructFieldMap() map[string]int { // parseConfigKeysToRule converts a map of key/value pairs into an api.NetworkACLRule using reflection. func (c *cmdNetworkACLRule) parseConfigToRule(config map[string]string) (*api.NetworkACLRule, error) { // Use reflect to get struct field indices in NetworkACLRule for json tags. - allowedKeys := c.ruleJSONStructFieldMap() + allowedKeys := networkACLRuleJSONStructFieldMap() // Initialise new rule. rule := api.NetworkACLRule{} @@ -894,6 +999,22 @@ func (c *cmdNetworkACLRule) commandRemove() *cobra.Command { cmd.RunE = c.runRemove + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworkACLs(toComplete) + } + + if len(args) == 1 { + return []string{"ingress", "egress"}, cobra.ShellCompDirectiveNoFileComp + } + + if len(args) == 2 { + return c.global.cmpNetworkACLRuleProperties() + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -929,7 +1050,7 @@ func (c *cmdNetworkACLRule) runRemove(cmd *cobra.Command, args []string) error { } // Use reflect to get struct field indices in NetworkACLRule for json tags. - allowedKeys := c.ruleJSONStructFieldMap() + allowedKeys := networkACLRuleJSONStructFieldMap() // Check the supplied filters match possible fields. for k := range filters { diff --git a/lxc/network_forward.go b/lxc/network_forward.go index 94ba4de736b4..5241a6fbf0f1 100644 --- a/lxc/network_forward.go +++ b/lxc/network_forward.go @@ -92,6 +92,14 @@ func (c *cmdNetworkForwardList) command() *cobra.Command { cmd.RunE = c.run cmd.Flags().StringVarP(&c.flagFormat, "format", "f", "table", i18n.G("Format (csv|json|table|yaml|compact)")+"``") + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworks(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -173,6 +181,18 @@ func (c *cmdNetworkForwardShow) command() *cobra.Command { cmd.Flags().StringVar(&c.networkForward.flagTarget, "target", "", i18n.G("Cluster member name")+"``") + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworks(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkForwards(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -386,6 +406,22 @@ func (c *cmdNetworkForwardGet) command() *cobra.Command { cmd.Flags().BoolVarP(&c.flagIsProperty, "property", "p", false, i18n.G("Get the key as a network forward property")) cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworks(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkForwards(args[0]) + } + + if len(args) == 2 { + return c.global.cmpNetworkForwardConfigs(args[0], args[1]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -460,6 +496,18 @@ For backward compatibility, a single configuration key may still be set with: cmd.Flags().BoolVarP(&c.flagIsProperty, "property", "p", false, i18n.G("Set the key as a network forward property")) cmd.Flags().StringVar(&c.networkForward.flagTarget, "target", "", i18n.G("Cluster member name")+"``") + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworks(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkForwards(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -552,6 +600,23 @@ func (c *cmdNetworkForwardUnset) command() *cobra.Command { cmd.RunE = c.run cmd.Flags().BoolVarP(&c.flagIsProperty, "property", "p", false, i18n.G("Unset the key as a network forward property")) + + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworks(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkForwards(args[0]) + } + + if len(args) == 2 { + return c.global.cmpNetworkForwardConfigs(args[0], args[1]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -583,6 +648,18 @@ func (c *cmdNetworkForwardEdit) command() *cobra.Command { cmd.Flags().StringVar(&c.networkForward.flagTarget, "target", "", i18n.G("Cluster member name")+"``") + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworks(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkForwards(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -725,6 +802,18 @@ func (c *cmdNetworkForwardDelete) command() *cobra.Command { cmd.Flags().StringVar(&c.networkForward.flagTarget, "target", "", i18n.G("Cluster member name")+"``") + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworks(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkForwards(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -802,6 +891,22 @@ func (c *cmdNetworkForwardPort) commandAdd() *cobra.Command { cmd.Flags().StringVar(&c.networkForward.flagTarget, "target", "", i18n.G("Cluster member name")+"``") + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworks(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkForwards(args[0]) + } + + if len(args) == 2 { + return []string{"tcp", "udp"}, cobra.ShellCompDirectiveNoFileComp + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -868,6 +973,22 @@ func (c *cmdNetworkForwardPort) commandRemove() *cobra.Command { cmd.Flags().StringVar(&c.networkForward.flagTarget, "target", "", i18n.G("Cluster member name")+"``") + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworks(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkForwards(args[0]) + } + + if len(args) == 2 { + return []string{"tcp", "udp"}, cobra.ShellCompDirectiveNoFileComp + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } diff --git a/lxc/network_load_balancer.go b/lxc/network_load_balancer.go index 98f3cf420581..a97fc5f42e05 100644 --- a/lxc/network_load_balancer.go +++ b/lxc/network_load_balancer.go @@ -96,6 +96,14 @@ func (c *cmdNetworkLoadBalancerList) command() *cobra.Command { cmd.RunE = c.run cmd.Flags().StringVarP(&c.flagFormat, "format", "f", "table", i18n.G("Format (csv|json|table|yaml|compact)")+"``") + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworks(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -175,6 +183,18 @@ func (c *cmdNetworkLoadBalancerShow) command() *cobra.Command { cmd.Flags().StringVar(&c.networkLoadBalancer.flagTarget, "target", "", i18n.G("Cluster member name")+"``") + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworks(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkLoadBalancers(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -463,6 +483,18 @@ For backward compatibility, a single configuration key may still be set with: cmd.Flags().BoolVarP(&c.flagIsProperty, "property", "p", false, i18n.G("Set the key as a network load balancer property")) cmd.Flags().StringVar(&c.networkLoadBalancer.flagTarget, "target", "", i18n.G("Cluster member name")+"``") + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworks(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkLoadBalancers(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -586,6 +618,18 @@ func (c *cmdNetworkLoadBalancerEdit) command() *cobra.Command { cmd.Flags().StringVar(&c.networkLoadBalancer.flagTarget, "target", "", i18n.G("Cluster member name")+"``") + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworks(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkLoadBalancers(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -729,6 +773,18 @@ func (c *cmdNetworkLoadBalancerDelete) command() *cobra.Command { cmd.Flags().StringVar(&c.networkLoadBalancer.flagTarget, "target", "", i18n.G("Cluster member name")+"``") + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworks(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkLoadBalancers(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -805,6 +861,18 @@ func (c *cmdNetworkLoadBalancerBackend) commandAdd() *cobra.Command { cmd.Flags().StringVar(&c.networkLoadBalancer.flagTarget, "target", "", i18n.G("Cluster member name")+"``") + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworks(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkLoadBalancers(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -869,6 +937,18 @@ func (c *cmdNetworkLoadBalancerBackend) commandRemove() *cobra.Command { cmd.Flags().StringVar(&c.networkLoadBalancer.flagTarget, "target", "", i18n.G("Cluster member name")+"``") + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworks(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkLoadBalancers(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -970,6 +1050,18 @@ func (c *cmdNetworkLoadBalancerPort) commandAdd() *cobra.Command { cmd.Flags().StringVar(&c.networkLoadBalancer.flagTarget, "target", "", i18n.G("Cluster member name")+"``") + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworks(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkLoadBalancers(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -1032,6 +1124,18 @@ func (c *cmdNetworkLoadBalancerPort) commandRemove() *cobra.Command { cmd.Flags().StringVar(&c.networkLoadBalancer.flagTarget, "target", "", i18n.G("Cluster member name")+"``") + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworks(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkLoadBalancers(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } diff --git a/lxc/network_peer.go b/lxc/network_peer.go index 832a8f5def0f..6c4c816bd61d 100644 --- a/lxc/network_peer.go +++ b/lxc/network_peer.go @@ -84,6 +84,14 @@ func (c *cmdNetworkPeerList) command() *cobra.Command { cmd.RunE = c.run cmd.Flags().StringVarP(&c.flagFormat, "format", "f", "table", i18n.G("Format (csv|json|table|yaml|compact)")+"``") + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworks(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -159,6 +167,18 @@ func (c *cmdNetworkPeerShow) command() *cobra.Command { cmd.Long = cli.FormatSection(i18n.G("Description"), i18n.G("Show network peer configurations")) cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworks(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkPeers(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -216,6 +236,14 @@ func (c *cmdNetworkPeerCreate) command() *cobra.Command { cmd.Long = cli.FormatSection(i18n.G("Description"), i18n.G("Create new network peering")) cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworks(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -333,6 +361,23 @@ func (c *cmdNetworkPeerGet) command() *cobra.Command { cmd.RunE = c.run cmd.Flags().BoolVarP(&c.flagIsProperty, "property", "p", false, i18n.G("Get the key as a network peer property")) + + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworks(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkPeers(args[0]) + } + + if len(args) == 2 { + return c.global.cmpNetworkPeerConfigs(args[0], args[1]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -405,6 +450,19 @@ For backward compatibility, a single configuration key may still be set with: cmd.RunE = c.run cmd.Flags().BoolVarP(&c.flagIsProperty, "property", "p", false, i18n.G("Set the key as a network peer property")) + + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworks(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkPeers(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -490,6 +548,23 @@ func (c *cmdNetworkPeerUnset) command() *cobra.Command { cmd.RunE = c.run cmd.Flags().BoolVarP(&c.flagIsProperty, "property", "p", false, i18n.G("Unset the key as a network peer property")) + + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworks(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkPeers(args[0]) + } + + if len(args) == 2 { + return c.global.cmpNetworkPeerConfigs(args[0], args[1]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -519,6 +594,18 @@ func (c *cmdNetworkPeerEdit) command() *cobra.Command { cmd.Long = cli.FormatSection(i18n.G("Description"), i18n.G("Edit network peer configurations as YAML")) cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworks(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkPeers(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -644,6 +731,18 @@ func (c *cmdNetworkPeerDelete) command() *cobra.Command { cmd.Long = cli.FormatSection(i18n.G("Description"), i18n.G("Delete network peerings")) cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworks(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkPeers(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } diff --git a/lxc/network_zone.go b/lxc/network_zone.go index edd2de12c0f6..0b3fcf628500 100644 --- a/lxc/network_zone.go +++ b/lxc/network_zone.go @@ -88,6 +88,14 @@ func (c *cmdNetworkZoneList) command() *cobra.Command { cmd.RunE = c.run cmd.Flags().StringVarP(&c.flagFormat, "format", "f", "table", i18n.G("Format (csv|json|table|yaml|compact)")+"``") + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpRemotes(false) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -157,6 +165,14 @@ func (c *cmdNetworkZoneShow) command() *cobra.Command { cmd.Long = cli.FormatSection(i18n.G("Description"), i18n.G("Show network zone configurations")) cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworkZones(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -213,6 +229,19 @@ func (c *cmdNetworkZoneGet) command() *cobra.Command { cmd.RunE = c.run cmd.Flags().BoolVarP(&c.flagIsProperty, "property", "p", false, i18n.G("Get the key as a network zone property")) + + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworkZones(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkZoneConfigs(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -277,6 +306,14 @@ lxc network zone create z1 < config.yaml cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworkZones(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -365,6 +402,14 @@ For backward compatibility, a single configuration key may still be set with: cmd.RunE = c.run cmd.Flags().BoolVarP(&c.flagIsProperty, "property", "p", false, i18n.G("Set the key as a network zone property")) + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworkZones(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -441,6 +486,18 @@ func (c *cmdNetworkZoneUnset) command() *cobra.Command { cmd.Flags().BoolVarP(&c.flagIsProperty, "property", "p", false, i18n.G("Unset the key as a network zone property")) + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworkZones(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkZoneConfigs(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -471,6 +528,14 @@ func (c *cmdNetworkZoneEdit) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworkZones(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -589,6 +654,14 @@ func (c *cmdNetworkZoneDelete) command() *cobra.Command { cmd.Long = cli.FormatSection(i18n.G("Description"), i18n.G("Delete network zones")) cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworkZones(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -696,6 +769,14 @@ func (c *cmdNetworkZoneRecordList) command() *cobra.Command { cmd.RunE = c.run cmd.Flags().StringVarP(&c.flagFormat, "format", "f", "table", i18n.G("Format (csv|json|table|yaml|compact)")+"``") + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworkZones(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -764,6 +845,18 @@ func (c *cmdNetworkZoneRecordShow) command() *cobra.Command { cmd.Long = cli.FormatSection(i18n.G("Description"), i18n.G("Show network zone record configurations")) cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworkZones(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkZoneRecords(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -817,6 +910,23 @@ func (c *cmdNetworkZoneRecordGet) command() *cobra.Command { cmd.RunE = c.run cmd.Flags().BoolVarP(&c.flagIsProperty, "property", "p", false, i18n.G("Get the key as a network zone record property")) + + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworkZones(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkZoneRecords(args[0]) + } + + if len(args) == 2 { + return c.global.cmpNetworkZoneRecordConfigs(args[0], args[1]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -880,6 +990,18 @@ lxc network zone record create z1 r1 < config.yaml cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworkZones(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkZoneRecords(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -964,6 +1086,19 @@ func (c *cmdNetworkZoneRecordSet) command() *cobra.Command { cmd.RunE = c.run cmd.Flags().BoolVarP(&c.flagIsProperty, "property", "p", false, i18n.G("Set the key as a network zone record property")) + + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworkZones(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkZoneRecords(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -1038,6 +1173,23 @@ func (c *cmdNetworkZoneRecordUnset) command() *cobra.Command { cmd.RunE = c.run cmd.Flags().BoolVarP(&c.flagIsProperty, "property", "p", false, i18n.G("Unset the key as a network zone record property")) + + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworkZones(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkZoneRecords(args[0]) + } + + if len(args) == 2 { + return c.global.cmpNetworkZoneRecordConfigs(args[0], args[1]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -1068,6 +1220,18 @@ func (c *cmdNetworkZoneRecordEdit) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworkZones(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkZoneRecords(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -1185,6 +1349,18 @@ func (c *cmdNetworkZoneRecordDelete) command() *cobra.Command { cmd.Long = cli.FormatSection(i18n.G("Description"), i18n.G("Delete network zone record")) cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworkZones(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkZoneRecords(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -1250,6 +1426,18 @@ func (c *cmdNetworkZoneRecordEntry) commandAdd() *cobra.Command { cmd.RunE = c.runAdd cmd.Flags().Uint64Var(&c.flagTTL, "ttl", 0, i18n.G("Entry TTL")+"``") + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworkZones(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkZoneRecords(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -1295,6 +1483,18 @@ func (c *cmdNetworkZoneRecordEntry) commandRemove() *cobra.Command { cmd.Long = cli.FormatSection(i18n.G("Description"), i18n.G("Remove entries from a network zone record")) cmd.RunE = c.runRemove + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpNetworkZones(toComplete) + } + + if len(args) == 1 { + return c.global.cmpNetworkZoneRecords(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } diff --git a/lxc/profile.go b/lxc/profile.go index 38c227fdc869..a93cc11bf2d6 100644 --- a/lxc/profile.go +++ b/lxc/profile.go @@ -107,6 +107,18 @@ func (c *cmdProfileAdd) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpInstances(toComplete) + } + + if len(args) == 1 { + return c.global.cmpProfiles(args[0], false) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -179,6 +191,14 @@ lxc profile assign foo '' cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpInstances(toComplete) + } + + return c.global.cmpProfiles(args[0], false) + } + return cmd } @@ -255,6 +275,18 @@ func (c *cmdProfileCopy) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpInstances(toComplete) + } + + if len(args) == 1 { + return c.global.cmpRemotes(false) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -327,6 +359,14 @@ lxc profile create p1 < config.yaml cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpRemotes(false) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -397,6 +437,14 @@ func (c *cmdProfileDelete) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpProfiles(toComplete, true) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -450,6 +498,14 @@ func (c *cmdProfileEdit) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpProfiles(toComplete, true) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -576,6 +632,19 @@ func (c *cmdProfileGet) command() *cobra.Command { cmd.RunE = c.run cmd.Flags().BoolVarP(&c.flagIsProperty, "property", "p", false, i18n.G("Get the key as a profile property")) + + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpProfiles(toComplete, true) + } + + if len(args) == 1 { + return c.global.cmpProfileConfigs(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -637,6 +706,14 @@ func (c *cmdProfileList) command() *cobra.Command { cmd.RunE = c.run cmd.Flags().StringVarP(&c.flagFormat, "format", "f", "table", i18n.G("Format (csv|json|table|yaml|compact)")+"``") + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpRemotes(false) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -697,6 +774,18 @@ func (c *cmdProfileRemove) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpInstances(toComplete) + } + + if len(args) == 1 { + return c.global.cmpProfiles(args[0], false) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -773,6 +862,14 @@ func (c *cmdProfileRename) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpProfiles(toComplete, true) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -828,6 +925,19 @@ For backward compatibility, a single configuration key may still be set with: cmd.RunE = c.run cmd.Flags().BoolVarP(&c.flagIsProperty, "property", "p", false, i18n.G("Set the key as a profile property")) + + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpProfiles(toComplete, true) + } + + if len(args) == 1 { + return c.global.cmpInstanceAllKeys(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -901,6 +1011,14 @@ func (c *cmdProfileShow) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpProfiles(toComplete, true) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -958,6 +1076,18 @@ func (c *cmdProfileUnset) command() *cobra.Command { cmd.RunE = c.run cmd.Flags().BoolVarP(&c.flagIsProperty, "property", "p", false, i18n.G("Unset the key as a profile property")) + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpProfiles(toComplete, true) + } + + if len(args) == 1 { + return c.global.cmpProfileConfigs(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } diff --git a/lxc/project.go b/lxc/project.go index 3fad70eef138..5c8252e1628e 100644 --- a/lxc/project.go +++ b/lxc/project.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "os" + "slices" "sort" "strings" @@ -102,6 +103,14 @@ lxc project create p1 < config.yaml cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpRemotes(false) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -184,6 +193,14 @@ func (c *cmdProjectDelete) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpProjects(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -250,6 +267,14 @@ func (c *cmdProjectEdit) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpProjects(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -375,6 +400,19 @@ func (c *cmdProjectGet) command() *cobra.Command { cmd.RunE = c.run cmd.Flags().BoolVarP(&c.flagIsProperty, "property", "p", false, i18n.G("Get the key as a project property")) + + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpProjects(toComplete) + } + + if len(args) == 1 { + return c.global.cmpProjectConfigs(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -437,6 +475,14 @@ func (c *cmdProjectList) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpRemotes(false) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -455,8 +501,6 @@ func (c *cmdProjectList) run(cmd *cobra.Command, args []string) error { remote = args[0] } - remoteName := strings.TrimSuffix(remote, ":") - resources, err := c.global.ParseServers(remote) if err != nil { return err @@ -470,9 +514,10 @@ func (c *cmdProjectList) run(cmd *cobra.Command, args []string) error { return err } - currentProject := conf.Remotes[remoteName].Project - if currentProject == "" { - currentProject = "default" + // Get the current project. + info, err := resource.server.GetConnectionInfo() + if err != nil { + return err } data := [][]string{} @@ -508,7 +553,7 @@ func (c *cmdProjectList) run(cmd *cobra.Command, args []string) error { } name := project.Name - if name == currentProject { + if name == info.Project { name = fmt.Sprintf("%s (%s)", name, i18n.G("current")) } @@ -549,6 +594,14 @@ func (c *cmdProjectRename) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpProjects(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -609,6 +662,15 @@ For backward compatibility, a single configuration key may still be set with: cmd.RunE = c.run cmd.Flags().BoolVarP(&c.flagIsProperty, "property", "p", false, i18n.G("Set the key as a project property")) + + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpProjects(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -685,6 +747,19 @@ func (c *cmdProjectUnset) command() *cobra.Command { cmd.RunE = c.run cmd.Flags().BoolVarP(&c.flagIsProperty, "property", "p", false, i18n.G("Unset the key as a project property")) + + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpProjects(toComplete) + } + + if len(args) == 1 { + return c.global.cmpProjectConfigs(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -716,6 +791,14 @@ func (c *cmdProjectShow) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpProjects(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -769,6 +852,14 @@ func (c *cmdProjectSwitch) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpProjects(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -821,7 +912,7 @@ type cmdProjectInfo struct { func (c *cmdProjectInfo) command() *cobra.Command { cmd := &cobra.Command{} - cmd.Use = usage("info", i18n.G("[:] ")) + cmd.Use = usage("info", i18n.G("[:]")) cmd.Short = i18n.G("Get a summary of resource allocations") cmd.Long = cli.FormatSection(i18n.G("Description"), i18n.G( `Get a summary of resource allocations`)) @@ -829,6 +920,14 @@ func (c *cmdProjectInfo) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpProjects(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -861,9 +960,11 @@ func (c *cmdProjectInfo) run(cmd *cobra.Command, args []string) error { byteLimits := []string{"disk", "memory"} data := [][]string{} for k, v := range projectState.Resources { + shortKey, _, _ := strings.Cut(k, ".") + limit := i18n.G("UNLIMITED") if v.Limit >= 0 { - if shared.ValueInSlice(k, byteLimits) { + if slices.Contains(byteLimits, shortKey) { limit = units.GetByteSizeStringIEC(v.Limit, 2) } else { limit = fmt.Sprintf("%d", v.Limit) @@ -871,13 +972,19 @@ func (c *cmdProjectInfo) run(cmd *cobra.Command, args []string) error { } usage := "" - if shared.ValueInSlice(k, byteLimits) { + if slices.Contains(byteLimits, shortKey) { usage = units.GetByteSizeStringIEC(v.Usage, 2) } else { usage = fmt.Sprintf("%d", v.Usage) } - data = append(data, []string{strings.ToUpper(k), limit, usage}) + columnName := strings.ToUpper(k) + before, after, found := strings.Cut(columnName, ".") + if found { + columnName = fmt.Sprintf("%s (%s)", before, after) + } + + data = append(data, []string{columnName, limit, usage}) } sort.Sort(cli.SortColumnsNaturally(data)) diff --git a/lxc/publish.go b/lxc/publish.go index 1d6541ddb98a..7c7b979fc98f 100644 --- a/lxc/publish.go +++ b/lxc/publish.go @@ -42,6 +42,18 @@ func (c *cmdPublish) command() *cobra.Command { cmd.Flags().StringVar(&c.flagExpiresAt, "expire", "", i18n.G("Image expiration date (format: rfc3339)")+"``") cmd.Flags().BoolVar(&c.flagReuse, "reuse", false, i18n.G("If the image alias already exists, delete and create a new one")) + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpInstancesAndSnapshots(toComplete) + } + + if len(args) == 1 { + return c.global.cmpRemotes(false) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } diff --git a/lxc/rebuild.go b/lxc/rebuild.go index fad42ea7b881..9b0ce18308d9 100644 --- a/lxc/rebuild.go +++ b/lxc/rebuild.go @@ -161,7 +161,7 @@ func (c *cmdRebuild) rebuild(conf *config.Config, args []string) error { return errors.New(i18n.G("Can't use an image with --empty")) } - req.Source.Type = "none" + req.Source.Type = api.SourceTypeNone op, err := d.RebuildInstance(name, req) if err != nil { return err diff --git a/lxc/remote.go b/lxc/remote.go index 065973e309a4..817b8363e6c5 100644 --- a/lxc/remote.go +++ b/lxc/remote.go @@ -171,14 +171,14 @@ func (c *cmdRemoteAdd) runToken(addr string, server string, token string, rawTok // If address is provided, use token on that specific address. if addr != "" { - return c.addRemoteFromToken(addr, server, token, rawToken.Fingerprint) + return c.addRemoteFromToken(addr, server, token, *rawToken) } // Otherwise, iterate over all addresses within the token. for _, addr := range rawToken.Addresses { addr = fmt.Sprintf("https://%s", addr) - err := c.addRemoteFromToken(addr, server, token, rawToken.Fingerprint) + err := c.addRemoteFromToken(addr, server, token, *rawToken) if err != nil { if api.StatusErrorCheck(err, http.StatusServiceUnavailable) { continue @@ -203,7 +203,7 @@ func (c *cmdRemoteAdd) runToken(addr string, server string, token string, rawTok return errors.New(i18n.G("Failed to add remote")) } - err = c.addRemoteFromToken(string(line), server, token, rawToken.Fingerprint) + err = c.addRemoteFromToken(string(line), server, token, *rawToken) if err != nil { return err } @@ -211,7 +211,7 @@ func (c *cmdRemoteAdd) runToken(addr string, server string, token string, rawTok return nil } -func (c *cmdRemoteAdd) addRemoteFromToken(addr string, server string, token string, fingerprint string) error { +func (c *cmdRemoteAdd) addRemoteFromToken(addr string, server string, token string, rawToken api.CertificateAddToken) error { conf := c.global.conf var certificate *x509.Certificate @@ -227,7 +227,7 @@ func (c *cmdRemoteAdd) addRemoteFromToken(addr string, server string, token stri } certDigest := shared.CertFingerprint(certificate) - if fingerprint != certDigest { + if rawToken.Fingerprint != certDigest { return fmt.Errorf(i18n.G("Certificate fingerprint mismatch between certificate token and server %q"), addr) } @@ -261,19 +261,33 @@ func (c *cmdRemoteAdd) addRemoteFromToken(addr string, server string, token stri return api.StatusErrorf(http.StatusServiceUnavailable, "%s: %w", i18n.G("Unavailable remote server"), err) } - req := api.CertificatesPost{} - if d.HasExtension("explicit_trust_token") { - req.TrustToken = token - } else { - req.Password = token - } - // Add client certificate to trust store. Even if we are already trusted (src.Auth == "trusted"), // we want to send the token to invalidate it. Therefore, we can ignore the conflict error, which // is thrown if we are trying to add a client cert that is already trusted by LXD remote. - err = d.CreateCertificate(req) - if err != nil && !api.StatusErrorCheck(err, http.StatusConflict) { - return err + // + // If "type" is not set on the token, the token was issued by the certificates API and CreateCertificate should be + // called. If "type" is set, the token was issued by the auth API and CreateIdentityTLS should be called. + if rawToken.Type == "" { + req := api.CertificatesPost{} + if d.HasExtension("explicit_trust_token") { + req.TrustToken = token + } else { + req.Password = token + } + + err = d.CreateCertificate(req) + if err != nil && !api.StatusErrorCheck(err, http.StatusConflict) { + return err + } + } else { + req := api.IdentitiesTLSPost{ + TrustToken: token, + } + + err = d.CreateIdentityTLS(req) + if err != nil && !api.StatusErrorCheck(err, http.StatusConflict) { + return err + } } // And check if trusted now. @@ -631,31 +645,62 @@ func (c *cmdRemoteAdd) run(cmd *cobra.Command, args []string) error { // Check if additional authentication is required. if srv.Auth != "trusted" { if c.flagAuthType == api.AuthenticationMethodTLS { - req := api.CertificatesPost{} - - if c.flagToken != "" && d.(lxd.InstanceServer).HasExtension("explicit_trust_token") { - req.TrustToken = c.flagToken - } else if c.flagPassword == "" { - // If the token is not set fallback to prompt for trust password. - fmt.Printf(i18n.G("Admin password (or token) for %s:")+" ", server) - pwd, err := term.ReadPassword(0) + var gainTrust func() error + + // If the password flag isn't provided and the server supports the explicit_trust_token extension, + // use the token instead and prompt for it if not present. + if d.(lxd.InstanceServer).HasExtension("explicit_trust_token") && c.flagPassword == "" { + // Prompt for trust token. + token, err := c.global.asker.AskString(fmt.Sprintf(i18n.G("Trust token for %s: "), server), "", nil) if err != nil { - // We got an error, maybe this isn't a terminal, let's try to read it as a file. - pwd, err = shared.ReadStdin() - if err != nil { - return err + return err + } + + // Decode the token. + certificateAddToken, err := shared.CertificateTokenDecode(token) + if err != nil { + return err + } + + // If the type field is set it's for use with the auth API. Otherwise it's for use with the certificates API. + if certificateAddToken.Type == "" { + gainTrust = func() error { + return d.(lxd.InstanceServer).CreateCertificate(api.CertificatesPost{ + Type: api.CertificateTypeClient, + TrustToken: token, + }) + } + } else { + gainTrust = func() error { + return d.(lxd.InstanceServer).CreateIdentityTLS(api.IdentitiesTLSPost{TrustToken: token}) } } - fmt.Println("") - req.Password = string(pwd) } else { - req.Password = c.flagPassword - } + // Prompt for trust password if token is not supported by the server. + if c.flagPassword == "" { + fmt.Printf(i18n.G("Admin password (or token) for %s:")+" ", server) + pwd, err := term.ReadPassword(0) + if err != nil { + // We got an error, maybe this isn't a terminal, let's try to read it as a file. + pwd, err = shared.ReadStdin() + if err != nil { + return err + } + } - req.Type = api.CertificateTypeClient + fmt.Println("") + c.flagPassword = string(pwd) + } + + gainTrust = func() error { + return d.(lxd.InstanceServer).CreateCertificate(api.CertificatesPost{ + Type: api.CertificateTypeClient, + Password: c.flagPassword, + }) + } + } - // Add client certificate to trust store. - err = d.(lxd.InstanceServer).CreateCertificate(req) + err = gainTrust() if err != nil { return err } @@ -831,6 +876,14 @@ func (c *cmdRemoteRename) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpRemoteNames() + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -904,6 +957,14 @@ func (c *cmdRemoteRemove) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpRemoteNames() + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -960,6 +1021,14 @@ func (c *cmdRemoteSwitch) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpRemoteNames() + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -1000,6 +1069,14 @@ func (c *cmdRemoteSetURL) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpRemoteNames() + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } diff --git a/lxc/rename.go b/lxc/rename.go index 0b438e643d8a..201d2d1168ac 100644 --- a/lxc/rename.go +++ b/lxc/rename.go @@ -23,6 +23,14 @@ func (c *cmdRename) command() *cobra.Command { `Rename instances and snapshots`)) cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpInstances(toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } diff --git a/lxc/storage.go b/lxc/storage.go index dac15ae65b70..1e0dde0bd43c 100644 --- a/lxc/storage.go +++ b/lxc/storage.go @@ -105,6 +105,14 @@ lxc storage create s1 dir < config.yaml cmd.Flags().StringVar(&c.storage.flagTarget, "target", "", i18n.G("Cluster member name")+"``") cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpRemotes(false) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -197,6 +205,14 @@ func (c *cmdStorageDelete) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpStoragePools(toComplete, false) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -250,6 +266,14 @@ func (c *cmdStorageEdit) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpStoragePools(toComplete, false) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -373,6 +397,18 @@ func (c *cmdStorageGet) command() *cobra.Command { cmd.Flags().BoolVarP(&c.flagIsProperty, "property", "p", false, i18n.G("Get the key as a storage property")) cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpStoragePools(toComplete, false) + } + + if len(args) == 1 { + return c.global.cmpStoragePoolConfigs(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -443,6 +479,14 @@ func (c *cmdStorageInfo) command() *cobra.Command { cmd.Flags().StringVar(&c.storage.flagTarget, "target", "", i18n.G("Cluster member name")+"``") cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpStoragePools(toComplete, false) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -614,6 +658,14 @@ func (c *cmdStorageList) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpRemotes(false) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -697,6 +749,14 @@ For backward compatibility, a single configuration key may still be set with: cmd.Flags().BoolVarP(&c.flagIsProperty, "property", "p", false, i18n.G("Set the key as a storage property")) cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpStoragePools(toComplete, false) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -790,6 +850,14 @@ func (c *cmdStorageShow) command() *cobra.Command { cmd.Flags().StringVar(&c.storage.flagTarget, "target", "", i18n.G("Cluster member name")+"``") cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpStoragePools(toComplete, false) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -876,6 +944,18 @@ func (c *cmdStorageUnset) command() *cobra.Command { cmd.Flags().BoolVarP(&c.flagIsProperty, "property", "p", false, i18n.G("Unset the key as a storage property")) cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpStoragePools(toComplete, false) + } + + if len(args) == 1 { + return c.global.cmpStoragePoolConfigs(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } diff --git a/lxc/storage_volume.go b/lxc/storage_volume.go index 4f0a38326db4..dc3804d821f7 100644 --- a/lxc/storage_volume.go +++ b/lxc/storage_volume.go @@ -7,6 +7,7 @@ import ( "net/url" "os" "path" + "slices" "sort" "strconv" "strings" @@ -37,6 +38,19 @@ type cmdStorageVolume struct { flagDestinationTarget string } +func parseVolume(defaultType string, name string) (volName string, volType string) { + fields := strings.SplitN(name, "/", 2) + if len(fields) == 1 { + volName, volType = fields[0], defaultType + } else if len(fields) == 2 && !slices.Contains([]string{"custom", "image", "container", "virtual-machine"}, fields[0]) { + volName, volType = name, defaultType + } else { + volName, volType = fields[1], fields[0] + } + + return volName, volType +} + func (c *cmdStorageVolume) command() *cobra.Command { cmd := &cobra.Command{} cmd.Use = usage("volume") @@ -132,17 +146,6 @@ Unless specified through a prefix, all volume operations affect "custom" (user c return cmd } -func (c *cmdStorageVolume) parseVolume(defaultType string, name string) (volumeName string, volumeType string) { - fields := strings.SplitN(name, "/", 2) - if len(fields) == 1 { - return fields[0], defaultType - } else if len(fields) == 2 && !shared.ValueInSlice(fields[0], []string{"custom", "image", "container", "virtual-machine"}) { - return name, defaultType - } - - return fields[1], fields[0] -} - func (c *cmdStorageVolume) parseVolumeWithPool(name string) (volumeName string, poolName string) { fields := strings.SplitN(name, "/", 2) if len(fields) == 1 { @@ -168,6 +171,22 @@ func (c *cmdStorageVolumeAttach) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpStoragePools(toComplete, false) + } + + if len(args) == 1 { + return c.global.cmpStoragePoolVolumes(args[0]) + } + + if len(args) == 2 { + return c.global.cmpInstanceNamesFromRemote(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -190,7 +209,7 @@ func (c *cmdStorageVolumeAttach) run(cmd *cobra.Command, args []string) error { return errors.New(i18n.G("Missing pool name")) } - volName, volType := c.storageVolume.parseVolume("custom", args[1]) + volName, volType := parseVolume("custom", args[1]) if volType != "custom" { return errors.New(i18n.G("Only \"custom\" volumes can be attached to instances")) } @@ -266,6 +285,22 @@ func (c *cmdStorageVolumeAttachProfile) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpStoragePools(toComplete, false) + } + + if len(args) == 1 { + return c.global.cmpStoragePoolVolumes(args[0]) + } + + if len(args) == 2 { + return c.global.cmpProfileNamesFromRemote(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -303,7 +338,7 @@ func (c *cmdStorageVolumeAttachProfile) run(cmd *cobra.Command, args []string) e devPath = args[4] } - volName, volType := c.storageVolume.parseVolume("custom", args[1]) + volName, volType := parseVolume("custom", args[1]) if volType != "custom" { return errors.New(i18n.G("Only \"custom\" volumes can be attached to instances")) } @@ -363,6 +398,24 @@ func (c *cmdStorageVolumeCopy) command() *cobra.Command { cmd.Flags().BoolVar(&c.flagRefresh, "refresh", false, i18n.G("Refresh and update the existing storage volume copies")) cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpStoragePoolWithVolume(toComplete) + } + + if len(args) == 1 { + completions, directive := c.global.cmpStoragePools(toComplete, true) + for i, completion := range completions { + if !strings.Contains(completion, ":") { + completions[i] = completion + "/" + } + } + return completions, directive + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -566,6 +619,14 @@ lxc storage volume create p1 v1 < config.yaml cmd.Flags().StringVar(&c.flagContentType, "type", "filesystem", i18n.G("Content type, block or filesystem")+"``") cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpStoragePools(toComplete, false) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -604,7 +665,7 @@ func (c *cmdStorageVolumeCreate) run(cmd *cobra.Command, args []string) error { } // Parse the input - volName, volType := c.storageVolume.parseVolume("custom", args[1]) + volName, volType := parseVolume("custom", args[1]) // Create the storage volume entry vol := api.StorageVolumesPost{ @@ -662,6 +723,18 @@ func (c *cmdStorageVolumeDelete) command() *cobra.Command { cmd.Flags().StringVar(&c.storage.flagTarget, "target", "", i18n.G("Cluster member name")+"``") cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpStoragePools(toComplete, false) + } + + if len(args) == 1 { + return c.global.cmpStoragePoolVolumes(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -686,7 +759,7 @@ func (c *cmdStorageVolumeDelete) run(cmd *cobra.Command, args []string) error { client := resource.server // Parse the input - volName, volType := c.storageVolume.parseVolume("custom", args[1]) + volName, volType := parseVolume("custom", args[1]) // If a target was specified, delete the volume on the given member. if c.storage.flagTarget != "" { @@ -736,6 +809,22 @@ func (c *cmdStorageVolumeDetach) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpStoragePools(toComplete, false) + } + + if len(args) == 1 { + return c.global.cmpStoragePoolVolumes(args[0]) + } + + if len(args) == 2 { + return c.global.cmpStoragePoolVolumeInstances(args[0], args[1]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -818,6 +907,22 @@ func (c *cmdStorageVolumeDetachProfile) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpStoragePools(toComplete, false) + } + + if len(args) == 1 { + return c.global.cmpStoragePoolVolumes(args[0]) + } + + if len(args) == 2 { + return c.global.cmpStoragePoolVolumeProfiles(args[0], args[1]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -906,6 +1011,18 @@ lxc storage volume edit [:] [/] < volume.yaml cmd.Flags().StringVar(&c.storage.flagTarget, "target", "", i18n.G("Cluster member name")+"``") cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpStoragePools(toComplete, false) + } + + if len(args) == 1 { + return c.global.cmpStoragePoolVolumes(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -945,7 +1062,7 @@ func (c *cmdStorageVolumeEdit) run(cmd *cobra.Command, args []string) error { client := resource.server // Parse the input - volName, volType := c.storageVolume.parseVolume("custom", args[1]) + volName, volType := parseVolume("custom", args[1]) isSnapshot := false fields := strings.Split(volName, "/") @@ -1121,6 +1238,22 @@ lxc storage volume get default virtual-machine/data snapshots.expiry cmd.Flags().BoolVarP(&c.flagIsProperty, "property", "p", false, i18n.G("Get the key as a storage volume property")) cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpStoragePools(toComplete, false) + } + + if len(args) == 1 { + return c.global.cmpStoragePoolVolumes(args[0]) + } + + if len(args) == 2 { + return c.global.cmpStoragePoolVolumeConfigs(args[0], args[1]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -1146,7 +1279,7 @@ func (c *cmdStorageVolumeGet) run(cmd *cobra.Command, args []string) error { client := resource.server // Parse input - volName, volType := c.storageVolume.parseVolume("custom", args[1]) + volName, volType := parseVolume("custom", args[1]) isSnapshot := false fields := strings.Split(volName, "/") @@ -1233,6 +1366,18 @@ lxc storage volume info default virtual-machine/data cmd.Flags().StringVar(&c.storage.flagTarget, "target", "", i18n.G("Cluster member name")+"``") cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpStoragePools(toComplete, false) + } + + if len(args) == 1 { + return c.global.cmpStoragePoolVolumes(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -1258,7 +1403,7 @@ func (c *cmdStorageVolumeInfo) run(cmd *cobra.Command, args []string) error { client := resource.server // Parse the input - volName, volType := c.storageVolume.parseVolume("custom", args[1]) + volName, volType := parseVolume("custom", args[1]) isSnapshot := false fields := strings.Split(volName, "/") @@ -1470,6 +1615,14 @@ Column shorthand chars: cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpStoragePools(toComplete, false) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -1703,6 +1856,24 @@ func (c *cmdStorageVolumeMove) command() *cobra.Command { cmd.Flags().StringVar(&c.storageVolumeCopy.flagTargetProject, "target-project", "", i18n.G("Move to a project different from the source")+"``") cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpStoragePoolWithVolume(toComplete) + } + + if len(args) == 1 { + completions, directive := c.global.cmpStoragePools(toComplete, true) + for i, completion := range completions { + if !strings.Contains(completion, ":") { + completions[i] = completion + "/" + } + } + return completions, directive + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -1781,6 +1952,18 @@ func (c *cmdStorageVolumeRename) command() *cobra.Command { cmd.Flags().StringVar(&c.storage.flagTarget, "target", "", i18n.G("Cluster member name")+"``") cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpStoragePools(toComplete, false) + } + + if len(args) == 1 { + return c.global.cmpStoragePoolVolumes(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -1806,7 +1989,7 @@ func (c *cmdStorageVolumeRename) run(cmd *cobra.Command, args []string) error { client := resource.server // Parse the input - volName, volType := c.storageVolume.parseVolume("custom", args[1]) + volName, volType := parseVolume("custom", args[1]) isSnapshot := false fields := strings.Split(volName, "/") @@ -1909,6 +2092,20 @@ lxc storage volume set default virtual-machine/data snapshots.expiry=7d cmd.Flags().BoolVarP(&c.flagIsProperty, "property", "p", false, i18n.G("Set the key as a storage volume property")) cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpStoragePools(toComplete, false) + } + + if len(args) == 1 { + return c.global.cmpStoragePoolVolumes(args[0]) + } + + // TODO all volume config keys + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -1939,7 +2136,7 @@ func (c *cmdStorageVolumeSet) run(cmd *cobra.Command, args []string) error { } // Parse the input. - volName, volType := c.storageVolume.parseVolume("custom", args[1]) + volName, volType := parseVolume("custom", args[1]) isSnapshot := false fields := strings.Split(volName, "/") @@ -2054,6 +2251,18 @@ lxc storage volume show default virtual-machine/data/snap0 cmd.Flags().StringVar(&c.storage.flagTarget, "target", "", i18n.G("Cluster member name")+"``") cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpStoragePools(toComplete, false) + } + + if len(args) == 1 { + return c.global.cmpStoragePoolVolumes(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -2079,7 +2288,7 @@ func (c *cmdStorageVolumeShow) run(cmd *cobra.Command, args []string) error { client := resource.server // Parse the input - volName, volType := c.storageVolume.parseVolume("custom", args[1]) + volName, volType := parseVolume("custom", args[1]) isSnapshot := false fields := strings.Split(volName, "/") @@ -2159,6 +2368,22 @@ lxc storage volume unset default virtual-machine/data snapshots.expiry cmd.Flags().BoolVarP(&c.flagIsProperty, "property", "p", false, i18n.G("Unset the key as a storage volume property")) cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpStoragePools(toComplete, false) + } + + if len(args) == 1 { + return c.global.cmpStoragePoolVolumes(args[0]) + } + + if len(args) == 2 { + return c.global.cmpStoragePoolVolumeConfigs(args[0], args[1]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -2201,6 +2426,19 @@ lxc storage volume snapshot create default v1 snap0 < config.yaml cmd.Flags().BoolVar(&c.flagNoExpiry, "no-expiry", false, i18n.G("Ignore any configured auto-expiry for the storage volume")) cmd.Flags().BoolVar(&c.flagReuse, "reuse", false, i18n.G("If the snapshot name already exists, delete and create a new one")) cmd.Flags().StringVar(&c.storage.flagTarget, "target", "", i18n.G("Cluster member name")+"``") + cmd.RunE = c.run + + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpStoragePools(toComplete, false) + } + + if len(args) == 1 { + return c.global.cmpStoragePoolVolumes(args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } return cmd } @@ -2246,7 +2484,7 @@ func (c *cmdStorageVolumeSnapshot) run(cmd *cobra.Command, args []string) error } // Parse the input - volName, volType := c.storageVolume.parseVolume("custom", args[1]) + volName, volType := parseVolume("custom", args[1]) if volType != "custom" { return errors.New(i18n.G("Only \"custom\" volumes can be snapshotted")) } @@ -2318,6 +2556,22 @@ func (c *cmdStorageVolumeRestore) command() *cobra.Command { cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpStoragePools(toComplete, false) + } + + if len(args) == 1 { + return c.global.cmpStoragePoolVolumes(args[0]) + } + + if len(args) == 2 { + return c.global.cmpStoragePoolVolumeSnapshots(args[0], args[1]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + } + return cmd } @@ -2389,6 +2643,18 @@ func (c *cmdStorageVolumeExport) command() *cobra.Command { cmd.Flags().StringVar(&c.storage.flagTarget, "target", "", i18n.G("Cluster member name")+"``") cmd.RunE = c.run + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpStoragePools(toComplete, false) + } + + if len(args) == 1 { + return c.global.cmpStoragePoolVolumes(args[0]) + } + + return nil, cobra.ShellCompDirectiveDefault + } + return cmd } @@ -2419,7 +2685,7 @@ func (c *cmdStorageVolumeExport) run(cmd *cobra.Command, args []string) error { volumeOnly := c.flagVolumeOnly - volName, volType := c.storageVolume.parseVolume("custom", args[1]) + volName, volType := parseVolume("custom", args[1]) if volType != "custom" { return errors.New(i18n.G("Only \"custom\" volumes can be exported")) } @@ -2542,6 +2808,14 @@ func (c *cmdStorageVolumeImport) command() *cobra.Command { cmd.RunE = c.run cmd.Flags().StringVar(&c.flagType, "type", "", i18n.G("Import type, backup or iso (default \"backup\")")+"``") + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return c.global.cmpStoragePools(toComplete, false) + } + + return nil, cobra.ShellCompDirectiveDefault + } + return cmd } diff --git a/lxc/warning.go b/lxc/warning.go index c4350ea67d53..e2ee7459700f 100644 --- a/lxc/warning.go +++ b/lxc/warning.go @@ -352,7 +352,7 @@ type cmdWarningDelete struct { func (c *cmdWarningDelete) command() *cobra.Command { cmd := &cobra.Command{} - cmd.Use = usage("delete", i18n.G("[:]")) + cmd.Use = usage("delete", i18n.G("[:][]")) cmd.Aliases = []string{"rm"} cmd.Short = i18n.G("Delete warning") cmd.Long = cli.FormatSection(i18n.G("Description"), i18n.G( @@ -367,15 +367,30 @@ func (c *cmdWarningDelete) command() *cobra.Command { func (c *cmdWarningDelete) run(cmd *cobra.Command, args []string) error { // Quick checks. - exit, err := c.global.CheckArgs(cmd, args, 1, 1) + exit, err := c.global.CheckArgs(cmd, args, 0, 1) if exit { return err } - // Parse remote - remoteName, UUID, err := c.global.conf.ParseRemote(args[0]) - if err != nil { - return err + if !c.flagAll && len(args) < 1 { + return errors.New(i18n.G("Specify a warning UUID or use --all")) + } + + var remoteName string + var UUID string + + if len(args) > 0 { + // Parse remote + remoteName, UUID, err = c.global.conf.ParseRemote(args[0]) + if err != nil { + return err + } + } else { + remoteName = c.global.conf.DefaultRemote + } + + if UUID != "" && c.flagAll { + return errors.New(i18n.G("No need to specify a warning UUID when using --all")) } remoteServer, err := c.global.conf.GetInstanceServer(remoteName) @@ -383,5 +398,22 @@ func (c *cmdWarningDelete) run(cmd *cobra.Command, args []string) error { return err } + if c.flagAll { + // Delete all warnings + warnings, err := remoteServer.GetWarnings() + if err != nil { + return err + } + + for _, warning := range warnings { + err = remoteServer.DeleteWarning(warning.UUID) + if err != nil { + return err + } + } + + return nil + } + return remoteServer.DeleteWarning(UUID) } diff --git a/lxd-agent/devlxd.go b/lxd-agent/devlxd.go index cff186de220d..dd64bcd9cda5 100644 --- a/lxd-agent/devlxd.go +++ b/lxd-agent/devlxd.go @@ -1,7 +1,6 @@ package main import ( - "encoding/json" "fmt" "io" "net" @@ -298,96 +297,6 @@ func devlxdImageExportHandler(d *Daemon, w http.ResponseWriter, r *http.Request) return nil } -var devlxdUbuntuProGet = devLxdHandler{ - path: "/1.0/ubuntu-pro", - handlerFunc: devlxdUbuntuProGetHandler, -} - -func devlxdUbuntuProGetHandler(d *Daemon, w http.ResponseWriter, r *http.Request) *devLxdResponse { - if r.Method != http.MethodGet { - return errorResponse(http.StatusMethodNotAllowed, http.StatusText(http.StatusMethodNotAllowed)) - } - - // Get a http.Client. - client, err := getClient(d.serverCID, int(d.serverPort), d.serverCertificate) - if err != nil { - return smartResponse(fmt.Errorf("Failed connecting to LXD over vsock: %w", err)) - } - - // Remove the request URI, this cannot be set on requests. - r.RequestURI = "" - - // Set up the request URL with the correct host. - r.URL = &api.NewURL().Scheme("https").Host("custom.socket").Path(version.APIVersion, "ubuntu-pro").URL - - // Proxy the request. - resp, err := client.Do(r) - if err != nil { - return errorResponse(http.StatusInternalServerError, err.Error()) - } - - var apiResponse api.Response - err = json.NewDecoder(resp.Body).Decode(&apiResponse) - if err != nil { - return smartResponse(err) - } - - var settingsResponse api.UbuntuProSettings - err = json.Unmarshal(apiResponse.Metadata, &settingsResponse) - if err != nil { - return errorResponse(http.StatusInternalServerError, fmt.Sprintf("Invalid Ubuntu Token settings response received from host: %v", err)) - } - - return okResponse(settingsResponse, "json") -} - -var devlxdUbuntuProTokenPost = devLxdHandler{ - path: "/1.0/ubuntu-pro/token", - handlerFunc: devlxdUbuntuProTokenPostHandler, -} - -func devlxdUbuntuProTokenPostHandler(d *Daemon, w http.ResponseWriter, r *http.Request) *devLxdResponse { - if r.Method != http.MethodPost { - return errorResponse(http.StatusMethodNotAllowed, http.StatusText(http.StatusMethodNotAllowed)) - } - - // Get a http.Client. - client, err := getClient(d.serverCID, int(d.serverPort), d.serverCertificate) - if err != nil { - return smartResponse(fmt.Errorf("Failed connecting to LXD over vsock: %w", err)) - } - - // Remove the request URI, this cannot be set on requests. - r.RequestURI = "" - - // Set up the request URL with the correct host. - r.URL = &api.NewURL().Scheme("https").Host("custom.socket").Path(version.APIVersion, "ubuntu-pro", "token").URL - - // Proxy the request. - resp, err := client.Do(r) - if err != nil { - return errorResponse(http.StatusInternalServerError, err.Error()) - } - - var apiResponse api.Response - err = json.NewDecoder(resp.Body).Decode(&apiResponse) - if err != nil { - return smartResponse(err) - } - - if apiResponse.StatusCode != http.StatusOK { - return errorResponse(apiResponse.Code, apiResponse.Error) - } - - var tokenResponse api.UbuntuProGuestTokenResponse - err = json.Unmarshal(apiResponse.Metadata, &tokenResponse) - if err != nil { - return errorResponse(http.StatusInternalServerError, fmt.Sprintf("Invalid Ubuntu Token response received from host: %v", err)) - } - - return okResponse(tokenResponse, "json") -} - var handlers = []devLxdHandler{ { path: "/", @@ -402,8 +311,6 @@ var handlers = []devLxdHandler{ devLxdEventsGet, devlxdDevicesGet, devlxdImageExport, - devlxdUbuntuProGet, - devlxdUbuntuProTokenPost, } func hoistReq(f func(*Daemon, http.ResponseWriter, *http.Request) *devLxdResponse, d *Daemon) func(http.ResponseWriter, *http.Request) { diff --git a/lxd-agent/events.go b/lxd-agent/events.go index cbf810725d7a..76b35d3c93e1 100644 --- a/lxd-agent/events.go +++ b/lxd-agent/events.go @@ -25,8 +25,7 @@ var eventsCmd = APIEndpoint{ } type eventsServe struct { - req *http.Request - d *Daemon + d *Daemon } // Render starts event socket. @@ -89,7 +88,7 @@ func eventsSocket(d *Daemon, r *http.Request, w http.ResponseWriter) error { } func eventsGet(d *Daemon, r *http.Request) response.Response { - return &eventsServe{req: r, d: d} + return &eventsServe{d: d} } func eventsPost(d *Daemon, r *http.Request) response.Response { diff --git a/lxd-agent/operations.go b/lxd-agent/operations.go index a3cf184465fb..33bd9c3aa6d2 100644 --- a/lxd-agent/operations.go +++ b/lxd-agent/operations.go @@ -163,7 +163,7 @@ func operationWebsocketGet(d *Daemon, r *http.Request) response.Response { return response.SmartError(err) } - return operations.OperationWebSocket(r, op) + return operations.OperationWebSocket(op) } func operationWaitGet(d *Daemon, r *http.Request) response.Response { diff --git a/lxd-agent/sftp.go b/lxd-agent/sftp.go index d07357436dd4..32d8bf70b3c3 100644 --- a/lxd-agent/sftp.go +++ b/lxd-agent/sftp.go @@ -17,12 +17,11 @@ var sftpCmd = APIEndpoint{ } func sftpHandler(d *Daemon, r *http.Request) response.Response { - return &sftpServe{d, r} + return &sftpServe{d} } type sftpServe struct { d *Daemon - r *http.Request } func (r *sftpServe) String() string { diff --git a/lxd-benchmark/benchmark/operation.go b/lxd-benchmark/benchmark/operation.go index a4988e0e6bbd..3a0636bb4613 100644 --- a/lxd-benchmark/benchmark/operation.go +++ b/lxd-benchmark/benchmark/operation.go @@ -16,7 +16,7 @@ func createContainer(c lxd.ContainerServer, fingerprint string, name string, pri req := api.ContainersPost{ Name: name, Source: api.ContainerSource{ - Type: "image", + Type: api.SourceTypeImage, Fingerprint: fingerprint, }, } diff --git a/lxd-migrate/main_migrate.go b/lxd-migrate/main_migrate.go index aa8fb7d40257..f3d3935c687a 100644 --- a/lxd-migrate/main_migrate.go +++ b/lxd-migrate/main_migrate.go @@ -362,7 +362,7 @@ func (c *cmdMigrate) newMigrateData(server lxd.InstanceServer) (*cmdMigrateData, config.InstanceArgs.Config = map[string]string{} config.InstanceArgs.Devices = map[string]map[string]string{} config.InstanceArgs.Source = api.InstanceSource{ - Type: "conversion", + Type: api.SourceTypeConversion, Mode: "push", ConversionOptions: c.flagConversionOpts, } @@ -372,7 +372,7 @@ func (c *cmdMigrate) newMigrateData(server lxd.InstanceServer) (*cmdMigrateData, // LXD instance. This means that images of different formats, // such as VMDK and QCow2, will not work. if !server.HasExtension("instance_import_conversion") { - config.InstanceArgs.Source.Type = "migration" + config.InstanceArgs.Source.Type = api.SourceTypeMigration } // Parse instance type from a flag. @@ -911,7 +911,7 @@ func (c *cmdMigrate) run(cmd *cobra.Command, args []string) error { } // In conversion mode, server expects the volume size hint in the request. - if config.InstanceArgs.Source.Type == "conversion" { + if config.InstanceArgs.Source.Type == api.SourceTypeConversion { size, err := block.DiskSizeBytes(target) if err != nil { return err @@ -943,7 +943,7 @@ func (c *cmdMigrate) run(cmd *cobra.Command, args []string) error { }) progressPrefix := "Transferring instance: %s" - if config.InstanceArgs.Source.Type == "conversion" { + if config.InstanceArgs.Source.Type == api.SourceTypeConversion { // In conversion mode, progress prefix is determined on the server side. progressPrefix = "%s" } @@ -955,7 +955,7 @@ func (c *cmdMigrate) run(cmd *cobra.Command, args []string) error { return err } - if config.InstanceArgs.Source.Type == "conversion" { + if config.InstanceArgs.Source.Type == api.SourceTypeConversion { err = transferRootDiskForConversion(ctx, op, fullPath, c.flagRsyncArgs, config.InstanceArgs.Type) } else { err = transferRootDiskForMigration(ctx, op, fullPath, c.flagRsyncArgs, config.InstanceArgs.Type) @@ -1098,7 +1098,7 @@ func (c *cmdMigrate) checkSource(path string, instanceType api.InstanceType, mig return errors.New("Path does not exist") } - if instanceType == api.InstanceTypeVM && migrationMode == "migration" { + if instanceType == api.InstanceTypeVM && migrationMode == api.SourceTypeMigration { isImageTypeRaw, err := isImageTypeRaw(path) if err != nil { return err diff --git a/lxd/acme.go b/lxd/acme.go index 7b9d131667ee..b2522e24308e 100644 --- a/lxd/acme.go +++ b/lxd/acme.go @@ -37,24 +37,19 @@ func acmeProvideChallenge(d *Daemon, r *http.Request) response.Response { return response.SmartError(err) } - if s.ServerClustered { - leader, err := d.gateway.LeaderAddress() + leaderInfo, err := s.LeaderInfo() + if err != nil { + return response.SmartError(err) + } + + if !leaderInfo.Leader { + // Forward the request to the leader + client, err := cluster.Connect(leaderInfo.Address, s.Endpoints.NetworkCert(), s.ServerCert(), r, true) if err != nil { return response.SmartError(err) } - // This gives me the correct value - clusterAddress := s.LocalConfig.ClusterAddress() - - if clusterAddress != "" && clusterAddress != leader { - // Forward the request to the leader - client, err := cluster.Connect(leader, s.Endpoints.NetworkCert(), s.ServerCert(), r, true) - if err != nil { - return response.SmartError(err) - } - - return response.ForwardedResponse(client, r) - } + return response.ForwardedResponse(client, r) } if d.http01Provider == nil || d.http01Provider.Token() != token { @@ -83,18 +78,13 @@ func autoRenewCertificate(ctx context.Context, d *Daemon, force bool) error { } // If we are clustered, let the leader handle the certificate renewal. - if s.ServerClustered { - leader, err := d.gateway.LeaderAddress() - if err != nil { - return err - } - - // Figure out our own cluster address. - clusterAddress := s.LocalConfig.ClusterAddress() + leaderInfo, err := s.LeaderInfo() + if err != nil { + return err + } - if clusterAddress != leader { - return nil - } + if !leaderInfo.Leader { + return nil } opRun := func(op *operations.Operation) error { diff --git a/lxd/api.go b/lxd/api.go index 9e52e86f68ab..477c03ccbcaf 100644 --- a/lxd/api.go +++ b/lxd/api.go @@ -16,12 +16,14 @@ import ( "github.com/canonical/lxd/lxd/cluster/request" "github.com/canonical/lxd/lxd/db" "github.com/canonical/lxd/lxd/instance" + "github.com/canonical/lxd/lxd/metrics" lxdRequest "github.com/canonical/lxd/lxd/request" "github.com/canonical/lxd/lxd/response" storagePools "github.com/canonical/lxd/lxd/storage" "github.com/canonical/lxd/lxd/storage/s3" "github.com/canonical/lxd/shared" "github.com/canonical/lxd/shared/api" + "github.com/canonical/lxd/shared/entity" "github.com/canonical/lxd/shared/logger" ) @@ -106,7 +108,8 @@ func restServer(d *Daemon) *http.Server { uiHandlerErrorUINotEnabled := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") w.WriteHeader(http.StatusServiceUnavailable) - _, _ = fmt.Fprint(w, errorMessage) + _, err := fmt.Fprint(w, errorMessage) + logger.Warn("Failed sending error message to client", logger.Ctx{"url": r.URL, "method": r.Method, "remote": r.RemoteAddr, "err": err}) }) mux.PathPrefix("/ui").Handler(uiHandlerErrorUINotEnabled) } @@ -182,6 +185,11 @@ func restServer(d *Daemon) *http.Server { } for _, c := range api10 { + // Every 1.0 endpoint should have a type for the API metrics. + if !shared.ValueInSlice(c.MetricsType, entity.APIMetricsEntityTypes()) { + panic(fmt.Sprintf(`Endpoint "/1.0/%s" has invalid MetricsType: %s`, c.Path, c.MetricsType)) + } + d.createCmd(mux, "1.0", c) // Create any alias endpoints using the same handlers as the parent endpoint but @@ -204,11 +212,15 @@ func restServer(d *Daemon) *http.Server { } mux.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + metrics.TrackStartedRequest(r, entity.TypeServer) // Use TypeServer for not found handler logger.Info("Sending top level 404", logger.Ctx{"url": r.URL, "method": r.Method, "remote": r.RemoteAddr}) w.Header().Set("Content-Type", "application/json") _ = response.NotFound(nil).Render(w, r) }) + // Initialize API metrics with zero values. + metrics.InitAPIMetrics() + return &http.Server{ Handler: &lxdHTTPServer{r: mux, d: d}, ConnContext: lxdRequest.SaveConnectionInContext, @@ -259,6 +271,7 @@ func metricsServer(d *Daemon) *http.Server { d.createCmd(mux, "1.0", metricsCmd) mux.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + metrics.TrackStartedRequest(r, entity.TypeServer) // Use TypeServer for not found handler logger.Info("Sending top level 404", logger.Ctx{"url": r.URL, "method": r.Method, "remote": r.RemoteAddr}) w.Header().Set("Content-Type", "application/json") _ = response.NotFound(nil).Render(w, r) diff --git a/lxd/api_1.0.go b/lxd/api_1.0.go index e6c589f06ceb..8d162bc04277 100644 --- a/lxd/api_1.0.go +++ b/lxd/api_1.0.go @@ -35,6 +35,8 @@ import ( ) var api10Cmd = APIEndpoint{ + MetricsType: entity.TypeServer, + Get: APIEndpointAction{Handler: api10Get, AllowUntrusted: true}, Patch: APIEndpointAction{Handler: api10Patch, AccessHandler: allowPermission(entity.TypeServer, auth.EntitlementCanEdit)}, Put: APIEndpointAction{Handler: api10Put, AccessHandler: allowPermission(entity.TypeServer, auth.EntitlementCanEdit)}, @@ -128,8 +130,11 @@ var api10 = []APIEndpoint{ warningCmd, metricsCmd, identitiesCmd, - identitiesByAuthenticationMethodCmd, - identityCmd, + currentIdentityCmd, + tlsIdentityCmd, + oidcIdentityCmd, + tlsIdentitiesCmd, + oidcIdentitiesCmd, authGroupsCmd, authGroupCmd, identityProviderGroupsCmd, @@ -696,7 +701,7 @@ func doAPI10Update(d *Daemon, r *http.Request, req api.ServerPut, patch bool) re // Then deal with cluster wide configuration var clusterChanged map[string]string var newClusterConfig *clusterConfig.Config - oldClusterConfig := make(map[string]any) + var oldClusterConfig map[string]any err = s.DB.Cluster.Transaction(context.Background(), func(ctx context.Context, tx *db.ClusterTx) error { var err error @@ -706,9 +711,7 @@ func doAPI10Update(d *Daemon, r *http.Request, req api.ServerPut, patch bool) re } // Keep old config around in case something goes wrong. In that case the config will be reverted. - for k, v := range newClusterConfig.Dump() { - oldClusterConfig[k] = v - } + oldClusterConfig = newClusterConfig.Dump() if patch { clusterChanged, err = newClusterConfig.Patch(req.Config) @@ -762,7 +765,7 @@ func doAPI10Update(d *Daemon, r *http.Request, req api.ServerPut, patch bool) re return response.SmartError(err) } - err = notifier(func(client lxd.InstanceServer) error { + err = notifier(func(member db.NodeInfo, client lxd.InstanceServer) error { server, etag, err := client.GetServer() if err != nil { return err diff --git a/lxd/api_cluster.go b/lxd/api_cluster.go index e37e3d33fd40..2e91bb5190e6 100644 --- a/lxd/api_cluster.go +++ b/lxd/api_cluster.go @@ -35,6 +35,7 @@ import ( "github.com/canonical/lxd/lxd/lifecycle" "github.com/canonical/lxd/lxd/node" "github.com/canonical/lxd/lxd/operations" + "github.com/canonical/lxd/lxd/project/limits" "github.com/canonical/lxd/lxd/request" "github.com/canonical/lxd/lxd/response" "github.com/canonical/lxd/lxd/scriptlet" @@ -72,21 +73,24 @@ type evacuateOpts struct { var targetGroupPrefix = "@" var clusterCmd = APIEndpoint{ - Path: "cluster", + Path: "cluster", + MetricsType: entity.TypeClusterMember, Get: APIEndpointAction{Handler: clusterGet, AccessHandler: allowAuthenticated}, Put: APIEndpointAction{Handler: clusterPut, AccessHandler: allowPermission(entity.TypeServer, auth.EntitlementCanEdit)}, } var clusterNodesCmd = APIEndpoint{ - Path: "cluster/members", + Path: "cluster/members", + MetricsType: entity.TypeClusterMember, Get: APIEndpointAction{Handler: clusterNodesGet, AccessHandler: allowAuthenticated}, Post: APIEndpointAction{Handler: clusterNodesPost, AccessHandler: allowPermission(entity.TypeServer, auth.EntitlementCanEdit)}, } var clusterNodeCmd = APIEndpoint{ - Path: "cluster/members/{name}", + Path: "cluster/members/{name}", + MetricsType: entity.TypeClusterMember, Delete: APIEndpointAction{Handler: clusterNodeDelete, AccessHandler: allowPermission(entity.TypeServer, auth.EntitlementCanEdit)}, Get: APIEndpointAction{Handler: clusterNodeGet, AccessHandler: allowAuthenticated}, @@ -96,27 +100,31 @@ var clusterNodeCmd = APIEndpoint{ } var clusterNodeStateCmd = APIEndpoint{ - Path: "cluster/members/{name}/state", + Path: "cluster/members/{name}/state", + MetricsType: entity.TypeClusterMember, Get: APIEndpointAction{Handler: clusterNodeStateGet, AccessHandler: allowAuthenticated}, Post: APIEndpointAction{Handler: clusterNodeStatePost, AccessHandler: allowPermission(entity.TypeServer, auth.EntitlementCanEdit)}, } var clusterCertificateCmd = APIEndpoint{ - Path: "cluster/certificate", + Path: "cluster/certificate", + MetricsType: entity.TypeClusterMember, Put: APIEndpointAction{Handler: clusterCertificatePut, AccessHandler: allowPermission(entity.TypeServer, auth.EntitlementCanEdit)}, } var clusterGroupsCmd = APIEndpoint{ - Path: "cluster/groups", + Path: "cluster/groups", + MetricsType: entity.TypeClusterMember, Get: APIEndpointAction{Handler: clusterGroupsGet, AccessHandler: allowAuthenticated}, Post: APIEndpointAction{Handler: clusterGroupsPost, AccessHandler: allowPermission(entity.TypeServer, auth.EntitlementCanEdit)}, } var clusterGroupCmd = APIEndpoint{ - Path: "cluster/groups/{name}", + Path: "cluster/groups/{name}", + MetricsType: entity.TypeClusterMember, Get: APIEndpointAction{Handler: clusterGroupGet, AccessHandler: allowAuthenticated}, Post: APIEndpointAction{Handler: clusterGroupPost, AccessHandler: allowPermission(entity.TypeServer, auth.EntitlementCanEdit)}, @@ -126,37 +134,43 @@ var clusterGroupCmd = APIEndpoint{ } var internalClusterAcceptCmd = APIEndpoint{ - Path: "cluster/accept", + Path: "cluster/accept", + MetricsType: entity.TypeClusterMember, Post: APIEndpointAction{Handler: internalClusterPostAccept, AccessHandler: allowPermission(entity.TypeServer, auth.EntitlementCanEdit)}, } var internalClusterRebalanceCmd = APIEndpoint{ - Path: "cluster/rebalance", + Path: "cluster/rebalance", + MetricsType: entity.TypeClusterMember, Post: APIEndpointAction{Handler: internalClusterPostRebalance, AccessHandler: allowPermission(entity.TypeServer, auth.EntitlementCanEdit)}, } var internalClusterAssignCmd = APIEndpoint{ - Path: "cluster/assign", + Path: "cluster/assign", + MetricsType: entity.TypeClusterMember, Post: APIEndpointAction{Handler: internalClusterPostAssign, AccessHandler: allowPermission(entity.TypeServer, auth.EntitlementCanEdit)}, } var internalClusterHandoverCmd = APIEndpoint{ - Path: "cluster/handover", + Path: "cluster/handover", + MetricsType: entity.TypeClusterMember, Post: APIEndpointAction{Handler: internalClusterPostHandover, AccessHandler: allowPermission(entity.TypeServer, auth.EntitlementCanEdit)}, } var internalClusterRaftNodeCmd = APIEndpoint{ - Path: "cluster/raft-node/{address}", + Path: "cluster/raft-node/{address}", + MetricsType: entity.TypeClusterMember, Delete: APIEndpointAction{Handler: internalClusterRaftNodeDelete, AccessHandler: allowPermission(entity.TypeServer, auth.EntitlementCanEdit)}, } var internalClusterHealCmd = APIEndpoint{ - Path: "cluster/heal/{name}", + Path: "cluster/heal/{name}", + MetricsType: entity.TypeClusterMember, Post: APIEndpointAction{Handler: internalClusterHeal, AccessHandler: allowPermission(entity.TypeServer, auth.EntitlementCanEdit)}, } @@ -409,7 +423,7 @@ func clusterPutBootstrap(d *Daemon, r *http.Request, req api.ClusterPut) respons } // Restart the networks (to pickup forkdns and the like). - err = networkStartup(s) + err = networkStartup(d.State) if err != nil { return err } @@ -835,7 +849,7 @@ func clusterPutJoin(d *Daemon, r *http.Request, req api.ClusterPut) response.Res // Start up networks so any post-join changes can be applied now that we have a Node ID. logger.Debug("Starting networks after cluster join") - err = networkStartup(s) + err = networkStartup(d.State) if err != nil { logger.Errorf("Failed starting networks: %v", err) } @@ -1212,11 +1226,15 @@ func clusterNodesGet(d *Daemon, r *http.Request) response.Response { recursion := util.IsRecursionRequest(r) s := d.State() - leaderAddress, err := d.gateway.LeaderAddress() + leaderInfo, err := s.LeaderInfo() if err != nil { return response.InternalError(err) } + if !leaderInfo.Clustered { + return response.InternalError(cluster.ErrNodeIsNotClustered) + } + var raftNodes []db.RaftNode err = s.DB.Node.Transaction(r.Context(), func(ctx context.Context, tx *db.NodeTx) error { raftNodes, err = tx.GetRaftNodes(ctx) @@ -1254,7 +1272,7 @@ func clusterNodesGet(d *Daemon, r *http.Request) response.Response { } args := db.NodeInfoArgs{ - LeaderAddress: leaderAddress, + LeaderAddress: leaderInfo.Address, FailureDomains: failureDomains, MemberFailureDomains: memberFailureDomains, OfflineThreshold: s.GlobalConfig.OfflineThreshold(), @@ -1489,11 +1507,15 @@ func clusterNodeGet(d *Daemon, r *http.Request) response.Response { return response.SmartError(err) } - leaderAddress, err := d.gateway.LeaderAddress() + leaderInfo, err := s.LeaderInfo() if err != nil { return response.InternalError(err) } + if !leaderInfo.Clustered { + return response.InternalError(cluster.ErrNodeIsNotClustered) + } + var raftNodes []db.RaftNode err = s.DB.Node.Transaction(r.Context(), func(ctx context.Context, tx *db.NodeTx) error { raftNodes, err = tx.GetRaftNodes(ctx) @@ -1530,7 +1552,7 @@ func clusterNodeGet(d *Daemon, r *http.Request) response.Response { } args := db.NodeInfoArgs{ - LeaderAddress: leaderAddress, + LeaderAddress: leaderInfo.Address, FailureDomains: failureDomains, MemberFailureDomains: memberFailureDomains, OfflineThreshold: s.GlobalConfig.OfflineThreshold(), @@ -1625,6 +1647,11 @@ func updateClusterNode(s *state.State, gateway *cluster.Gateway, r *http.Request return response.SmartError(err) } + resp := forwardedResponseToNode(s, r, name) + if resp != nil { + return resp + } + leaderAddress, err := gateway.LeaderAddress() if err != nil { return response.InternalError(err) @@ -1962,24 +1989,27 @@ func clusterNodeDelete(d *Daemon, r *http.Request) response.Response { } // Redirect all requests to the leader, which is the one with - // knowing what nodes are part of the raft cluster. + // knowledge of which nodes are part of the raft cluster. localClusterAddress := s.LocalConfig.ClusterAddress() - - leader, err := d.gateway.LeaderAddress() + leaderInfo, err := s.LeaderInfo() if err != nil { - return response.InternalError(err) + return response.SmartError(err) + } + + if !leaderInfo.Clustered { + return response.InternalError(cluster.ErrNodeIsNotClustered) } - var localInfo, leaderInfo db.NodeInfo + var localInfo, leaderNodeInfo db.NodeInfo err = s.DB.Cluster.Transaction(context.TODO(), func(ctx context.Context, tx *db.ClusterTx) error { localInfo, err = tx.GetNodeByAddress(ctx, localClusterAddress) if err != nil { return fmt.Errorf("Failed loading local member info %q: %w", localClusterAddress, err) } - leaderInfo, err = tx.GetNodeByAddress(ctx, leader) + leaderNodeInfo, err = tx.GetNodeByAddress(ctx, leaderInfo.Address) if err != nil { - return fmt.Errorf("Failed loading leader member info %q: %w", leader, err) + return fmt.Errorf("Failed loading leader member info %q: %w", leaderInfo.Address, err) } return nil @@ -1999,7 +2029,7 @@ func clusterNodeDelete(d *Daemon, r *http.Request) response.Response { return response.SmartError(fmt.Errorf("Unable to get raft nodes: %w", err)) } - if localClusterAddress != leader { + if !leaderInfo.Leader { if localInfo.Name == name { // If the member being removed is ourselves and we are not the leader, then lock the // clusterPutDisableMu before we forward the request to the leader, so that when the leader @@ -2016,8 +2046,8 @@ func clusterNodeDelete(d *Daemon, r *http.Request) response.Response { }() } - logger.Debugf("Redirect member delete request to %s", leader) - client, err := cluster.Connect(leader, s.Endpoints.NetworkCert(), s.ServerCert(), r, false) + logger.Debugf("Redirect member delete request to %s", leaderInfo.Address) + client, err := cluster.Connect(leaderInfo.Address, s.Endpoints.NetworkCert(), s.ServerCert(), r, false) if err != nil { return response.SmartError(err) } @@ -2029,7 +2059,7 @@ func clusterNodeDelete(d *Daemon, r *http.Request) response.Response { // If we are the only remaining node, wait until promotion to leader, // then update cluster certs. - if name == leaderInfo.Name && len(nodes) == 2 { + if name == leaderNodeInfo.Name && len(nodes) == 2 { err = d.gateway.WaitLeadership() if err != nil { return response.SmartError(err) @@ -2061,9 +2091,9 @@ func clusterNodeDelete(d *Daemon, r *http.Request) response.Response { defer d.clusterMembershipMutex.Unlock() // If we are removing the leader of a 2 node cluster, ensure the other node can be a leader. - if name == leaderInfo.Name && len(nodes) == 2 { + if name == leaderNodeInfo.Name && len(nodes) == 2 { for i := range nodes { - if nodes[i].Address != leader && nodes[i].Role != db.RaftVoter { + if nodes[i].Address != leaderInfo.Address && nodes[i].Role != db.RaftVoter { // Promote the remaining node. nodes[i].Role = db.RaftVoter err := changeMemberRole(s, r, nodes[i].Address, nodes) @@ -2407,25 +2437,23 @@ func internalClusterPostAccept(d *Daemon, r *http.Request) response.Response { } // Redirect all requests to the leader, which is the one with - // knowning what nodes are part of the raft cluster. - localClusterAddress := s.LocalConfig.ClusterAddress() - - leader, err := d.gateway.LeaderAddress() + // knowledge of which nodes are part of the raft cluster. + leaderInfo, err := s.LeaderInfo() if err != nil { return response.InternalError(err) } - if localClusterAddress != leader { - logger.Debugf("Redirect member accept request to %s", leader) + if !leaderInfo.Clustered { + return response.InternalError(cluster.ErrNodeIsNotClustered) + } - if leader == "" { - return response.SmartError(fmt.Errorf("Unable to find leader address")) - } + if !leaderInfo.Leader { + logger.Debugf("Redirect member accept request to %s", leaderInfo.Address) url := &url.URL{ Scheme: "https", Path: "/internal/cluster/accept", - Host: leader, + Host: leaderInfo.Address, } return response.SyncResponseRedirect(url.String()) @@ -2498,19 +2526,21 @@ func internalClusterPostRebalance(d *Daemon, r *http.Request) response.Response // Redirect all requests to the leader, which is the one with with // up-to-date knowledge of what nodes are part of the raft cluster. - localClusterAddress := s.LocalConfig.ClusterAddress() - - leader, err := d.gateway.LeaderAddress() + leaderInfo, err := s.LeaderInfo() if err != nil { return response.InternalError(err) } - if localClusterAddress != leader { - logger.Debugf("Redirect cluster rebalance request to %s", leader) + if !leaderInfo.Clustered { + return response.InternalError(cluster.ErrNodeIsNotClustered) + } + + if !leaderInfo.Leader { + logger.Debugf("Redirect cluster rebalance request to %s", leaderInfo.Address) url := &url.URL{ Scheme: "https", Path: "/internal/cluster/rebalance", - Host: leader, + Host: leaderInfo.Address, } return response.SyncResponseRedirect(url.String()) @@ -2643,16 +2673,12 @@ func handoverMemberRole(s *state.State, gateway *cluster.Gateway) error { // Find the cluster leader. findLeader: - leader, err := gateway.LeaderAddress() + leaderInfo, err := s.LeaderInfo() if err != nil { return err } - if leader == "" { - return fmt.Errorf("No leader address found") - } - - if leader == localClusterAddress { + if leaderInfo.Leader { logger.Info("Transferring leadership", logCtx) err := gateway.TransferLeadership() if err != nil { @@ -2663,7 +2689,7 @@ findLeader: } logger.Info("Handing over cluster member role", logCtx) - client, err := cluster.Connect(leader, s.Endpoints.NetworkCert(), s.ServerCert(), nil, true) + client, err := cluster.Connect(leaderInfo.Address, s.Endpoints.NetworkCert(), s.ServerCert(), nil, true) if err != nil { return fmt.Errorf("Failed handing over cluster member role: %w", err) } @@ -2733,21 +2759,17 @@ func internalClusterPostHandover(d *Daemon, r *http.Request) response.Response { // authoritative knowledge of the current raft configuration. localClusterAddress := s.LocalConfig.ClusterAddress() - leader, err := d.gateway.LeaderAddress() + leaderInfo, err := s.LeaderInfo() if err != nil { return response.InternalError(err) } - if leader == "" { - return response.SmartError(fmt.Errorf("No leader address found")) - } - - if localClusterAddress != leader { - logger.Debugf("Redirect handover request to %s", leader) + if !leaderInfo.Leader { + logger.Debugf("Redirect handover request to %s", leaderInfo.Address) url := &url.URL{ Scheme: "https", Path: "/internal/cluster/handover", - Host: leader, + Host: leaderInfo.Address, } return response.SyncResponseRedirect(url.String()) @@ -2965,7 +2987,7 @@ func clusterNodeStateGet(d *Daemon, r *http.Request) response.Response { return resp } - memberState, err := cluster.MemberState(r.Context(), s, memberName) + memberState, err := cluster.MemberState(r.Context(), s) if err != nil { return response.SmartError(err) } @@ -3341,7 +3363,9 @@ func evacuateInstances(ctx context.Context, opts evacuateOpts) error { return fmt.Errorf("Failed getting cluster members: %w", err) } - candidateMembers, err = tx.GetCandidateMembers(ctx, allMembers, []int{inst.Architecture()}, "", nil, opts.s.GlobalConfig.OfflineThreshold()) + clusterGroupsAllowed := limits.GetRestrictedClusterGroups(&instProject) + + candidateMembers, err = tx.GetCandidateMembers(ctx, allMembers, []int{inst.Architecture()}, "", clusterGroupsAllowed, opts.s.GlobalConfig.OfflineThreshold()) if err != nil { return err } @@ -3447,7 +3471,7 @@ func restoreClusterMember(d *Daemon, r *http.Request) response.Response { metadata := make(map[string]any) // Restart the networks. - err = networkStartup(d.State()) + err = networkStartup(d.State) if err != nil { return err } @@ -3668,13 +3692,13 @@ func clusterGroupsPost(d *Daemon, r *http.Request) response.Response { Nodes: req.Members, } - groupID, err := dbCluster.CreateClusterGroup(ctx, tx.Tx(), obj) + _, err := dbCluster.CreateClusterGroup(ctx, tx.Tx(), obj) if err != nil { return err } for _, node := range obj.Nodes { - _, err = dbCluster.CreateNodeClusterGroup(ctx, tx.Tx(), dbCluster.NodeClusterGroup{GroupID: int(groupID), Node: node}) + err = tx.AddNodeToClusterGroup(ctx, obj.Name, node) if err != nil { return err } @@ -4221,7 +4245,7 @@ func clusterGroupPatch(d *Daemon, r *http.Request) response.Response { } for _, node := range obj.Nodes { - _, err = dbCluster.CreateNodeClusterGroup(ctx, tx.Tx(), dbCluster.NodeClusterGroup{GroupID: int(groupID), Node: node}) + err = tx.AddNodeToClusterGroup(ctx, obj.Name, node) if err != nil { return err } @@ -4441,17 +4465,13 @@ func autoHealClusterTask(d *Daemon) (task.Func, task.Schedule) { return // Skip healing if it's disabled. } - leader, err := d.gateway.LeaderAddress() + leaderInfo, err := s.LeaderInfo() if err != nil { - if errors.Is(err, cluster.ErrNodeIsNotClustered) { - return // Skip healing if not clustered. - } - - logger.Error("Failed to get leader cluster member address", logger.Ctx{"err": err}) + logger.Error("Failed to determine cluster leader", logger.Ctx{"err": err}) return } - if s.LocalConfig.ClusterAddress() != leader { + if !leaderInfo.Clustered || !leaderInfo.Leader { return // Skip healing if not cluster leader. } diff --git a/lxd/api_internal.go b/lxd/api_internal.go index 135950e0ab0b..b5c06f48ca87 100644 --- a/lxd/api_internal.go +++ b/lxd/api_internal.go @@ -61,6 +61,7 @@ var apiInternal = []APIEndpoint{ internalSQLCmd, internalWarningCreateCmd, internalIdentityCacheRefreshCmd, + internalPruneTokenCmd, } var internalShutdownCmd = APIEndpoint{ @@ -136,6 +137,11 @@ var internalBGPStateCmd = APIEndpoint{ Get: APIEndpointAction{Handler: internalBGPState, AccessHandler: allowPermission(entity.TypeServer, auth.EntitlementCanEdit)}, } +var internalPruneTokenCmd = APIEndpoint{ + Path: "testing/prune-tokens", + Post: APIEndpointAction{Handler: removeTokenHandler, AccessHandler: allowPermission(entity.TypeServer, auth.EntitlementCanEdit)}, +} + var internalIdentityCacheRefreshCmd = APIEndpoint{ Path: "identity-cache-refresh", diff --git a/lxd/api_internal_recover.go b/lxd/api_internal_recover.go index 173f1b004bda..d02d07b48efe 100644 --- a/lxd/api_internal_recover.go +++ b/lxd/api_internal_recover.go @@ -104,6 +104,16 @@ func internalRecoverScan(s *state.State, userPools []api.StoragePoolsPost, valid return err } + profileConfigs, err := dbCluster.GetConfig(ctx, tx.Tx(), "profile") + if err != nil { + return err + } + + profileDevices, err := dbCluster.GetDevices(ctx, tx.Tx(), "profile") + if err != nil { + return err + } + // Convert to map for lookups by project name later. projectProfiles = make(map[string][]*api.Profile) for _, profile := range profiles { @@ -111,7 +121,7 @@ func internalRecoverScan(s *state.State, userPools []api.StoragePoolsPost, valid projectProfiles[profile.Project] = []*api.Profile{} } - apiProfile, err := profile.ToAPI(ctx, tx.Tx()) + apiProfile, err := profile.ToAPI(ctx, tx.Tx(), profileConfigs, profileDevices) if err != nil { return err } diff --git a/lxd/api_metrics.go b/lxd/api_metrics.go index 4b63abc02095..3a3bb8b0108b 100644 --- a/lxd/api_metrics.go +++ b/lxd/api_metrics.go @@ -14,6 +14,7 @@ import ( "github.com/canonical/lxd/lxd/auth" "github.com/canonical/lxd/lxd/db" dbCluster "github.com/canonical/lxd/lxd/db/cluster" + "github.com/canonical/lxd/lxd/db/warningtype" "github.com/canonical/lxd/lxd/instance" instanceDrivers "github.com/canonical/lxd/lxd/instance/drivers" "github.com/canonical/lxd/lxd/instance/instancetype" @@ -37,7 +38,8 @@ var metricsCache map[string]metricsCacheEntry var metricsCacheLock sync.Mutex var metricsCmd = APIEndpoint{ - Path: "metrics", + Path: "metrics", + MetricsType: entity.TypeServer, Get: APIEndpointAction{Handler: metricsGet, AccessHandler: allowMetrics, AllowUntrusted: true}, } @@ -128,7 +130,7 @@ func metricsGet(d *Daemon, r *http.Request) response.Response { } // Register internal metrics. - intMetrics = internalMetrics(ctx, s.StartTime, tx) + intMetrics = internalMetrics(ctx, s, tx) return nil }) if err != nil { @@ -167,6 +169,7 @@ func metricsGet(d *Daemon, r *http.Request) response.Response { // If all valid, return immediately. if len(projectsToFetch) == 0 { + metricSet.Merge(intMetrics) return getFilteredMetrics(s, r, compress, metricSet) } @@ -192,6 +195,7 @@ func metricsGet(d *Daemon, r *http.Request) response.Response { // If all valid, return immediately. if len(projectsToFetch) == 0 { + metricSet.Merge(intMetrics) return getFilteredMetrics(s, r, compress, metricSet) } @@ -315,10 +319,6 @@ func metricsGet(d *Daemon, r *http.Request) response.Response { updatedProjects := []string{} for project, entries := range newMetrics { - if project == api.ProjectDefaultName { - entries.Merge(intMetrics) // internal metrics are always considered new. Add them to the default project. - } - counterMetric, ok := counterMetrics[project] if ok { entries.Merge(counterMetric) @@ -345,6 +345,8 @@ func metricsGet(d *Daemon, r *http.Request) response.Response { metricsCacheLock.Unlock() + metricSet.Merge(intMetrics) // Include the internal metrics after caching so they are not cached. + return getFilteredMetrics(s, r, compress, metricSet) } @@ -377,10 +379,39 @@ func getFilteredMetrics(s *state.State, r *http.Request, compress bool, metricSe return response.SyncResponsePlain(true, compress, metricSet.String()) } -func internalMetrics(ctx context.Context, daemonStartTime time.Time, tx *db.ClusterTx) *metrics.MetricSet { +// clusterMemberWarnings returns the list of unresolved and unacknowledged warnings related to this cluster member. +// If this member is the leader, also include nodeless warnings. +// This way we include them while avoiding counting them redundantly across cluster members. +func clusterMemberWarnings(ctx context.Context, s *state.State, tx *db.ClusterTx) ([]dbCluster.Warning, error) { + var filters []dbCluster.WarningFilter + + leaderInfo, err := s.LeaderInfo() + if err != nil { + return nil, err + } + + // Use local variable to get pointer. + emptyNode := "" + + for status := range warningtype.Statuses { + // Do not include resolved warnings that are resolved but not yet pruned neither those that were acknowledged. + if status != warningtype.StatusResolved && status != warningtype.StatusAcknowledged { + filters = append(filters, dbCluster.WarningFilter{Node: &s.ServerName, Status: &status}) + if leaderInfo.Leader { + // Count the nodeless warnings as belonging to the leader node. + filters = append(filters, dbCluster.WarningFilter{Node: &emptyNode, Status: &status}) + } + } + } + + return dbCluster.GetWarnings(ctx, tx.Tx(), filters...) +} + +func internalMetrics(ctx context.Context, s *state.State, tx *db.ClusterTx) *metrics.MetricSet { out := metrics.NewMetricSet(nil) - warnings, err := dbCluster.GetWarnings(ctx, tx.Tx()) + warnings, err := clusterMemberWarnings(ctx, s, tx) + if err != nil { logger.Warn("Failed to get warnings", logger.Ctx{"err": err}) } else { @@ -388,7 +419,9 @@ func internalMetrics(ctx context.Context, daemonStartTime time.Time, tx *db.Clus out.AddSamples(metrics.WarningsTotal, metrics.Sample{Value: float64(len(warnings))}) } - operations, err := dbCluster.GetOperations(ctx, tx.Tx()) + // Create local variable to get a pointer. + nodeID := tx.GetNodeID() + operations, err := dbCluster.GetOperations(ctx, tx.Tx(), dbCluster.OperationFilter{NodeID: &nodeID}) if err != nil { logger.Warn("Failed to get operations", logger.Ctx{"err": err}) } else { @@ -396,8 +429,29 @@ func internalMetrics(ctx context.Context, daemonStartTime time.Time, tx *db.Clus out.AddSamples(metrics.OperationsTotal, metrics.Sample{Value: float64(len(operations))}) } + // API request metrics + for _, entityType := range entity.APIMetricsEntityTypes() { + out.AddSamples( + metrics.APIOngoingRequests, + metrics.Sample{ + Labels: map[string]string{"entity_type": entityType.String()}, + Value: float64(metrics.GetOngoingRequests(entityType)), + }, + ) + + for result, resultName := range metrics.GetRequestResultsNames() { + out.AddSamples( + metrics.APICompletedRequests, + metrics.Sample{ + Labels: map[string]string{"entity_type": entityType.String(), "result": resultName}, + Value: float64(metrics.GetCompletedRequests(entityType, result)), + }, + ) + } + } + // Daemon uptime - out.AddSamples(metrics.UptimeSeconds, metrics.Sample{Value: time.Since(daemonStartTime).Seconds()}) + out.AddSamples(metrics.UptimeSeconds, metrics.Sample{Value: time.Since(s.StartTime).Seconds()}) // Number of goroutines out.AddSamples(metrics.GoGoroutines, metrics.Sample{Value: float64(runtime.NumGoroutine())}) diff --git a/lxd/api_project.go b/lxd/api_project.go index d4b941762226..15ed0568ba49 100644 --- a/lxd/api_project.go +++ b/lxd/api_project.go @@ -34,14 +34,16 @@ import ( ) var projectsCmd = APIEndpoint{ - Path: "projects", + Path: "projects", + MetricsType: entity.TypeProject, Get: APIEndpointAction{Handler: projectsGet, AccessHandler: allowAuthenticated}, Post: APIEndpointAction{Handler: projectsPost, AccessHandler: allowPermission(entity.TypeServer, auth.EntitlementCanCreateProjects)}, } var projectCmd = APIEndpoint{ - Path: "projects/{name}", + Path: "projects/{name}", + MetricsType: entity.TypeProject, Delete: APIEndpointAction{Handler: projectDelete, AccessHandler: allowPermission(entity.TypeProject, auth.EntitlementCanDelete, "name")}, Get: APIEndpointAction{Handler: projectGet, AccessHandler: allowPermission(entity.TypeProject, auth.EntitlementCanView, "name")}, @@ -51,7 +53,8 @@ var projectCmd = APIEndpoint{ } var projectStateCmd = APIEndpoint{ - Path: "projects/{name}/state", + Path: "projects/{name}/state", + MetricsType: entity.TypeProject, Get: APIEndpointAction{Handler: projectStateGet, AccessHandler: allowPermission(entity.TypeProject, auth.EntitlementCanView, "name")}, } @@ -1380,6 +1383,37 @@ func projectValidateConfig(s *state.State, config map[string]string) error { "restricted.snapshots": isEitherAllowOrBlock, } + // Add the storage pool keys. + err := s.DB.Cluster.Transaction(context.TODO(), func(ctx context.Context, tx *db.ClusterTx) error { + var err error + + // Load all the pools. + pools, err := tx.GetStoragePoolNames(ctx) + if err != nil { + return err + } + + // Add the storage-pool specific config keys. + for _, poolName := range pools { + // lxdmeta:generate(entities=project; group=limits; key=limits.disk.pool.POOL_NAME) + // This value is the maximum value of the aggregate disk + // space used by all instance volumes, custom volumes, and images of the + // project on this specific storage pool. + // + // When set to 0, the pool is excluded from storage pool list for + // the project. + // --- + // type: string + // shortdesc: Maximum disk space used by the project on this pool + projectConfigKeys[fmt.Sprintf("limits.disk.pool.%s", poolName)] = validate.Optional(validate.IsSize) + } + + return nil + }) + if err != nil && !api.StatusErrorCheck(err, http.StatusNotFound) { + return fmt.Errorf("Failed loading storage pool names: %w", err) + } + for k, v := range config { key := k diff --git a/lxd/apparmor/instance.go b/lxd/apparmor/instance.go index 71c54d34fbb7..d897ef86b3a9 100644 --- a/lxd/apparmor/instance.go +++ b/lxd/apparmor/instance.go @@ -26,6 +26,12 @@ type instance interface { DevicesPath() string } +type instanceVM interface { + instance + + FirmwarePath() string +} + // InstanceProfileName returns the instance's AppArmor profile name. func InstanceProfileName(inst instance) string { path := shared.VarPath("") @@ -195,9 +201,18 @@ func instanceProfile(sysOS *sys.OS, inst instance) (string, error) { return "", err } - qemuFwPathsArr, err := util.GetQemuFwPaths() - if err != nil { - return "", err + vmInst, ok := inst.(instanceVM) + if !ok { + return "", fmt.Errorf("Instance is not VM type") + } + + // Get start time firmware path to allow access to it. + firmwarePath := vmInst.FirmwarePath() + if firmwarePath != "" { + firmwarePath, err = filepath.EvalSymlinks(firmwarePath) + if err != nil { + return "", fmt.Errorf("Failed finding firmware: %w", err) + } } execPath := util.GetExecPath() @@ -217,7 +232,7 @@ func instanceProfile(sysOS *sys.OS, inst instance) (string, error) { "rootPath": rootPath, "snap": shared.InSnap(), "userns": sysOS.RunningInUserNS, - "qemuFwPaths": qemuFwPathsArr, + "firmwarePath": firmwarePath, "snapExtQemuPrefix": os.Getenv("SNAP_QEMU_PREFIX"), }) if err != nil { diff --git a/lxd/apparmor/instance_forkproxy.go b/lxd/apparmor/instance_forkproxy.go index 919a34537990..15c3d1141333 100644 --- a/lxd/apparmor/instance_forkproxy.go +++ b/lxd/apparmor/instance_forkproxy.go @@ -80,6 +80,7 @@ profile "{{ .name }}" flags=(attach_disconnected,mediate_deleted) { # The binary itself (for nesting) /var/snap/lxd/common/lxd.debug mr, /snap/lxd/*/bin/lxd mr, + /snap/lxd/*/sbin/lxd mr, # Snap-specific libraries /snap/lxd/*/lib/**.so* mr, diff --git a/lxd/apparmor/instance_qemu.go b/lxd/apparmor/instance_qemu.go index 1d7b9bed5073..e5243e61e5de 100644 --- a/lxd/apparmor/instance_qemu.go +++ b/lxd/apparmor/instance_qemu.go @@ -73,6 +73,7 @@ profile "{{ .name }}" flags=(attach_disconnected,mediate_deleted) { # The binary itself (for nesting) /var/snap/lxd/common/lxd.debug mr, /snap/lxd/*/bin/lxd mr, + /snap/lxd/*/sbin/lxd mr, /snap/lxd/*/bin/qemu-system-* mrix, /snap/lxd/*/share/qemu/** kr, @@ -102,13 +103,9 @@ profile "{{ .name }}" flags=(attach_disconnected,mediate_deleted) { {{- end }} {{- end }} -{{if .qemuFwPaths -}} - # Entries from LXD_OVMF_PATH or LXD_QEMU_FW_PATH -{{range $index, $element := .qemuFwPaths}} - {{$element}}/OVMF_CODE.fd kr, - {{$element}}/OVMF_CODE.*.fd kr, - {{$element}}/*bios*.bin kr, -{{- end }} +{{if .firmwarePath -}} + # Firmware path + {{ .firmwarePath }} kr, {{- end }} {{- if .raw }} diff --git a/lxd/apparmor/network_forkdns.go b/lxd/apparmor/network_forkdns.go index 45e14d88329d..fc644a1970ed 100644 --- a/lxd/apparmor/network_forkdns.go +++ b/lxd/apparmor/network_forkdns.go @@ -44,6 +44,7 @@ profile "{{ .name }}" flags=(attach_disconnected,mediate_deleted) { # The binary itself (for nesting) /var/snap/lxd/common/lxd.debug mr, /snap/lxd/*/bin/lxd mr, + /snap/lxd/*/sbin/lxd mr, # Snap-specific libraries /snap/lxd/*/lib/**.so* mr, diff --git a/lxd/auth/drivers/openfga.go b/lxd/auth/drivers/openfga.go index 4ad2a27c786a..1edf18b1d480 100644 --- a/lxd/auth/drivers/openfga.go +++ b/lxd/auth/drivers/openfga.go @@ -134,11 +134,6 @@ func (e *embeddedOpenFGA) CheckPermission(ctx context.Context, entityURL *api.UR return fmt.Errorf("Failed to parse entity URL: %w", err) } - err = auth.ValidateEntitlement(entityType, entitlement) - if err != nil { - return fmt.Errorf("Cannot check permissions for entity type %q and entitlement %q: %w", entityType, entitlement, err) - } - logCtx := logger.Ctx{"entity_url": entityURL.String(), "entitlement": entitlement} ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() @@ -167,8 +162,8 @@ func (e *embeddedOpenFGA) CheckPermission(ctx context.Context, entityURL *api.UR logCtx["protocol"] = id.AuthenticationMethod l := e.logger.AddContext(logCtx) - // If the authentication method was TLS, use the TLS driver instead. - if id.AuthenticationMethod == api.AuthenticationMethodTLS { + // If the identity type does not use fine-grained auth use the TLS driver instead. + if !identity.IsFineGrainedIdentityType(id.IdentityType) { return e.tlsAuthorizer.CheckPermission(ctx, entityURL, entitlement) } @@ -220,13 +215,19 @@ func (e *embeddedOpenFGA) CheckPermission(ctx context.Context, entityURL *api.UR Object: entityObject, }, ContextualTuples: &openfgav1.ContextualTupleKeys{ - // Users can always view (but not edit) themselves. TupleKeys: []*openfgav1.TupleKey{ { + // Users can always view (but not edit) themselves. User: userObject, Relation: string(auth.EntitlementCanView), Object: userObject, }, + { + // Users can always delete (but not edit) themselves. + User: userObject, + Relation: string(auth.EntitlementCanDelete), + Object: userObject, + }, }, }, } @@ -244,6 +245,12 @@ func (e *embeddedOpenFGA) CheckPermission(ctx context.Context, entityURL *api.UR l.Debug("Checking OpenFGA relation") resp, err := e.server.Check(ctx, req) if err != nil { + // If we have a not found error from the underlying OpenFGADatastore we should mask it to make requests consistent. + // (all not found errors returned before an access control decision is made are masked to prevent discovery). + if api.StatusErrorCheck(err, http.StatusNotFound) { + return api.NewGenericStatusError(http.StatusNotFound) + } + // Attempt to extract the internal error. This allows bubbling errors up from the OpenFGA datastore implementation. // (Otherwise we just get "rpc error (4000): Internal Server Error" or similar which isn't useful). var openFGAInternalError openFGAErrors.InternalError @@ -269,6 +276,12 @@ func (e *embeddedOpenFGA) CheckPermission(ctx context.Context, entityURL *api.UR l.Debug("Checking OpenFGA relation") resp, err := e.server.Check(ctx, req) if err != nil { + // If we have a not found error from the underlying OpenFGADatastore we should mask it to make requests consistent. + // (all not found errors returned before an access control decision is made are masked to prevent discovery). + if api.StatusErrorCheck(err, http.StatusNotFound) { + return api.NewGenericStatusError(http.StatusNotFound) + } + // Attempt to extract the internal error. This allows bubbling errors up from the OpenFGA datastore implementation. // (Otherwise we just get "rpc error (4000): Internal Server Error" or similar which isn't useful). var openFGAInternalError openFGAErrors.InternalError @@ -306,11 +319,6 @@ func (e *embeddedOpenFGA) CheckPermission(ctx context.Context, entityURL *api.UR // this function is called. The returned auth.PermissionChecker will expect entity URLs to contain the request URL. These // will be re-written to contain the effective project if set, so that they correspond to the list returned by OpenFGA. func (e *embeddedOpenFGA) GetPermissionChecker(ctx context.Context, entitlement auth.Entitlement, entityType entity.Type) (auth.PermissionChecker, error) { - err := auth.ValidateEntitlement(entityType, entitlement) - if err != nil { - return nil, fmt.Errorf("Cannot get a permission checker for entity type %q and entitlement %q: %w", entityType, entitlement, err) - } - logCtx := logger.Ctx{"entity_type": entityType, "entitlement": entitlement} ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() @@ -359,8 +367,8 @@ func (e *embeddedOpenFGA) GetPermissionChecker(ctx context.Context, entitlement logCtx["protocol"] = id.AuthenticationMethod l := e.logger.AddContext(logCtx) - // If the authentication method was TLS, use the TLS driver instead. - if id.AuthenticationMethod == api.AuthenticationMethodTLS { + // If the identity type does not use fine-grained auth, use the TLS driver instead. + if !identity.IsFineGrainedIdentityType(id.IdentityType) { return e.tlsAuthorizer.GetPermissionChecker(ctx, entitlement, entityType) } @@ -394,13 +402,19 @@ func (e *embeddedOpenFGA) GetPermissionChecker(ctx context.Context, entitlement Relation: string(entitlement), User: userObject, ContextualTuples: &openfgav1.ContextualTupleKeys{ - // Users can always view (but not edit) themselves. TupleKeys: []*openfgav1.TupleKey{ { + // Users can always view (but not edit) themselves. User: userObject, Relation: string(auth.EntitlementCanView), Object: userObject, }, + { + // Users can always delete (but not edit) themselves. + User: userObject, + Relation: string(auth.EntitlementCanDelete), + Object: userObject, + }, }, }, } diff --git a/lxd/auth/drivers/openfga_model.openfga b/lxd/auth/drivers/openfga_model.openfga index ddd27c9b8f49..be63c2f13f11 100644 --- a/lxd/auth/drivers/openfga_model.openfga +++ b/lxd/auth/drivers/openfga_model.openfga @@ -136,6 +136,9 @@ type server # Grants permission to view warnings. define can_view_warnings: [identity, service_account, group#member] or admin or viewer + + # Grants permission to view unmanaged networks on the LXD host machines. + define can_view_unmanaged_networks: [identity, service_account, group#member] or admin or viewer type certificate relations define server: [server] @@ -363,7 +366,7 @@ type instance # Grants permission to delete the instance. define can_delete: [identity, service_account, group#member] or can_delete_instances from project - # Grants permission to view the instance. + # Grants permission to view the instance and any snapshots or backups it might have. define can_view: [identity, service_account, group#member] or user or operator or can_edit or can_delete or can_view_instances from project # Grants permission to change the instance state. @@ -386,6 +389,21 @@ type instance # Grants permission to start a terminal session. define can_exec: [identity, service_account, group#member] or user or operator or can_operate_instances from project + +type instance_snapshot + relations + define instance: [instance] + define can_view: can_view from instance + define can_edit: can_manage_snapshots from instance + define can_delete: can_manage_snapshots from instance + +type instance_backup + relations + define instance: [instance] + define can_view: can_view from instance + define can_edit: can_manage_backups from instance + define can_delete: can_manage_backups from instance + type network relations define project: [project] @@ -444,7 +462,7 @@ type storage_volume # Grants permission to delete the storage volume. define can_delete: [identity, service_account, group#member] or can_delete_storage_volumes from project - # Grants permission to view the storage volume. + # Grants permission to view the storage volume and any snapshots or backups it might have. define can_view: [identity, service_account, group#member] or can_edit or can_delete or can_view_storage_volumes from project # Grants permission to create and delete snapshots of the storage volume. @@ -452,6 +470,21 @@ type storage_volume # Grants permission to create and delete backups of the storage volume. define can_manage_backups: [identity, service_account, group#member] or can_edit_storage_volumes from project + +type storage_volume_snapshot + relations + define storage_volume: [storage_volume] + define can_view: can_view from storage_volume + define can_edit: can_manage_snapshots from storage_volume + define can_delete: can_manage_snapshots from storage_volume + +type storage_volume_backup + relations + define storage_volume: [storage_volume] + define can_view: can_view from storage_volume + define can_edit: can_manage_backups from storage_volume + define can_delete: can_manage_backups from storage_volume + type storage_bucket relations define project: [project] diff --git a/lxd/auth/drivers/tls.go b/lxd/auth/drivers/tls.go index bac5b2b98759..c5f8c2029953 100644 --- a/lxd/auth/drivers/tls.go +++ b/lxd/auth/drivers/tls.go @@ -164,7 +164,7 @@ func (t *tls) allowProjectUnspecificEntityType(entitlement auth.Entitlement, ent switch entityType { case entity.TypeServer: // Restricted TLS certificates have the following entitlements on server. - return shared.ValueInSlice(entitlement, []auth.Entitlement{auth.EntitlementCanViewResources, auth.EntitlementCanViewMetrics}) + return shared.ValueInSlice(entitlement, []auth.Entitlement{auth.EntitlementCanViewResources, auth.EntitlementCanViewMetrics, auth.EntitlementCanViewUnmanagedNetworks}) case entity.TypeIdentity: // If the entity URL refers to the identity that made the request, then the second path argument of the URL is // the identifier of the identity. This line allows the caller to view their own identity and no one else's. diff --git a/lxd/auth/entitlements_generated.go b/lxd/auth/entitlements_generated.go index 6b4e5ae86c96..942319447883 100644 --- a/lxd/auth/entitlements_generated.go +++ b/lxd/auth/entitlements_generated.go @@ -109,6 +109,9 @@ const ( // EntitlementCanViewWarnings is the "can_view_warnings" entitlement. It applies to the following entities: entity.TypeServer. EntitlementCanViewWarnings Entitlement = "can_view_warnings" + // EntitlementCanViewUnmanagedNetworks is the "can_view_unmanaged_networks" entitlement. It applies to the following entities: entity.TypeServer. + EntitlementCanViewUnmanagedNetworks Entitlement = "can_view_unmanaged_networks" + // EntitlementOperator is the "operator" entitlement. It applies to the following entities: entity.TypeInstance, entity.TypeProject. EntitlementOperator Entitlement = "operator" @@ -339,7 +342,7 @@ var EntityTypeToEntitlements = map[entity.Type][]Entitlement{ EntitlementCanEdit, // Grants permission to delete the instance. EntitlementCanDelete, - // Grants permission to view the instance. + // Grants permission to view the instance and any snapshots or backups it might have. EntitlementCanView, // Grants permission to change the instance state. EntitlementCanUpdateState, @@ -561,6 +564,8 @@ var EntityTypeToEntitlements = map[entity.Type][]Entitlement{ EntitlementCanViewMetrics, // Grants permission to view warnings. EntitlementCanViewWarnings, + // Grants permission to view unmanaged networks on the LXD host machines. + EntitlementCanViewUnmanagedNetworks, }, entity.TypeStorageBucket: { // Grants permission to edit the storage bucket. @@ -581,7 +586,7 @@ var EntityTypeToEntitlements = map[entity.Type][]Entitlement{ EntitlementCanEdit, // Grants permission to delete the storage volume. EntitlementCanDelete, - // Grants permission to view the storage volume. + // Grants permission to view the storage volume and any snapshots or backups it might have. EntitlementCanView, // Grants permission to create and delete snapshots of the storage volume. EntitlementCanManageSnapshots, diff --git a/lxd/auth_groups.go b/lxd/auth_groups.go index c5c27a432cf5..917a7dbfe89b 100644 --- a/lxd/auth_groups.go +++ b/lxd/auth_groups.go @@ -27,8 +27,9 @@ import ( ) var authGroupsCmd = APIEndpoint{ - Name: "auth_groups", - Path: "auth/groups", + Name: "auth_groups", + Path: "auth/groups", + MetricsType: entity.TypeIdentity, Get: APIEndpointAction{ Handler: getAuthGroups, AccessHandler: allowAuthenticated, @@ -40,8 +41,9 @@ var authGroupsCmd = APIEndpoint{ } var authGroupCmd = APIEndpoint{ - Name: "auth_group", - Path: "auth/groups/{groupName}", + Name: "auth_group", + Path: "auth/groups/{groupName}", + MetricsType: entity.TypeIdentity, Get: APIEndpointAction{ Handler: getAuthGroup, AccessHandler: allowPermission(entity.TypeAuthGroup, auth.EntitlementCanView, "groupName"), @@ -711,7 +713,7 @@ func renameAuthGroup(d *Daemon, r *http.Request) response.Response { return response.SmartError(err) } - err = notifier(func(client lxd.InstanceServer) error { + err = notifier(func(member db.NodeInfo, client lxd.InstanceServer) error { _, _, err := client.RawQuery(http.MethodPost, "/internal/identity-cache-refresh", nil, "") return err }) @@ -772,7 +774,7 @@ func deleteAuthGroup(d *Daemon, r *http.Request) response.Response { return response.SmartError(err) } - err = notifier(func(client lxd.InstanceServer) error { + err = notifier(func(member db.NodeInfo, client lxd.InstanceServer) error { _, _, err := client.RawQuery(http.MethodPost, "/internal/identity-cache-refresh", nil, "") return err }) diff --git a/lxd/backup/backup_config_utils.go b/lxd/backup/backup_config_utils.go index 61f5d47de01c..5a12622b087b 100644 --- a/lxd/backup/backup_config_utils.go +++ b/lxd/backup/backup_config_utils.go @@ -50,8 +50,20 @@ func ConfigToInstanceDBArgs(state *state.State, c *config.Config, projectName st return err } + // Get all the profile configs. + profileConfigs, err := cluster.GetConfig(ctx, tx.Tx(), "profile") + if err != nil { + return err + } + + // Get all the profile devices. + profileDevices, err := cluster.GetDevices(ctx, tx.Tx(), "profile") + if err != nil { + return err + } + for _, profile := range profiles { - apiProfile, err := profile.ToAPI(ctx, tx.Tx()) + apiProfile, err := profile.ToAPI(ctx, tx.Tx(), profileConfigs, profileDevices) if err != nil { return err } diff --git a/lxd/certificates.go b/lxd/certificates.go index 416c27dd394e..0e219590c466 100644 --- a/lxd/certificates.go +++ b/lxd/certificates.go @@ -39,14 +39,16 @@ import ( ) var certificatesCmd = APIEndpoint{ - Path: "certificates", + Path: "certificates", + MetricsType: entity.TypeCertificate, Get: APIEndpointAction{Handler: certificatesGet, AccessHandler: allowAuthenticated}, Post: APIEndpointAction{Handler: certificatesPost, AllowUntrusted: true}, } var certificateCmd = APIEndpoint{ - Path: "certificates/{fingerprint}", + Path: "certificates/{fingerprint}", + MetricsType: entity.TypeCertificate, Delete: APIEndpointAction{Handler: certificateDelete, AccessHandler: allowAuthenticated}, Get: APIEndpointAction{Handler: certificateGet, AccessHandler: allowAuthenticated}, @@ -292,6 +294,15 @@ func certificateTokenValid(s *state.State, r *http.Request, addToken *api.Certif } if foundOp == nil { + err := s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error { + var err error + _, err = dbCluster.GetPendingTLSIdentityByTokenSecret(ctx, tx.Tx(), addToken.Secret) + return err + }) + if err == nil { + return nil, api.NewStatusError(http.StatusBadRequest, "TLS Identity token detected (you must update your client)") + } + // No operation found. return nil, nil } @@ -555,7 +566,7 @@ func certificatesPost(d *Daemon, r *http.Request) response.Response { // If so then check there is a matching join operation. tokenReq, err := certificateTokenValid(s, r, joinToken) if err != nil { - return response.InternalError(fmt.Errorf("Failed during search for certificate add token operation: %w", err)) + return response.SmartError(fmt.Errorf("Failed during search for certificate add token operation: %w", err)) } if tokenReq == nil { @@ -660,20 +671,13 @@ func certificatesPost(d *Daemon, r *http.Request) response.Response { } cert = r.TLS.PeerCertificates[len(r.TLS.PeerCertificates)-1] - networkCert := s.Endpoints.NetworkCert() - if networkCert.CA() != nil { - // If we are in CA mode, we only allow adding certificates that are signed by the CA. - trusted, _, _ := util.CheckCASignature(*cert, networkCert) - if !trusted { - return response.Forbidden(fmt.Errorf("The certificate is not trusted by the CA or has been revoked")) - } - } } else { return response.BadRequest(fmt.Errorf("Can't use TLS data on non-TLS link")) } // Check validity. - err = certificateValidate(cert) + networkCert := d.endpoints.NetworkCert() + err = certificateValidate(networkCert, cert) if err != nil { return response.BadRequest(err) } @@ -721,7 +725,7 @@ func certificatesPost(d *Daemon, r *http.Request) response.Response { } // Send a notification to other cluster members to refresh their identity cache. - notifier, err := cluster.NewNotifier(s, s.Endpoints.NetworkCert(), s.ServerCert(), cluster.NotifyAlive) + notifier, err := cluster.NewNotifier(s, networkCert, s.ServerCert(), cluster.NotifyAlive) if err != nil { return response.SmartError(err) } @@ -732,7 +736,7 @@ func certificatesPost(d *Daemon, r *http.Request) response.Response { Type: api.CertificateTypeClient, } - err = notifier(func(client lxd.InstanceServer) error { + err = notifier(func(member db.NodeInfo, client lxd.InstanceServer) error { _, _, err := client.RawQuery(http.MethodPost, "/internal/identity-cache-refresh", nil, "") return err }) @@ -1029,6 +1033,7 @@ func doCertificateUpdate(ctx context.Context, d *Daemon, dbInfo api.Certificate, } } + networkCert := d.endpoints.NetworkCert() if req.Certificate != "" && dbInfo.Certificate != req.Certificate { // Add supplied certificate. block, _ := pem.Decode([]byte(req.Certificate)) @@ -1042,7 +1047,7 @@ func doCertificateUpdate(ctx context.Context, d *Daemon, dbInfo api.Certificate, dbCert.Fingerprint = shared.CertFingerprint(cert) // Check validity. - err = certificateValidate(cert) + err = certificateValidate(networkCert, cert) if err != nil { return response.BadRequest(err) } @@ -1067,12 +1072,12 @@ func doCertificateUpdate(ctx context.Context, d *Daemon, dbInfo api.Certificate, } // Notify other cluster members to update their identity cache. - notifier, err := cluster.NewNotifier(s, s.Endpoints.NetworkCert(), s.ServerCert(), cluster.NotifyAlive) + notifier, err := cluster.NewNotifier(s, networkCert, s.ServerCert(), cluster.NotifyAlive) if err != nil { return response.SmartError(err) } - err = notifier(func(client lxd.InstanceServer) error { + err = notifier(func(member db.NodeInfo, client lxd.InstanceServer) error { _, _, err := client.RawQuery(http.MethodPost, "/internal/identity-cache-refresh", nil, "") return err }) @@ -1183,7 +1188,7 @@ func certificateDelete(d *Daemon, r *http.Request) response.Response { return response.SmartError(err) } - err = notifier(func(client lxd.InstanceServer) error { + err = notifier(func(member db.NodeInfo, client lxd.InstanceServer) error { _, _, err := client.RawQuery(http.MethodPost, "/internal/identity-cache-refresh", nil, "") return err }) @@ -1199,13 +1204,21 @@ func certificateDelete(d *Daemon, r *http.Request) response.Response { return response.EmptySyncResponse } -func certificateValidate(cert *x509.Certificate) error { +func certificateValidate(networkCert *shared.CertInfo, cert *x509.Certificate) error { if time.Now().Before(cert.NotBefore) { - return fmt.Errorf("The provided certificate isn't valid yet") + return api.NewStatusError(http.StatusBadRequest, "The provided certificate isn't valid yet") } if time.Now().After(cert.NotAfter) { - return fmt.Errorf("The provided certificate is expired") + return api.NewStatusError(http.StatusBadRequest, "The provided certificate is expired") + } + + if networkCert != nil && networkCert.CA() != nil { + // If we are in CA mode, we only allow adding certificates that are signed by the CA. + trusted, _, _ := util.CheckCASignature(*cert, networkCert) + if !trusted { + return api.NewStatusError(http.StatusForbidden, "The certificate is not trusted by the CA or has been revoked") + } } if cert.PublicKeyAlgorithm == x509.RSA { @@ -1216,7 +1229,7 @@ func certificateValidate(cert *x509.Certificate) error { // Check that we're dealing with at least 2048bit (Size returns a value in bytes). if pubKey.Size()*8 < 2048 { - return fmt.Errorf("RSA key is too weak (minimum of 2048bit)") + return api.NewStatusError(http.StatusBadRequest, "RSA key is too weak (minimum of 2048bit)") } } diff --git a/lxd/cgroup/cgroup_cpu.go b/lxd/cgroup/cgroup_cpu.go index 593eb2f21581..e81ed919a8ca 100644 --- a/lxd/cgroup/cgroup_cpu.go +++ b/lxd/cgroup/cgroup_cpu.go @@ -4,25 +4,25 @@ import ( "fmt" "strconv" "strings" + + "github.com/canonical/lxd/lxd/instance/instancetype" ) // DeviceSchedRebalance channel for scheduling a CPU rebalance. var DeviceSchedRebalance = make(chan []string, 2) // TaskSchedulerTrigger triggers a CPU rebalance. -func TaskSchedulerTrigger(srcType string, srcName string, srcStatus string) { +func TaskSchedulerTrigger(srcType instancetype.Type, srcName string, srcStatus string) { // Spawn a go routine which then triggers the scheduler select { - case DeviceSchedRebalance <- []string{srcType, srcName, srcStatus}: + case DeviceSchedRebalance <- []string{srcType.String(), srcName, srcStatus}: default: // Channel is full, drop the event } } // ParseCPU parses CPU allowances. -func ParseCPU(cpuAllowance string, cpuPriority string) (int64, int64, int64, error) { - var err error - +func ParseCPU(cpuAllowance string, cpuPriority string) (cpuShares int64, cpuCfsQuota int64, cpuCfsPeriod int64, err error) { // Max shares depending on backend. maxShares := int64(1024) if cgControllers["cpu"] == V2 { @@ -30,7 +30,7 @@ func ParseCPU(cpuAllowance string, cpuPriority string) (int64, int64, int64, err } // Parse priority - cpuShares := int64(0) + cpuShares = 0 cpuPriorityInt := 10 if cpuPriority != "" { cpuPriorityInt, err = strconv.Atoi(cpuPriority) @@ -41,8 +41,8 @@ func ParseCPU(cpuAllowance string, cpuPriority string) (int64, int64, int64, err cpuShares -= int64(10 - cpuPriorityInt) // Parse allowance - cpuCfsQuota := int64(-1) - cpuCfsPeriod := int64(100000) + cpuCfsQuota = -1 + cpuCfsPeriod = 100000 if cgControllers["cpu"] == V2 { cpuCfsPeriod = -1 } diff --git a/lxd/cluster/gateway.go b/lxd/cluster/gateway.go index 8b15db597f69..20770a28299c 100644 --- a/lxd/cluster/gateway.go +++ b/lxd/cluster/gateway.go @@ -517,7 +517,7 @@ func (g *Gateway) DemoteOfflineNode(raftID uint64) error { // Shutdown this gateway, stopping the gRPC server and possibly the raft factory. func (g *Gateway) Shutdown() error { - logger.Infof("Stop database gateway") + logger.Info("Stop database gateway") var err error if g.server != nil { @@ -533,6 +533,18 @@ func (g *Gateway) Shutdown() error { g.lock.Lock() g.memoryDial = nil g.lock.Unlock() + + // Record the raft term and index in the logs on every shutdown. This + // allows an administrator to determine the furthest-ahead cluster member + // in case recovery is needed. + lastEntryInfo, err := dqlite.ReadLastEntryInfo(g.db.Dir()) + if err != nil { + return err + } + + // This isn't really a warning, but it's important that this break through + // the snap's default log level of 'Warn'. + logger.Warn("Dqlite last entry", logger.Ctx{"term": lastEntryInfo.Term, "index": lastEntryInfo.Index}) } return err @@ -565,7 +577,7 @@ func (g *Gateway) Sync() { return } - dir := filepath.Join(g.db.Dir(), "global") + dir := g.db.DqliteDir() for _, file := range files { path := filepath.Join(dir, file.Name) err := os.WriteFile(path, file.Data, 0600) @@ -588,7 +600,7 @@ func (g *Gateway) Reset(networkCert *shared.CertInfo) error { return err } - err = os.RemoveAll(filepath.Join(g.db.Dir(), "global")) + err = os.RemoveAll(g.db.DqliteDir()) if err != nil { return err } @@ -725,7 +737,7 @@ func (g *Gateway) init(bootstrap bool) error { return fmt.Errorf("Failed to create raft factory: %w", err) } - dir := filepath.Join(g.db.Dir(), "global") + dir := g.db.DqliteDir() if shared.PathExists(filepath.Join(dir, "logs.db")) { return fmt.Errorf("Unsupported upgrade path, please first upgrade to LXD 4.0") } @@ -765,16 +777,6 @@ func (g *Gateway) init(bootstrap bool) error { options = append(options, dqlite.WithDialFunc(g.raftDial())) } - server, err := dqlite.New( - info.ID, - info.Address, - dir, - options..., - ) - if err != nil { - return fmt.Errorf("Failed to create dqlite server: %w", err) - } - // Force the correct configuration into the bootstrap node, this is needed // when the raft node already has log entries, in which case a regular // bootstrap fails, resulting in the node containing outdated configuration. @@ -785,12 +787,22 @@ func (g *Gateway) init(bootstrap bool) error { {ID: uint64(info.ID), Address: info.Address}, } - err = server.Recover(cluster) + err = dqlite.ReconfigureMembershipExt(dir, cluster) if err != nil { return fmt.Errorf("Failed to recover database state: %w", err) } } + server, err := dqlite.New( + info.ID, + info.Address, + dir, + options..., + ) + if err != nil { + return fmt.Errorf("Failed to create dqlite server: %w", err) + } + err = server.Start() if err != nil { return fmt.Errorf("Failed to start dqlite server: %w", err) diff --git a/lxd/cluster/gateway_test.go b/lxd/cluster/gateway_test.go index ba1a7bfb7c3b..8022e07fd1a7 100644 --- a/lxd/cluster/gateway_test.go +++ b/lxd/cluster/gateway_test.go @@ -8,7 +8,6 @@ import ( "net/http" "net/http/httptest" "os" - "path/filepath" "testing" "github.com/canonical/go-dqlite/v2/driver" @@ -187,7 +186,7 @@ func TestGateway_RaftNodesNotLeader(t *testing.T) { // Create a new test Gateway with the given parameters, and ensure no error happens. func newGateway(t *testing.T, node *db.Node, networkCert *shared.CertInfo, s *state.State) *cluster.Gateway { - require.NoError(t, os.Mkdir(filepath.Join(node.Dir(), "global"), 0755)) + require.NoError(t, os.Mkdir(node.DqliteDir(), 0755)) stateFunc := func() *state.State { return s } gateway, err := cluster.NewGateway(context.Background(), node, networkCert, stateFunc, cluster.Latency(0.2), cluster.LogLevel("TRACE")) require.NoError(t, err) diff --git a/lxd/cluster/info.go b/lxd/cluster/info.go index d296a2c8ed65..65bfeed37844 100644 --- a/lxd/cluster/info.go +++ b/lxd/cluster/info.go @@ -3,7 +3,6 @@ package cluster import ( "context" "os" - "path/filepath" "github.com/canonical/lxd/lxd/db" "github.com/canonical/lxd/lxd/node" @@ -38,7 +37,7 @@ func loadInfo(database *db.Node) (*db.RaftNode, error) { logger.Info("Starting database node", logger.Ctx{"id": info.ID, "local": info.Address, "role": info.Role}) // Data directory - dir := filepath.Join(database.Dir(), "global") + dir := database.DqliteDir() if !shared.PathExists(dir) { err := os.Mkdir(dir, 0750) if err != nil { diff --git a/lxd/cluster/member_state.go b/lxd/cluster/member_state.go index 9da788edad32..9d6096ece8fe 100644 --- a/lxd/cluster/member_state.go +++ b/lxd/cluster/member_state.go @@ -4,14 +4,18 @@ import ( "context" "fmt" "os" + "runtime" "strconv" "strings" + "sync" "golang.org/x/sys/unix" + "github.com/canonical/lxd/client" "github.com/canonical/lxd/lxd/db" "github.com/canonical/lxd/lxd/state" storagePools "github.com/canonical/lxd/lxd/storage" + "github.com/canonical/lxd/shared" "github.com/canonical/lxd/shared/api" "github.com/canonical/lxd/shared/logger" ) @@ -45,35 +49,121 @@ func getLoadAvgs() ([]float64, error) { return loadAvgs, nil } -// MemberState retrieves state information about the cluster member. -func MemberState(ctx context.Context, s *state.State, memberName string) (*api.ClusterMemberState, error) { - var err error - var memberState api.ClusterMemberState - +// LocalSysInfo retrieves system information about a cluster member. +func LocalSysInfo() (*api.ClusterMemberSysInfo, error) { // Get system info. info := unix.Sysinfo_t{} - err = unix.Sysinfo(&info) + err := unix.Sysinfo(&info) if err != nil { logger.Warn("Failed getting sysinfo", logger.Ctx{"err": err}) return nil, err } + sysInfo := &api.ClusterMemberSysInfo{} + // Account for different representations of Sysinfo_t on different architectures. - memberState.SysInfo.Uptime = int64(info.Uptime) - memberState.SysInfo.TotalRAM = uint64(info.Totalram) - memberState.SysInfo.SharedRAM = uint64(info.Sharedram) - memberState.SysInfo.BufferRAM = uint64(info.Bufferram) - memberState.SysInfo.FreeRAM = uint64(info.Freeram) - memberState.SysInfo.TotalSwap = uint64(info.Totalswap) - memberState.SysInfo.FreeSwap = uint64(info.Freeswap) - - memberState.SysInfo.Processes = info.Procs - memberState.SysInfo.LoadAverages, err = getLoadAvgs() + sysInfo.Uptime = int64(info.Uptime) + sysInfo.TotalRAM = uint64(info.Totalram) + sysInfo.SharedRAM = uint64(info.Sharedram) + sysInfo.BufferRAM = uint64(info.Bufferram) + sysInfo.FreeRAM = uint64(info.Freeram) + sysInfo.TotalSwap = uint64(info.Totalswap) + sysInfo.FreeSwap = uint64(info.Freeswap) + + sysInfo.Processes = info.Procs + sysInfo.LoadAverages, err = getLoadAvgs() if err != nil { return nil, fmt.Errorf("Failed getting load averages: %w", err) } + // NumCPU gives the number of threads available to the LXD server at startup, + // not the currently available number of threads. + sysInfo.LogicalCPUs = uint64(runtime.NumCPU()) + + return sysInfo, nil +} + +// ClusterState returns a map from clusterMemberName -> state for every member +// of the cluster. This requires an HTTP call to the rest of the cluster. +func ClusterState(s *state.State, networkCert *shared.CertInfo, members ...db.NodeInfo) (map[string]api.ClusterMemberState, error) { + serverCert := s.ServerCert() + + notifier, err := NewNotifier(s, networkCert, serverCert, NotifyAll, members...) + if err != nil { + return nil, err + } + + type stateTuple struct { + name string + state *api.ClusterMemberState + } + + memberStates := make(map[string]api.ClusterMemberState) + statesChan := make(chan stateTuple) + + var wg sync.WaitGroup + wg.Add(1) + go func() { + for state := range statesChan { + memberStates[state.name] = *state.state + } + + wg.Done() + }() + + err = notifier(func(member db.NodeInfo, client lxd.InstanceServer) error { + state, _, err := client.GetClusterMemberState(member.Name) + if err != nil { + return err + } + + statesChan <- stateTuple{ + name: member.Name, + state: state, + } + + return nil + }) + if err != nil { + return nil, err + } + + close(statesChan) + + includeLocalMember := len(members) == 0 + for _, member := range members { + if member.Name == s.ServerName { + includeLocalMember = true + break + } + } + + wg.Wait() + + if includeLocalMember { + localState, err := MemberState(context.TODO(), s) + if err != nil { + return nil, fmt.Errorf("Failed to get local member state: %w", err) + } + + memberStates[s.ServerName] = *localState + } + + return memberStates, nil +} + +// MemberState retrieves state information about the cluster member. +func MemberState(ctx context.Context, s *state.State) (*api.ClusterMemberState, error) { + var memberState api.ClusterMemberState + + sysInfo, err := LocalSysInfo() + if err != nil { + return nil, err + } + + memberState.SysInfo = *sysInfo + // Get storage pool states. stateCreated := db.StoragePoolCreated diff --git a/lxd/cluster/member_state_test.go b/lxd/cluster/member_state_test.go new file mode 100644 index 000000000000..9d9b13bd18ee --- /dev/null +++ b/lxd/cluster/member_state_test.go @@ -0,0 +1,77 @@ +package cluster_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/canonical/lxd/lxd/cluster" + "github.com/canonical/lxd/lxd/db" + "github.com/canonical/lxd/lxd/node" + "github.com/canonical/lxd/lxd/state" + "github.com/canonical/lxd/shared" +) + +func TestClusterState(t *testing.T) { + state, cleanup := state.NewTestState(t) + defer cleanup() + + cert := shared.TestingKeyPair() + + state.ServerCert = func() *shared.CertInfo { return cert } + + f := notifyFixtures{t: t, state: state} + cleanupF := f.Nodes(cert, 3) + defer cleanupF() + + // Populate state.LocalConfig after nodes created above. + var err error + var nodeConfig *node.Config + err = state.DB.Node.Transaction(context.TODO(), func(ctx context.Context, tx *db.NodeTx) error { + nodeConfig, err = node.ConfigLoad(ctx, tx) + return err + }) + require.NoError(t, err) + + state.LocalConfig = nodeConfig + + states, err := cluster.ClusterState(state, cert) + require.NoError(t, err) + + assert.Equal(t, 3, len(states)) + + for clusterMemberName, state := range states { + // Local cluster member + if clusterMemberName == "0" { + assert.Greater(t, state.SysInfo.LogicalCPUs, uint64(0)) + continue + } + + assert.Equal(t, uint64(24), state.SysInfo.LogicalCPUs) + } + + var members []db.NodeInfo + err = state.DB.Cluster.Transaction(context.TODO(), func(ctx context.Context, tx *db.ClusterTx) error { + members, err = tx.GetNodes(ctx) + return err + }) + require.NoError(t, err) + + for i, memberInfo := range members { + if memberInfo.Name == "0" { + members[i] = members[len(members)-1] + members = members[:len(members)-1] + break + } + } + + states, err = cluster.ClusterState(state, cert, members...) + require.NoError(t, err) + + assert.Equal(t, 2, len(states)) + for _, state := range states { + assert.Equal(t, uint64(24), state.SysInfo.LogicalCPUs) + } +} diff --git a/lxd/cluster/notify.go b/lxd/cluster/notify.go index 8f337292694b..aa59382f9b3d 100644 --- a/lxd/cluster/notify.go +++ b/lxd/cluster/notify.go @@ -15,9 +15,10 @@ import ( "github.com/canonical/lxd/shared/logger" ) -// Notifier is a function that invokes the given function against each node in -// the cluster excluding the invoking one. -type Notifier func(hook func(lxd.InstanceServer) error) error +// Notifier is a function that invokes `hook` against each node in the cluster, +// excluding the invoking one. The NodeInfo passed to `hook` describes the +// cluster member of InstanceServer. +type Notifier func(hook func(db.NodeInfo, lxd.InstanceServer) error) error // NotifierPolicy can be used to tweak the behavior of NewNotifier in case of // some nodes are down. @@ -32,36 +33,48 @@ const ( // NewNotifier builds a Notifier that can be used to notify other peers using // the given policy. -func NewNotifier(state *state.State, networkCert *shared.CertInfo, serverCert *shared.CertInfo, policy NotifierPolicy) (Notifier, error) { +func NewNotifier(state *state.State, networkCert *shared.CertInfo, serverCert *shared.CertInfo, policy NotifierPolicy, members ...db.NodeInfo) (Notifier, error) { localClusterAddress := state.LocalConfig.ClusterAddress() + // Unfortunately the notifier is called during database startup before the + // global config has been loaded, so we need to fall back on loading the + // offline threshold from the database. + var offlineThreshold time.Duration + if state.GlobalConfig != nil { + offlineThreshold = state.GlobalConfig.OfflineThreshold() + } + // Fast-track the case where we're not clustered at all. - if localClusterAddress == "" { - nullNotifier := func(func(lxd.InstanceServer) error) error { return nil } + if !state.ServerClustered { + nullNotifier := func(func(db.NodeInfo, lxd.InstanceServer) error) error { return nil } return nullNotifier, nil } - var err error - var members []db.NodeInfo - var offlineThreshold time.Duration - err = state.DB.Cluster.Transaction(context.TODO(), func(ctx context.Context, tx *db.ClusterTx) error { - offlineThreshold, err = tx.GetNodeOfflineThreshold(ctx) - if err != nil { - return err - } + if len(members) == 0 || state.GlobalConfig == nil { + err := state.DB.Cluster.Transaction(context.TODO(), func(ctx context.Context, tx *db.ClusterTx) error { + var err error + if state.GlobalConfig == nil { + offlineThreshold, err = tx.GetNodeOfflineThreshold(ctx) + if err != nil { + return fmt.Errorf("Failed getting cluster offline threshold: %w", err) + } + } - members, err = tx.GetNodes(ctx) + if len(members) == 0 { + members, err = tx.GetNodes(ctx) + if err != nil { + return fmt.Errorf("Failed getting cluster members: %w", err) + } + } + + return nil + }) if err != nil { - return fmt.Errorf("Failed getting cluster members: %w", err) + return nil, err } - - return nil - }) - if err != nil { - return nil, err } - peers := []string{} + var peers []db.NodeInfo for _, member := range members { if member.Address == localClusterAddress || member.Address == "0.0.0.0" { continue // Exclude ourselves @@ -75,7 +88,7 @@ func NewNotifier(state *state.State, networkCert *shared.CertInfo, serverCert *s if !HasConnectivity(networkCert, serverCert, member.Address) { switch policy { case NotifyAll: - return nil, fmt.Errorf("peer node %s is down", member.Address) + return nil, fmt.Errorf("Peer cluster member %s at %s is down", member.Name, member.Address) case NotifyAlive: continue // Just skip this node case NotifyTryAll: @@ -83,28 +96,28 @@ func NewNotifier(state *state.State, networkCert *shared.CertInfo, serverCert *s } } - peers = append(peers, member.Address) + peers = append(peers, member) } - notifier := func(hook func(lxd.InstanceServer) error) error { + notifier := func(hook func(db.NodeInfo, lxd.InstanceServer) error) error { errs := make([]error, len(peers)) wg := sync.WaitGroup{} wg.Add(len(peers)) - for i, address := range peers { - logger.Debugf("Notify node %s of state changes", address) - go func(i int, address string) { + for i, member := range peers { + logger.Debug("Notify cluster member of state changes", logger.Ctx{"name": member.Name, "address": member.Address}) + go func(i int, member db.NodeInfo) { defer wg.Done() - client, err := Connect(address, networkCert, serverCert, nil, true) + client, err := Connect(member.Address, networkCert, serverCert, nil, true) if err != nil { - errs[i] = fmt.Errorf("failed to connect to peer %s: %w", address, err) + errs[i] = fmt.Errorf("Failed to connect to peer %s at %s: %w", member.Name, member.Address, err) return } - err = hook(client) + err = hook(member, client) if err != nil { - errs[i] = fmt.Errorf("failed to notify peer %s: %w", address, err) + errs[i] = fmt.Errorf("Failed to notify peer %s at %s: %w", member.Name, member.Address, err) } - }(i, address) + }(i, member) } wg.Wait() @@ -114,7 +127,7 @@ func NewNotifier(state *state.State, networkCert *shared.CertInfo, serverCert *s isDown := shared.IsConnectionError(err) || api.StatusErrorCheck(err, http.StatusServiceUnavailable) if isDown && policy == NotifyAlive { - logger.Warnf("Could not notify node %s", peers[i]) + logger.Warn("Could not notify cluster member", logger.Ctx{"name": peers[i].Name, "address": peers[i].Address}) continue } diff --git a/lxd/cluster/notify_test.go b/lxd/cluster/notify_test.go index d5cdb09bbdf2..9589e9622b90 100644 --- a/lxd/cluster/notify_test.go +++ b/lxd/cluster/notify_test.go @@ -49,7 +49,7 @@ func TestNewNotifier(t *testing.T) { require.NoError(t, err) peers := make(chan string, 2) - hook := func(client lxd.InstanceServer) error { + hook := func(member db.NodeInfo, client lxd.InstanceServer) error { server, _, err := client.GetServer() require.NoError(t, err) address, ok := server.Config["cluster.https_address"].(string) @@ -101,7 +101,7 @@ func TestNewNotify_NotifyAllError(t *testing.T) { notifier, err := cluster.NewNotifier(state, cert, cert, cluster.NotifyAll) assert.Nil(t, notifier) require.Error(t, err) - assert.Regexp(t, "peer node .+ is down", err.Error()) + assert.Regexp(t, "Peer cluster member .+ at .+ is down", err.Error()) } // Creating a new notifier does not fail if the policy is set to NotifyAlive @@ -133,7 +133,7 @@ func TestNewNotify_NotifyAlive(t *testing.T) { assert.NoError(t, err) i := 0 - hook := func(client lxd.InstanceServer) error { + hook := func(member db.NodeInfo, client lxd.InstanceServer) error { i++ return nil } @@ -171,7 +171,7 @@ func TestNewNotify_NotifyAliveShuttingDown(t *testing.T) { assert.NoError(t, err) connections := 0 - hook := func(client lxd.InstanceServer) error { + hook := func(member db.NodeInfo, client lxd.InstanceServer) error { connections++ // Notifiers do not GetServer() when they set up the connection; _, _, err := client.GetServer() @@ -195,9 +195,14 @@ type notifyFixtures struct { // The address of the first node spawned will be saved as local // cluster.https_address. func (h *notifyFixtures) Nodes(cert *shared.CertInfo, n int) func() { + if n > 1 { + h.state.ServerClustered = true + h.state.ServerName = "0" + } + servers := make([]*httptest.Server, n) for i := 0; i < n; i++ { - servers[i] = newRestServer(cert) + servers[i] = newRestServer(strconv.Itoa(i), cert) } // Insert new entries in the nodes table of the cluster database. @@ -282,7 +287,7 @@ func (h *notifyFixtures) Unavailable(i int, err error) { // Returns a minimal stub for the LXD RESTful API server, just realistic // enough to make lxd.ConnectLXD succeed. -func newRestServer(cert *shared.CertInfo) *httptest.Server { +func newRestServer(name string, cert *shared.CertInfo) *httptest.Server { mux := http.NewServeMux() server := httptest.NewUnstartedServer(mux) @@ -296,5 +301,16 @@ func newRestServer(cert *shared.CertInfo) *httptest.Server { _ = util.WriteJSON(w, api.ResponseRaw{Metadata: metadata}, nil) }) + mux.HandleFunc(fmt.Sprintf("/1.0/cluster/members/%s/state", name), func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + metadata := api.ClusterMemberState{ + SysInfo: api.ClusterMemberSysInfo{ + LogicalCPUs: 24, + }, + } + + _ = util.WriteJSON(w, api.ResponseRaw{Metadata: metadata}, nil) + }) + return server } diff --git a/lxd/cluster/recover.go b/lxd/cluster/recover.go index ac87628d8dfe..402f8a329cee 100644 --- a/lxd/cluster/recover.go +++ b/lxd/cluster/recover.go @@ -1,19 +1,36 @@ package cluster import ( + "archive/tar" + "compress/gzip" "context" "fmt" + "io" + "io/fs" "os" + "path" "path/filepath" + "slices" + "strings" "time" dqlite "github.com/canonical/go-dqlite/v2" "github.com/canonical/go-dqlite/v2/client" + "gopkg.in/yaml.v2" "github.com/canonical/lxd/lxd/db" "github.com/canonical/lxd/lxd/node" + "github.com/canonical/lxd/shared/logger" + "github.com/canonical/lxd/shared/revert" ) +// RecoveryTarballName is the filename used for recovery tarballs. +const RecoveryTarballName = "lxd_recovery_db.tar.gz" + +const raftNodesFilename = "raft_nodes.yaml" + +const errPatchExists = "Custom patches should not be applied during recovery" + // ListDatabaseNodes returns a list of database node names. func ListDatabaseNodes(database *db.Node) ([]string, error) { nodes := []db.RaftNode{} @@ -38,22 +55,41 @@ func ListDatabaseNodes(database *db.Node) ([]string, error) { return addresses, nil } -// Recover attempts data recovery on the cluster database. -func Recover(database *db.Node) error { - // Figure out if we actually act as dqlite node. +// Return the entry in the raft_nodes table that corresponds to the local +// `core.https_address`. +// Returns err if no raft_node exists for the local node. +func localRaftNode(database *db.Node) (*db.RaftNode, error) { var info *db.RaftNode err := database.Transaction(context.TODO(), func(ctx context.Context, tx *db.NodeTx) error { var err error info, err = node.DetermineRaftNode(ctx, tx) + return err }) if err != nil { - return fmt.Errorf("Failed to determine node role: %w", err) + return nil, fmt.Errorf("Failed to determine cluster member raft role: %w", err) } // If we're not a database node, return an error. if info == nil { - return fmt.Errorf("This LXD instance has no database role") + return nil, fmt.Errorf("This cluster member has no raft role") + } + + return info, nil +} + +// Recover rebuilds the dqlite raft configuration leaving only the current +// member in the cluster. Use `Reconfigure` if more members should remain in +// the raft configuration. +func Recover(database *db.Node) error { + _, err := createDatabaseBackup(database.Dir()) + if err != nil { + return fmt.Errorf("Failed creating backup: %w", err) + } + + info, err := localRaftNode(database) + if err != nil { + return err } // If this is a standalone node not exposed to the network, return an @@ -62,21 +98,17 @@ func Recover(database *db.Node) error { return fmt.Errorf("This LXD instance is not clustered") } - dir := filepath.Join(database.Dir(), "global") - server, err := dqlite.New( - uint64(info.ID), - info.Address, - dir, - ) - if err != nil { - return fmt.Errorf("Failed to create dqlite server: %w", err) - } + dir := database.DqliteDir() cluster := []dqlite.NodeInfo{ - {ID: uint64(info.ID), Address: info.Address}, + { + ID: uint64(info.ID), + Address: info.Address, + Role: client.Voter, + }, } - err = server.Recover(cluster) + err = dqlite.ReconfigureMembershipExt(dir, cluster) if err != nil { return fmt.Errorf("Failed to recover database state: %w", err) } @@ -126,22 +158,57 @@ func updateLocalAddress(database *db.Node, address string) error { return nil } -// Reconfigure replaces the entire cluster configuration. -// Addresses and node roles may be updated. Node IDs are read-only. -func Reconfigure(database *db.Node, raftNodes []db.RaftNode) error { - var info *db.RaftNode - err := database.Transaction(context.TODO(), func(ctx context.Context, tx *db.NodeTx) error { - var err error - info, err = node.DetermineRaftNode(ctx, tx) +// Create a patch file for the nodes table in the global database; this updates +// the addresses of all cluster members from the list of RaftNode in case they +// were changed during cluster recovery. +func writeGlobalNodesPatch(database *db.Node, nodes []db.RaftNode) error { + // No patch needed if there are no nodes + if len(nodes) < 1 { + return nil + } + reverter := revert.New() + defer reverter.Fail() + + filePath := filepath.Join(database.Dir(), "patch.global.sql") + + _, err := os.Stat(filePath) + if err == nil { + return fmt.Errorf("Found %s: %s", filePath, errPatchExists) + } + + file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { return err - }) + } + + defer func() { _ = file.Close() }() + reverter.Add(func() { _ = os.Remove(filePath) }) + + for _, node := range nodes { + _, err = fmt.Fprintf(file, "UPDATE nodes SET address = %q WHERE id = %d;\n", node.Address, node.ID) + if err != nil { + return err + } + } + + reverter.Success() + + return nil +} + +// Reconfigure replaces the entire cluster configuration. +// Addresses and node roles may be updated. Node IDs are read-only. +// Returns the path to the new database state (recovery tarball). +func Reconfigure(database *db.Node, raftNodes []db.RaftNode) (string, error) { + _, err := createDatabaseBackup(database.Dir()) if err != nil { - return fmt.Errorf("Failed to determine cluster member raft role: %w", err) + return "", fmt.Errorf("Failed creating backup: %w", err) } - if info == nil { - return fmt.Errorf("This cluster member has no raft role") + info, err := localRaftNode(database) + if err != nil { + return "", err } localAddress := info.Address @@ -156,55 +223,198 @@ func Reconfigure(database *db.Node, raftNodes []db.RaftNode) error { } } + patchPath := path.Join(database.Dir(), "patch.global.sql") + _, err = os.Stat(patchPath) + if err == nil { + return "", fmt.Errorf("Found %s: %s", patchPath, errPatchExists) + } + // Update cluster.https_address if changed. if localAddress != info.Address { err := updateLocalAddress(database, localAddress) if err != nil { - return err + return "", err } } - dir := filepath.Join(database.Dir(), "global") + dir := database.DqliteDir() // Replace cluster configuration in dqlite. err = dqlite.ReconfigureMembershipExt(dir, nodes) if err != nil { - return fmt.Errorf("Failed to recover database state: %w", err) + return "", fmt.Errorf("Failed to recover database state: %w", err) } // Replace cluster configuration in local raft_nodes database. err = database.Transaction(context.TODO(), func(ctx context.Context, tx *db.NodeTx) error { return tx.ReplaceRaftNodes(raftNodes) }) + if err != nil { + return "", err + } + + tarballPath, err := writeRecoveryTarball(database.Dir(), raftNodes) + if err != nil { + return "", fmt.Errorf("Failed to create recovery tarball: copy db manually; %w", err) + } + + err = writeGlobalNodesPatch(database, raftNodes) + if err != nil { + return "", fmt.Errorf("Failed to create global db patch for cluster recover: %w", err) + } + + return tarballPath, nil +} + +// Create a tarball of the global database dir to be copied to all other +// remaining cluster members. +func writeRecoveryTarball(databaseDir string, raftNodes []db.RaftNode) (string, error) { + tarballPath := filepath.Join(databaseDir, RecoveryTarballName) + globalDBDirPath := filepath.Join(databaseDir, "global") + + raftNodesPath := filepath.Join(globalDBDirPath, raftNodesFilename) + + raftNodesYaml, err := yaml.Marshal(raftNodes) + if err != nil { + return "", err + } + + raftNodesFd, err := os.Create(raftNodesPath) + if err != nil { + return "", err + } + + written, err := raftNodesFd.Write(raftNodesYaml) + if err != nil { + return "", err + } + + if written != len(raftNodesYaml) { + return "", fmt.Errorf("Wrote %d bytes but expected to write %d", written, len(raftNodesYaml)) + } + + err = raftNodesFd.Close() + if err != nil { + return "", err + } + + err = createTarball(tarballPath, globalDBDirPath, ".", []string{}) + if err != nil { + return "", err + } + + return tarballPath, nil +} + +// DatabaseReplaceFromTarball unpacks the tarball found at `tarballPath`, replaces +// the global database, updates the local database with any changed addresses, +// and writes a global patch file to update the global database with any changed +// addresses. +func DatabaseReplaceFromTarball(tarballPath string, database *db.Node) error { + globalDBDir := database.DqliteDir() + unpackDir := filepath.Join(database.Dir(), "global.recover") + + logger.Warn("Recovery tarball located; attempting DB recovery", logger.Ctx{"tarball": tarballPath}) + + _, err := createDatabaseBackup(database.Dir()) + if err != nil { + return fmt.Errorf("Failed creating backup: %w", err) + } + + err = unpackTarball(tarballPath, unpackDir) if err != nil { return err } - // Create patch file for global nodes database. - content := "" - for _, node := range nodes { - content += fmt.Sprintf("UPDATE nodes SET address = %q WHERE id = %d;\n", node.Address, node.ID) + raftNodesYamlPath := path.Join(unpackDir, raftNodesFilename) + raftNodesYaml, err := os.ReadFile(raftNodesYamlPath) + if err != nil { + return err } - if len(content) > 0 { - filePath := filepath.Join(database.Dir(), "patch.global.sql") - file, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - return err - } + var incomingRaftNodes []db.RaftNode + err = yaml.Unmarshal(raftNodesYaml, &incomingRaftNodes) + if err != nil { + return fmt.Errorf("Invalid %q", raftNodesYamlPath) + } + + var localRaftNodes []db.RaftNode + err = database.Transaction(context.TODO(), func(ctx context.Context, tx *db.NodeTx) (err error) { + localRaftNodes, err = tx.GetRaftNodes(ctx) + return err + }) + if err != nil { + return err + } - defer func() { _ = file.Close() }() + for _, localNode := range localRaftNodes { + foundLocal := false + for _, incomingNode := range incomingRaftNodes { + foundLocal = localNode.ID == incomingNode.ID && + localNode.Name == incomingNode.Name - _, err = file.Write([]byte(content)) - if err != nil { - return err + if foundLocal { + break + } } - err = file.Close() - if err != nil { - return err + // The incoming tarball should contain a node with the same dqlite ID as + // the local LXD server; we shouldn't unpack a recovery tarball from a + // different cluster. + if !foundLocal { + return fmt.Errorf("Missing cluster member %q in incoming recovery tarball", localNode.Name) } } + // Update our core.https_address if it has changed + localRaftNode, err := localRaftNode(database) + if err != nil { + return err + } + + for _, incomingNode := range incomingRaftNodes { + if incomingNode.ID == localRaftNode.ID { + if incomingNode.Address != localRaftNode.Address { + err = updateLocalAddress(database, incomingNode.Address) + if err != nil { + return err + } + } + + break + } + } + + // Replace cluster configuration in local raft_nodes database. + err = database.Transaction(context.TODO(), func(ctx context.Context, tx *db.NodeTx) error { + return tx.ReplaceRaftNodes(incomingRaftNodes) + }) + if err != nil { + return err + } + + err = writeGlobalNodesPatch(database, incomingRaftNodes) + if err != nil { + return fmt.Errorf("Failed to create global db patch for cluster recover: %w", err) + } + + // Now that we're as sure as we can be that the recovery DB is valid, we can + // replace the existing DB + err = os.RemoveAll(globalDBDir) + if err != nil { + return err + } + + err = os.Rename(unpackDir, globalDBDir) + if err != nil { + return err + } + + // Prevent the database being restored again after subsequent restarts + err = os.Remove(tarballPath) + if err != nil { + return err + } + return nil } @@ -245,3 +455,182 @@ func RemoveRaftNode(gateway *Gateway, address string) error { return nil } + +func unpackTarball(tarballPath string, destRoot string) error { + reverter := revert.New() + defer reverter.Fail() + + tarball, err := os.Open(tarballPath) + if err != nil { + return err + } + + gzReader, err := gzip.NewReader(tarball) + if err != nil { + return err + } + + tarReader := tar.NewReader(gzReader) + + err = os.MkdirAll(destRoot, 0o755) + if err != nil { + return err + } + + reverter.Add(func() { _ = os.RemoveAll(destRoot) }) + + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } else if err != nil { + return err + } + + // CWE-22 + if strings.Contains(header.Name, "..") { + return fmt.Errorf("Invalid sequence `..` in recovery tarball entry %q", header.Name) + } + + filepath := path.Join(destRoot, header.Name) + + switch header.Typeflag { + case tar.TypeReg: + file, err := os.Create(filepath) + if err != nil { + return err + } + + countWritten, err := io.Copy(file, tarReader) + if countWritten != header.Size { + return fmt.Errorf("Mismatched written (%d) and size (%d) for entry %q in %q", countWritten, header.Size, header.Name, tarballPath) + } else if err != nil { + return err + } + + case tar.TypeDir: + err = os.MkdirAll(filepath, fs.FileMode(header.Mode&int64(fs.ModePerm))) + if err != nil { + return err + } + } + } + + reverter.Success() + + return nil +} + +func createDatabaseBackup(databaseDir string) (string, error) { + varDir := path.Dir(databaseDir) + + // tar interprets `:` as a remote drive; ISO8601 allows a 'basic format' + // with the colons omitted (as opposed to time.RFC3339) + // https://en.wikipedia.org/wiki/ISO_8601 + backupFileName := fmt.Sprintf("db_backup.%s.tar.gz", time.Now().Format("2006-01-02T150405Z0700")) + tarballPath := filepath.Join(varDir, backupFileName) + + walkDir := path.Base(databaseDir) + + logger.Info("Creating database backup", logger.Ctx{"path": tarballPath}) + + // Don't include the recovery tarball in a backup tarball + excludeFiles := []string{path.Join(walkDir, RecoveryTarballName)} + + err := createTarball(tarballPath, varDir, walkDir, excludeFiles) + if err != nil { + return "", err + } + + return tarballPath, nil +} + +// createTarball creates tarball at tarballPath, rooted at rootDir and including +// all files in walkDir except those paths found in excludeFiles. +// walkDir and excludeFiles elements are relative to rootDir. +func createTarball(tarballPath string, rootDir string, walkDir string, excludeFiles []string) error { + reverter := revert.New() + defer reverter.Fail() + + tarball, err := os.Create(tarballPath) + if err != nil { + return err + } + + reverter.Add(func() { _ = os.Remove(tarballPath) }) + + gzWriter := gzip.NewWriter(tarball) + tarWriter := tar.NewWriter(gzWriter) + + filesys := os.DirFS(rootDir) + + err = fs.WalkDir(filesys, walkDir, func(filepath string, stat fs.DirEntry, err error) error { + if err != nil { + return err + } + + if slices.Contains(excludeFiles, filepath) { + return nil + } + + info, err := stat.Info() + if err != nil { + return err + } + + header, err := tar.FileInfoHeader(info, filepath) + if err != nil { + return fmt.Errorf("Failed creating tar header for %q: %w", filepath, err) + } + + // header.Name is the basename of `stat` by default + header.Name = filepath + + err = tarWriter.WriteHeader(header) + if err != nil { + return err + } + + // Only write contents for regular files + if header.Typeflag == tar.TypeReg { + file, err := os.Open(path.Join(rootDir, filepath)) + if err != nil { + return err + } + + _, err = io.Copy(tarWriter, file) + if err != nil { + return err + } + + err = file.Close() + if err != nil { + return err + } + } + + return nil + }) + if err != nil { + return err + } + + err = tarWriter.Close() + if err != nil { + return err + } + + err = gzWriter.Close() + if err != nil { + return err + } + + err = tarball.Close() + if err != nil { + return err + } + + reverter.Success() + + return nil +} diff --git a/lxd/cluster/upgrade.go b/lxd/cluster/upgrade.go index cbf44da4ab36..3a1c65e56736 100644 --- a/lxd/cluster/upgrade.go +++ b/lxd/cluster/upgrade.go @@ -27,7 +27,7 @@ func NotifyUpgradeCompleted(state *state.State, networkCert *shared.CertInfo, se return err } - return notifier(func(client lxd.InstanceServer) error { + return notifier(func(member db.NodeInfo, client lxd.InstanceServer) error { info, err := client.GetConnectionInfo() if err != nil { return fmt.Errorf("failed to get connection info: %w", err) diff --git a/lxd/daemon.go b/lxd/daemon.go index 12f6ae986070..590784ac3006 100644 --- a/lxd/daemon.go +++ b/lxd/daemon.go @@ -54,6 +54,7 @@ import ( "github.com/canonical/lxd/lxd/lifecycle" "github.com/canonical/lxd/lxd/loki" "github.com/canonical/lxd/lxd/maas" + "github.com/canonical/lxd/lxd/metrics" networkZone "github.com/canonical/lxd/lxd/network/zone" "github.com/canonical/lxd/lxd/node" "github.com/canonical/lxd/lxd/request" @@ -68,7 +69,6 @@ import ( "github.com/canonical/lxd/lxd/storage/s3/miniod" "github.com/canonical/lxd/lxd/sys" "github.com/canonical/lxd/lxd/task" - "github.com/canonical/lxd/lxd/ubuntupro" "github.com/canonical/lxd/lxd/ucred" "github.com/canonical/lxd/lxd/util" "github.com/canonical/lxd/lxd/warnings" @@ -164,9 +164,6 @@ type Daemon struct { // Syslog listener cancel function. syslogSocketCancel context.CancelFunc - - // Ubuntu Pro settings - ubuntuPro *ubuntupro.Client } // DaemonConfig holds configuration values for Daemon. @@ -222,15 +219,16 @@ func defaultDaemon() *Daemon { // APIEndpoint represents a URL in our API. type APIEndpoint struct { - Name string // Name for this endpoint. - Path string // Path pattern for this endpoint. - Aliases []APIEndpointAlias // Any aliases for this endpoint. - Get APIEndpointAction - Head APIEndpointAction - Put APIEndpointAction - Post APIEndpointAction - Delete APIEndpointAction - Patch APIEndpointAction + Name string // Name for this endpoint. + Path string // Path pattern for this endpoint. + MetricsType entity.Type // Main entity type related to this endpoint. Used by the API metrics. + Aliases []APIEndpointAlias // Any aliases for this endpoint. + Get APIEndpointAction + Head APIEndpointAction + Put APIEndpointAction + Post APIEndpointAction + Delete APIEndpointAction + Patch APIEndpointAction } // APIEndpointAlias represents an alias URL of and APIEndpoint in our API. @@ -249,12 +247,7 @@ type APIEndpointAction struct { // allowAuthenticated is an AccessHandler which allows only authenticated requests. This should be used in conjunction // with further access control within the handler (e.g. to filter resources the user is able to view/edit). func allowAuthenticated(_ *Daemon, r *http.Request) response.Response { - trusted, err := request.GetCtxValue[bool](r.Context(), request.CtxTrusted) - if err != nil { - return response.SmartError(err) - } - - if trusted { + if auth.IsTrusted(r.Context()) { return response.EmptySyncResponse } @@ -334,6 +327,9 @@ func allowProjectResourceList(d *Daemon, r *http.Request) response.Response { case api.IdentityTypeOIDCClient: // OIDC authenticated clients are governed by fine-grained auth. They can call the endpoint but may see an empty list. return response.EmptySyncResponse + case api.IdentityTypeCertificateClient: + // Fine-grained TLS identities can list resources in any project. They may see an empty list. + return response.EmptySyncResponse case api.IdentityTypeCertificateClientRestricted: // A restricted client may be able to call the endpoint, continue. default: @@ -422,7 +418,7 @@ func (d *Daemon) Authenticate(w http.ResponseWriter, r *http.Request) (trusted b // List of candidate identity types for this request. We have already checked server certificates at the beginning of this method // so we only need to consider client and metrics certificates. (OIDC auth was completed above). - candidateIdentityTypes := []string{api.IdentityTypeCertificateClientUnrestricted, api.IdentityTypeCertificateClientRestricted} + candidateIdentityTypes := []string{api.IdentityTypeCertificateClientUnrestricted, api.IdentityTypeCertificateClientRestricted, api.IdentityTypeCertificateClient} if isMetricsRequest(*r.URL) { // Metrics certificates can only authenticate when calling metrics related endpoints. candidateIdentityTypes = append(candidateIdentityTypes, api.IdentityTypeCertificateMetricsUnrestricted, api.IdentityTypeCertificateMetricsRestricted) @@ -544,7 +540,7 @@ func (d *Daemon) handleOIDCAuthenticationResult(r *http.Request, result *oidc.Au return fmt.Errorf("Failed to notify cluster members of new or updated OIDC identity: %w", err) } - err = notifier(func(client lxd.InstanceServer) error { + err = notifier(func(member db.NodeInfo, client lxd.InstanceServer) error { _, _, err := client.RawQuery(http.MethodPost, "/internal/identity-cache-refresh", nil, "") return err }) @@ -579,7 +575,7 @@ func (d *Daemon) State() *state.State { localConfig := d.localConfig d.globalConfigMu.Unlock() - return &state.State{ + s := &state.State{ ShutdownCtx: d.shutdownCtx, DB: d.db, MAAS: d.maas, @@ -602,8 +598,31 @@ func (d *Daemon) State() *state.State { ServerUUID: d.serverUUID, StartTime: d.startTime, Authorizer: d.authorizer, - UbuntuPro: d.ubuntuPro, } + + s.LeaderInfo = func() (*state.LeaderInfo, error) { + if !s.ServerClustered { + return &state.LeaderInfo{ + Clustered: false, + Leader: true, + Address: "", + }, nil + } + + localClusterAddress := s.LocalConfig.ClusterAddress() + leaderAddress, err := d.gateway.LeaderAddress() + if err != nil { + return nil, fmt.Errorf("Failed to get the address of the cluster leader: %w", err) + } + + return &state.LeaderInfo{ + Clustered: true, + Leader: localClusterAddress == leaderAddress, + Address: leaderAddress, + }, nil + } + + return s } // UnixSocket returns the full path to the unix.socket file that this daemon is @@ -617,6 +636,11 @@ func (d *Daemon) UnixSocket() string { return filepath.Join(d.os.VarDir, "unix.socket") } +// createCmd creates API handlers for the provided endpoint including some useful behavior, +// such as appropriate authentication, authorization and checking server availability. +// +// The created handler also keeps track of handled requests for the API metrics +// for the main API endpoints. func (d *Daemon) createCmd(restAPI *mux.Router, version string, c APIEndpoint) { var uri string if c.Path == "" { @@ -628,6 +652,12 @@ func (d *Daemon) createCmd(restAPI *mux.Router, version string, c APIEndpoint) { } route := restAPI.HandleFunc(uri, func(w http.ResponseWriter, r *http.Request) { + // Only endpoints from the main API (version 1.0) should be counted for the metrics. + // This prevents internal endpoints from being included as well. + if version == "1.0" { + metrics.TrackStartedRequest(r, c.MetricsType) + } + w.Header().Set("Content-Type", "application/json") if !(r.RemoteAddr == "@" && version == "internal") { @@ -1206,6 +1236,18 @@ func (d *Daemon) init() error { d.serverCertInt = serverCert } + // If we're clustered, check for an incoming recovery tarball + if d.serverClustered { + tarballPath := filepath.Join(d.db.Node.Dir(), cluster.RecoveryTarballName) + + if shared.PathExists(tarballPath) { + err = cluster.DatabaseReplaceFromTarball(tarballPath, d.db.Node) + if err != nil { + return fmt.Errorf("Failed to load recovery tarball: %w", err) + } + } + } + /* Setup dqlite */ clusterLogLevel := "ERROR" if shared.ValueInSlice("dqlite", trace) { @@ -1611,7 +1653,7 @@ func (d *Daemon) init() error { if !d.db.Cluster.LocalNodeIsEvacuated() { logger.Infof("Initializing networks") - err = networkStartup(d.State()) + err = networkStartup(d.State) if err != nil { return err } @@ -1819,9 +1861,6 @@ func (d *Daemon) init() error { // Start all background tasks d.tasks.Start(d.shutdownCtx) - // Load Ubuntu Pro configuration before starting any instances. - d.ubuntuPro = ubuntupro.New(d.os.ReleaseInfo["NAME"], d.shutdownCtx) - // Restore instances instancesStart(d.State(), instances) diff --git a/lxd/db/cluster/entity_type_identity.go b/lxd/db/cluster/entity_type_identity.go index 281674558a20..8373feb322eb 100644 --- a/lxd/db/cluster/entity_type_identity.go +++ b/lxd/db/cluster/entity_type_identity.go @@ -28,12 +28,12 @@ SELECT identities.identifier ) FROM identities -WHERE type IN (%d) +WHERE type IN (%d, %d, %d) `, e.code(), authMethodTLS, api.AuthenticationMethodTLS, authMethodOIDC, api.AuthenticationMethodOIDC, - identityTypeOIDCClient, + identityTypeOIDCClient, identityTypeCertificateClient, identityTypeCertificateClientPending, ) } diff --git a/lxd/db/cluster/identities.go b/lxd/db/cluster/identities.go index db64117353fa..013171d642f4 100644 --- a/lxd/db/cluster/identities.go +++ b/lxd/db/cluster/identities.go @@ -12,11 +12,15 @@ import ( "errors" "fmt" "net/http" + "time" + + "github.com/google/uuid" "github.com/canonical/lxd/lxd/auth" "github.com/canonical/lxd/lxd/certificate" "github.com/canonical/lxd/lxd/db/query" "github.com/canonical/lxd/lxd/identity" + "github.com/canonical/lxd/shared" "github.com/canonical/lxd/shared/api" "github.com/canonical/lxd/shared/entity" ) @@ -117,6 +121,8 @@ const ( identityTypeCertificateMetricsRestricted int64 = 4 identityTypeOIDCClient int64 = 5 identityTypeCertificateMetricsUnrestricted int64 = 6 + identityTypeCertificateClient int64 = 7 + identityTypeCertificateClientPending int64 = 8 ) // Scan implements sql.Scanner for IdentityType. This converts the integer value back into the correct API constant or @@ -149,6 +155,10 @@ func (i *IdentityType) Scan(value any) error { *i = api.IdentityTypeCertificateMetricsUnrestricted case identityTypeOIDCClient: *i = api.IdentityTypeOIDCClient + case identityTypeCertificateClient: + *i = api.IdentityTypeCertificateClient + case identityTypeCertificateClientPending: + *i = api.IdentityTypeCertificateClientPending default: return fmt.Errorf("Unknown identity type `%d`", identityTypeInt) } @@ -171,6 +181,10 @@ func (i IdentityType) Value() (driver.Value, error) { return identityTypeCertificateMetricsUnrestricted, nil case api.IdentityTypeOIDCClient: return identityTypeOIDCClient, nil + case api.IdentityTypeCertificateClient: + return identityTypeCertificateClient, nil + case api.IdentityTypeCertificateClientPending: + return identityTypeCertificateClientPending, nil } return nil, fmt.Errorf("Invalid identity type %q", i) @@ -270,10 +284,16 @@ func (i Identity) ToCertificate() (*Certificate, error) { return c, nil } -// X509 returns an x509.Certificate from the identity metadata. The AuthMethod of the Identity must be api.AuthenticationMethodTLS. -func (i Identity) X509() (*x509.Certificate, error) { +// CertificateMetadata returns the metadata associated with the identity as CertificateMetadata. It fails if the +// authentication method is not api.AuthentictionMethodTLS or if the type is api.IdentityTypeClientCertificatePending, +// as they do not have metadata of this type. +func (i Identity) CertificateMetadata() (*CertificateMetadata, error) { if i.AuthMethod != api.AuthenticationMethodTLS { - return nil, fmt.Errorf("Cannot extract X509 certificate from identity: Identity has authentication method %q (%q required)", i.AuthMethod, api.AuthenticationMethodTLS) + return nil, fmt.Errorf("Cannot get certificate metadata: Identity has authentication method %q (%q required)", i.AuthMethod, api.AuthenticationMethodTLS) + } + + if i.Type == api.IdentityTypeCertificateClientPending { + return nil, fmt.Errorf("Cannot get certificate metadata: Identity is pending") } var metadata CertificateMetadata @@ -282,6 +302,16 @@ func (i Identity) X509() (*x509.Certificate, error) { return nil, fmt.Errorf("Failed to unmarshal certificate identity metadata: %w", err) } + return &metadata, nil +} + +// X509 returns an x509.Certificate from the identity metadata. The AuthMethod of the Identity must be api.AuthenticationMethodTLS. +func (i Identity) X509() (*x509.Certificate, error) { + metadata, err := i.CertificateMetadata() + if err != nil { + return nil, err + } + return metadata.X509() } @@ -305,6 +335,27 @@ func (i Identity) Subject() (string, error) { return metadata.Subject, nil } +// PendingTLSMetadata contains metadata for the pending TLS certificate identity type. +type PendingTLSMetadata struct { + Secret string `json:"secret"` + Expiry time.Time `json:"expiry"` +} + +// PendingTLSMetadata returns the pending TLS identity metadata. +func (i Identity) PendingTLSMetadata() (*PendingTLSMetadata, error) { + if i.Type != api.IdentityTypeCertificateClientPending { + return nil, api.NewStatusError(http.StatusBadRequest, "Cannot extract pending TLS identity secret: Identity is not pending") + } + + var metadata PendingTLSMetadata + err := json.Unmarshal([]byte(i.Metadata), &metadata) + if err != nil { + return nil, api.StatusErrorf(http.StatusInternalServerError, "Failed to unmarshal pending TLS identity metadata: %w", err) + } + + return &metadata, nil +} + // ToAPI converts an Identity to an api.Identity, executing database queries as necessary. func (i *Identity) ToAPI(ctx context.Context, tx *sql.Tx, canViewGroup auth.PermissionChecker) (*api.Identity, error) { groups, err := GetAuthGroupsByIdentityID(ctx, tx, i.ID) @@ -319,15 +370,85 @@ func (i *Identity) ToAPI(ctx context.Context, tx *sql.Tx, canViewGroup auth.Perm } } + var tlsCertificate string + if i.AuthMethod == api.AuthenticationMethodTLS && i.Type != api.IdentityTypeCertificateClientPending { + metadata, err := i.CertificateMetadata() + if err != nil { + return nil, err + } + + tlsCertificate = metadata.Certificate + } + return &api.Identity{ AuthenticationMethod: string(i.AuthMethod), Type: string(i.Type), Identifier: i.Identifier, Name: i.Name, Groups: groupNames, + TLSCertificate: tlsCertificate, }, nil } +// ActivateTLSIdentity updates a TLS identity to make it valid by adding the fingerprint, PEM encoded certificate, and setting +// the type to api.IdentityTypeCertificateClient. +func ActivateTLSIdentity(ctx context.Context, tx *sql.Tx, identifier uuid.UUID, cert *x509.Certificate) error { + fingerprint := shared.CertFingerprint(cert) + _, err := GetIdentityID(ctx, tx, api.AuthenticationMethodTLS, fingerprint) + if err == nil { + return api.StatusErrorf(http.StatusConflict, "Identity already exists") + } + + metadata := CertificateMetadata{Certificate: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}))} + b, err := json.Marshal(metadata) + if err != nil { + return fmt.Errorf("Failed to encode certificate metadata: %w", err) + } + + stmt := `UPDATE identities SET type = ?, identifier = ?, metadata = ? WHERE identifier = ? AND auth_method = ?` + res, err := tx.ExecContext(ctx, stmt, identityTypeCertificateClient, fingerprint, string(b), identifier.String(), authMethodTLS) + if err != nil { + return fmt.Errorf("Failed to activate TLS identity: %w", err) + } + + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("Failed to check for activated TLS identity: %w", err) + } + + if n == 0 { + return api.StatusErrorf(http.StatusNotFound, "No pending TLS identity found with identifier %q", identifier) + } else if n > 1 { + return fmt.Errorf("Unknown error occurred when activating a TLS identity: %w", err) + } + + return nil +} + +var getPendingTLSIdentityByTokenSecretStmt = fmt.Sprintf(` +SELECT identities.id, identities.auth_method, identities.type, identities.identifier, identities.name, identities.metadata + FROM identities + WHERE identities.type = %d + AND json_extract(identities.metadata, '$.secret') = ? +`, identityTypeCertificateClientPending) + +// GetPendingTLSIdentityByTokenSecret gets a single identity of type identityTypeCertificateClientPending with the given +// secret in its metadata. If no pending identity is found, an api.StatusError is returned with http.StatusNotFound. +func GetPendingTLSIdentityByTokenSecret(ctx context.Context, tx *sql.Tx, secret string) (*Identity, error) { + identities, err := getIdentitysRaw(ctx, tx, getPendingTLSIdentityByTokenSecretStmt, secret) + if err != nil { + return nil, err + } + + if len(identities) == 0 { + return nil, api.NewStatusError(http.StatusNotFound, "No pending identities found with given secret") + } else if len(identities) > 1 { + return nil, errors.New("Multiple pending identities found with given secret") + } + + return &identities[0], nil +} + // GetAuthGroupsByIdentityID returns a slice of groups that the identity with the given ID is a member of. func GetAuthGroupsByIdentityID(ctx context.Context, tx *sql.Tx, identityID int) ([]AuthGroup, error) { stmt := ` diff --git a/lxd/db/cluster/instances.go b/lxd/db/cluster/instances.go index f67c54eb6670..51dc1ac239e8 100644 --- a/lxd/db/cluster/instances.go +++ b/lxd/db/cluster/instances.go @@ -77,16 +77,30 @@ type InstanceFilter struct { } // ToAPI converts the database Instance to API type. -func (i *Instance) ToAPI(ctx context.Context, tx *sql.Tx, globalConfig map[string]any) (*api.Instance, error) { +func (i *Instance) ToAPI(ctx context.Context, tx *sql.Tx, globalConfig map[string]any, instanceDevices map[int][]Device, profileConfigs map[int]map[string]string, profileDevices map[int][]Device) (*api.Instance, error) { profiles, err := GetInstanceProfiles(ctx, tx, i.ID) if err != nil { return nil, err } + if profileConfigs == nil { + profileConfigs, err = GetConfig(ctx, tx, "profile") + if err != nil { + return nil, err + } + } + + if profileDevices == nil { + profileDevices, err = GetDevices(ctx, tx, "profile") + if err != nil { + return nil, err + } + } + apiProfiles := make([]api.Profile, 0, len(profiles)) profileNames := make([]string, 0, len(profiles)) for _, p := range profiles { - apiProfile, err := p.ToAPI(ctx, tx) + apiProfile, err := p.ToAPI(ctx, tx, profileConfigs, profileDevices) if err != nil { return nil, err } @@ -95,9 +109,18 @@ func (i *Instance) ToAPI(ctx context.Context, tx *sql.Tx, globalConfig map[strin profileNames = append(profileNames, p.Name) } - devices, err := GetInstanceDevices(ctx, tx, i.ID) - if err != nil { - return nil, err + var devices map[string]Device + if instanceDevices != nil { + devices = map[string]Device{} + + for _, dev := range instanceDevices[i.ID] { + devices[dev.Name] = dev + } + } else { + devices, err = GetInstanceDevices(ctx, tx, i.ID) + if err != nil { + return nil, err + } } apiDevices := DevicesToAPI(devices) diff --git a/lxd/db/cluster/nodes_cluster_groups.go b/lxd/db/cluster/nodes_cluster_groups.go index a5e6c5d1ff14..2c1f6f90577c 100644 --- a/lxd/db/cluster/nodes_cluster_groups.go +++ b/lxd/db/cluster/nodes_cluster_groups.go @@ -7,14 +7,9 @@ package cluster // //go:generate mapper stmt -e node_cluster_group objects table=nodes_cluster_groups //go:generate mapper stmt -e node_cluster_group objects-by-GroupID table=nodes_cluster_groups -//go:generate mapper stmt -e node_cluster_group id table=nodes_cluster_groups -//go:generate mapper stmt -e node_cluster_group create table=nodes_cluster_groups //go:generate mapper stmt -e node_cluster_group delete-by-GroupID table=nodes_cluster_groups // //go:generate mapper method -e node_cluster_group GetMany -//go:generate mapper method -e node_cluster_group Create -//go:generate mapper method -e node_cluster_group Exists -//go:generate mapper method -e node_cluster_group ID //go:generate mapper method -e node_cluster_group DeleteOne-by-GroupID // NodeClusterGroup associates a node to a cluster group. diff --git a/lxd/db/cluster/nodes_cluster_groups.mapper.go b/lxd/db/cluster/nodes_cluster_groups.mapper.go index 0777c56a2d69..d7d4c6b93345 100644 --- a/lxd/db/cluster/nodes_cluster_groups.mapper.go +++ b/lxd/db/cluster/nodes_cluster_groups.mapper.go @@ -7,7 +7,6 @@ package cluster import ( "context" "database/sql" - "errors" "fmt" "net/http" "strings" @@ -33,16 +32,6 @@ SELECT nodes_cluster_groups.group_id, nodes.name AS node ORDER BY nodes_cluster_groups.group_id `) -var nodeClusterGroupID = RegisterStmt(` -SELECT nodes_cluster_groups.id FROM nodes_cluster_groups - WHERE nodes_cluster_groups.group_id = ? -`) - -var nodeClusterGroupCreate = RegisterStmt(` -INSERT INTO nodes_cluster_groups (group_id, node_id) - VALUES (?, (SELECT nodes.id FROM nodes WHERE nodes.name = ?)) -`) - var nodeClusterGroupDeleteByGroupID = RegisterStmt(` DELETE FROM nodes_cluster_groups WHERE group_id = ? `) @@ -168,82 +157,6 @@ func GetNodeClusterGroups(ctx context.Context, tx *sql.Tx, filters ...NodeCluste return objects, nil } -// CreateNodeClusterGroup adds a new node_cluster_group to the database. -// generator: node_cluster_group Create -func CreateNodeClusterGroup(ctx context.Context, tx *sql.Tx, object NodeClusterGroup) (int64, error) { - // Check if a node_cluster_group with the same key exists. - exists, err := NodeClusterGroupExists(ctx, tx, object.GroupID) - if err != nil { - return -1, fmt.Errorf("Failed to check for duplicates: %w", err) - } - - if exists { - return -1, api.StatusErrorf(http.StatusConflict, "This \"nodes_clusters_groups\" entry already exists") - } - - args := make([]any, 2) - - // Populate the statement arguments. - args[0] = object.GroupID - args[1] = object.Node - - // Prepared statement to use. - stmt, err := Stmt(tx, nodeClusterGroupCreate) - if err != nil { - return -1, fmt.Errorf("Failed to get \"nodeClusterGroupCreate\" prepared statement: %w", err) - } - - // Execute the statement. - result, err := stmt.Exec(args...) - if err != nil { - return -1, fmt.Errorf("Failed to create \"nodes_clusters_groups\" entry: %w", err) - } - - id, err := result.LastInsertId() - if err != nil { - return -1, fmt.Errorf("Failed to fetch \"nodes_clusters_groups\" entry ID: %w", err) - } - - return id, nil -} - -// NodeClusterGroupExists checks if a node_cluster_group with the given key exists. -// generator: node_cluster_group Exists -func NodeClusterGroupExists(ctx context.Context, tx *sql.Tx, groupID int) (bool, error) { - _, err := GetNodeClusterGroupID(ctx, tx, groupID) - if err != nil { - if api.StatusErrorCheck(err, http.StatusNotFound) { - return false, nil - } - - return false, err - } - - return true, nil -} - -// GetNodeClusterGroupID return the ID of the node_cluster_group with the given key. -// generator: node_cluster_group ID -func GetNodeClusterGroupID(ctx context.Context, tx *sql.Tx, groupID int) (int64, error) { - stmt, err := Stmt(tx, nodeClusterGroupID) - if err != nil { - return -1, fmt.Errorf("Failed to get \"nodeClusterGroupID\" prepared statement: %w", err) - } - - row := stmt.QueryRowContext(ctx, groupID) - var id int64 - err = row.Scan(&id) - if errors.Is(err, sql.ErrNoRows) { - return -1, api.StatusErrorf(http.StatusNotFound, "NodeClusterGroup not found") - } - - if err != nil { - return -1, fmt.Errorf("Failed to get \"nodes_clusters_groups\" ID: %w", err) - } - - return id, nil -} - // DeleteNodeClusterGroup deletes the node_cluster_group matching the given key parameters. // generator: node_cluster_group DeleteOne-by-GroupID func DeleteNodeClusterGroup(ctx context.Context, tx *sql.Tx, groupID int) error { diff --git a/lxd/db/cluster/profiles.go b/lxd/db/cluster/profiles.go index baf8fd5d204c..9f05eabac2f4 100644 --- a/lxd/db/cluster/profiles.go +++ b/lxd/db/cluster/profiles.go @@ -51,22 +51,41 @@ type ProfileFilter struct { } // ToAPI returns a cluster Profile as an API struct. -func (p *Profile) ToAPI(ctx context.Context, tx *sql.Tx) (*api.Profile, error) { - config, err := GetProfileConfig(ctx, tx, p.ID) - if err != nil { - return nil, err +func (p *Profile) ToAPI(ctx context.Context, tx *sql.Tx, profileConfigs map[int]map[string]string, profileDevices map[int][]Device) (*api.Profile, error) { + var err error + + var dbConfig map[string]string + if profileConfigs != nil { + dbConfig = profileConfigs[p.ID] + if dbConfig == nil { + dbConfig = map[string]string{} + } + } else { + dbConfig, err = GetProfileConfig(ctx, tx, p.ID) + if err != nil { + return nil, err + } } - devices, err := GetProfileDevices(ctx, tx, p.ID) - if err != nil { - return nil, err + var dbDevices map[string]Device + if profileDevices != nil { + dbDevices = map[string]Device{} + + for _, dev := range profileDevices[p.ID] { + dbDevices[dev.Name] = dev + } + } else { + dbDevices, err = GetProfileDevices(ctx, tx, p.ID) + if err != nil { + return nil, err + } } profile := &api.Profile{ Name: p.Name, Description: p.Description, - Config: config, - Devices: DevicesToAPI(devices), + Config: dbConfig, + Devices: DevicesToAPI(dbDevices), } return profile, nil diff --git a/lxd/db/cluster/warnings.go b/lxd/db/cluster/warnings.go index 79adbda3d9e0..47123ed3fbd5 100644 --- a/lxd/db/cluster/warnings.go +++ b/lxd/db/cluster/warnings.go @@ -18,6 +18,7 @@ import ( //go:generate mapper stmt -e warning objects-by-UUID //go:generate mapper stmt -e warning objects-by-Project //go:generate mapper stmt -e warning objects-by-Status +//go:generate mapper stmt -e warning objects-by-Node-and-Status //go:generate mapper stmt -e warning objects-by-Node-and-TypeCode //go:generate mapper stmt -e warning objects-by-Node-and-TypeCode-and-Project //go:generate mapper stmt -e warning objects-by-Node-and-TypeCode-and-Project-and-EntityType-and-EntityID diff --git a/lxd/db/cluster/warnings.mapper.go b/lxd/db/cluster/warnings.mapper.go index 93d7b4634cd2..beac24e5f334 100644 --- a/lxd/db/cluster/warnings.mapper.go +++ b/lxd/db/cluster/warnings.mapper.go @@ -53,6 +53,15 @@ SELECT warnings.id, coalesce(nodes.name, '') AS node, coalesce(projects.name, '' ORDER BY warnings.uuid `) +var warningObjectsByNodeAndStatus = RegisterStmt(` +SELECT warnings.id, coalesce(nodes.name, '') AS node, coalesce(projects.name, '') AS project, coalesce(warnings.entity_type_code, -1), coalesce(warnings.entity_id, -1), warnings.uuid, warnings.type_code, warnings.status, warnings.first_seen_date, warnings.last_seen_date, warnings.updated_date, warnings.last_message, warnings.count + FROM warnings + LEFT JOIN nodes ON warnings.node_id = nodes.id + LEFT JOIN projects ON warnings.project_id = projects.id + WHERE ( coalesce(node, '') = ? AND warnings.status = ? ) + ORDER BY warnings.uuid +`) + var warningObjectsByNodeAndTypeCode = RegisterStmt(` SELECT warnings.id, coalesce(nodes.name, '') AS node, coalesce(projects.name, '') AS project, coalesce(warnings.entity_type_code, -1), coalesce(warnings.entity_id, -1), warnings.uuid, warnings.type_code, warnings.status, warnings.first_seen_date, warnings.last_seen_date, warnings.updated_date, warnings.last_message, warnings.count FROM warnings @@ -238,6 +247,30 @@ func GetWarnings(ctx context.Context, tx *sql.Tx, filters ...WarningFilter) ([]W continue } + _, where, _ := strings.Cut(parts[0], "WHERE") + queryParts[0] += "OR" + where + } else if filter.Node != nil && filter.Status != nil && filter.ID == nil && filter.UUID == nil && filter.Project == nil && filter.TypeCode == nil && filter.EntityType == nil && filter.EntityID == nil { + args = append(args, []any{filter.Node, filter.Status}...) + if len(filters) == 1 { + sqlStmt, err = Stmt(tx, warningObjectsByNodeAndStatus) + if err != nil { + return nil, fmt.Errorf("Failed to get \"warningObjectsByNodeAndStatus\" prepared statement: %w", err) + } + + break + } + + query, err := StmtString(warningObjectsByNodeAndStatus) + if err != nil { + return nil, fmt.Errorf("Failed to get \"warningObjects\" prepared statement: %w", err) + } + + parts := strings.SplitN(query, "ORDER BY", 2) + if i == 0 { + copy(queryParts[:], parts) + continue + } + _, where, _ := strings.Cut(parts[0], "WHERE") queryParts[0] += "OR" + where } else if filter.UUID != nil && filter.ID == nil && filter.Project == nil && filter.Node == nil && filter.TypeCode == nil && filter.EntityType == nil && filter.EntityID == nil && filter.Status == nil { diff --git a/lxd/db/db.go b/lxd/db/db.go index ef18815a734e..9d8c730518fa 100644 --- a/lxd/db/db.go +++ b/lxd/db/db.go @@ -93,6 +93,11 @@ func (n *Node) Dir() string { return n.dir } +// DqliteDir returns the global database directory used by dqlite. +func (n *Node) DqliteDir() string { + return filepath.Join(n.Dir(), "global") +} + // Transaction creates a new NodeTx object and transactionally executes the // node-level database interactions invoked by the given function. If the // function returns no error, all database changes are committed to the @@ -431,8 +436,8 @@ func begin(db *sql.DB) (*sql.Tx, error) { time.Sleep(30 * time.Millisecond) } - logger.Debugf("DbBegin: DB still locked") - logger.Debugf(logger.GetStack()) + logger.Debug("DbBegin: DB still locked") + logger.Debug(logger.GetStack()) return nil, fmt.Errorf("DB is locked") } diff --git a/lxd/db/images.go b/lxd/db/images.go index 252a6790430e..16e13de31c84 100644 --- a/lxd/db/images.go +++ b/lxd/db/images.go @@ -401,6 +401,7 @@ func (c *ClusterTx) GetImageByFingerprintPrefix(ctx context.Context, fingerprint image.Cached = object.Cached image.Public = object.Public image.AutoUpdate = object.AutoUpdate + image.Project = object.Project err = c.imageFill( ctx, object.ID, &image, diff --git a/lxd/db/instances.go b/lxd/db/instances.go index dffe6b41f97d..41d422d16b3e 100644 --- a/lxd/db/instances.go +++ b/lxd/db/instances.go @@ -17,6 +17,7 @@ import ( "github.com/canonical/lxd/lxd/instance/instancetype" "github.com/canonical/lxd/shared" "github.com/canonical/lxd/shared/api" + "github.com/canonical/lxd/shared/osarch" ) // InstanceArgs is a value object holding all db-related details about an instance. @@ -44,6 +45,37 @@ type InstanceArgs struct { ExpiryDate time.Time } +// ToAPI converts InstanceArgs to api.Instance. +// The returned Instance has ExpandedConfig and ExpandedDevices set. +func (i *InstanceArgs) ToAPI() (*api.Instance, error) { + var err error + + rslt := &api.Instance{ + Name: i.Name, + Description: i.Description, + + CreatedAt: i.CreationDate, + LastUsedAt: i.LastUsedDate, + Location: i.Node, + Type: i.Type.String(), + Project: i.Project, + Ephemeral: i.Ephemeral, + Stateful: i.Stateful, + + Config: i.Config, + Devices: i.Devices.CloneNative(), + } + + rslt.Architecture, err = osarch.ArchitectureName(i.Architecture) + + rslt.Profiles = make([]string, 0, len(i.Profiles)) + for _, profile := range i.Profiles { + rslt.Profiles = append(rslt.Profiles, profile.Name) + } + + return rslt, err +} + // GetInstanceNames returns the names of all containers the given project. func (c *ClusterTx) GetInstanceNames(ctx context.Context, project string) ([]string, error) { stmt := ` @@ -534,6 +566,18 @@ func (c *ClusterTx) instanceProfilesFill(ctx context.Context, snapshotsMode bool return fmt.Errorf("Failed loading profiles: %w", err) } + // Get all the profile configs. + profileConfigs, err := cluster.GetConfig(context.TODO(), c.Tx(), "profile") + if err != nil { + return fmt.Errorf("Failed loading profile configs: %w", err) + } + + // Get all the profile devices. + profileDevices, err := cluster.GetDevices(context.TODO(), c.Tx(), "profile") + if err != nil { + return fmt.Errorf("Failed loading profile devices: %w", err) + } + // Populate profilesByID map entry for referenced profiles. // This way we only call ToAPI() on the profiles actually referenced by the instances in // the list, which can reduce the number of queries run. @@ -543,7 +587,7 @@ func (c *ClusterTx) instanceProfilesFill(ctx context.Context, snapshotsMode bool continue } - profilesByID[profile.ID], err = profile.ToAPI(context.TODO(), c.tx) + profilesByID[profile.ID], err = profile.ToAPI(context.TODO(), c.tx, profileConfigs, profileDevices) if err != nil { return err } @@ -991,7 +1035,7 @@ ORDER BY instances_snapshots.creation_date, instances_snapshots.id // GetNextInstanceSnapshotIndex returns the index that the next snapshot of the // instance with the given name and pattern should have. -func (c *ClusterTx) GetNextInstanceSnapshotIndex(ctx context.Context, project string, name string, pattern string) int { +func (c *ClusterTx) GetNextInstanceSnapshotIndex(ctx context.Context, project string, name string, pattern string) (nextIndex int) { q := ` SELECT instances_snapshots.name FROM instances_snapshots @@ -1009,8 +1053,6 @@ ORDER BY instances_snapshots.creation_date, instances_snapshots.id return 0 } - max := 0 - for _, r := range results { snapOnlyName, ok := r[0].(string) if !ok { @@ -1025,12 +1067,12 @@ ORDER BY instances_snapshots.creation_date, instances_snapshots.id continue } - if num >= max { - max = num + 1 + if num >= nextIndex { + nextIndex = num + 1 } } - return max + return nextIndex } // DeleteReadyStateFromLocalInstances deletes the volatile.last_state.ready config key diff --git a/lxd/db/node.go b/lxd/db/node.go index a0e4fc69dec8..9c6bcc4e9f29 100644 --- a/lxd/db/node.go +++ b/lxd/db/node.go @@ -99,9 +99,9 @@ func (n NodeInfo) ToAPI(ctx context.Context, tx *ClusterTx, args NodeInfoArgs) ( // From local database. var raftNode *RaftNode - for _, node := range args.RaftNodes { + for i, node := range args.RaftNodes { if node.Address == n.Address { - raftNode = &node + raftNode = &args.RaftNodes[i] break } } @@ -156,12 +156,12 @@ func (n NodeInfo) ToAPI(ctx context.Context, tx *ClusterTx, args NodeInfoArgs) ( result.Message = fmt.Sprintf("No heartbeat for %s (%s)", time.Since(n.Heartbeat), n.Heartbeat) } else { // Check if up to date. - n, err := util.CompareVersions(maxVersion, n.Version()) + cmp, err := util.CompareVersions(maxVersion, n.Version()) if err != nil { return nil, err } - if n == 1 { + if cmp == 1 { result.Status = "Blocked" result.Message = "Needs updating to newer version" } @@ -241,7 +241,7 @@ func (c *ClusterTx) GetNodeWithID(ctx context.Context, nodeID int) (NodeInfo, er // GetPendingNodeByAddress returns the pending node with the given network address. func (c *ClusterTx) GetPendingNodeByAddress(ctx context.Context, address string) (NodeInfo, error) { null := NodeInfo{} - nodes, err := c.nodes(ctx, true /*pending */, "address=?", address) + nodes, err := c.nodes(ctx, true /* pending */, "address=?", address) if err != nil { return null, err } @@ -374,18 +374,18 @@ func (c *ClusterTx) GetNodesCount(ctx context.Context) (int, error) { // RenameNode changes the name of an existing node. // // Return an error if a node with the same name already exists. -func (c *ClusterTx) RenameNode(ctx context.Context, old string, new string) error { - count, err := query.Count(ctx, c.tx, "nodes", "name=?", new) +func (c *ClusterTx) RenameNode(ctx context.Context, oldName string, newName string) error { + count, err := query.Count(ctx, c.tx, "nodes", "name=?", newName) if err != nil { return fmt.Errorf("failed to check existing nodes: %w", err) } if count != 0 { - return api.StatusErrorf(http.StatusConflict, "A cluster member already exists with name %q", new) + return api.StatusErrorf(http.StatusConflict, "A cluster member already exists with name %q", newName) } stmt := `UPDATE nodes SET name=? WHERE name=?` - result, err := c.tx.Exec(stmt, new, old) + result, err := c.tx.Exec(stmt, newName, oldName) if err != nil { return fmt.Errorf("failed to update node name: %w", err) } diff --git a/lxd/db/openfga/openfga.go b/lxd/db/openfga/openfga.go index e5c26c2d8441..b92cf9077404 100644 --- a/lxd/db/openfga/openfga.go +++ b/lxd/db/openfga/openfga.go @@ -15,6 +15,7 @@ import ( "github.com/canonical/lxd/lxd/db" "github.com/canonical/lxd/lxd/db/cluster" "github.com/canonical/lxd/lxd/db/query" + "github.com/canonical/lxd/shared" "github.com/canonical/lxd/shared/api" "github.com/canonical/lxd/shared/entity" ) @@ -97,7 +98,7 @@ func (o *openfgaStore) Read(ctx context.Context, s string, key *openfgav1.TupleK return nil, fmt.Errorf("Read: Failed to parse entity URL %q: %w", entityURL, err) } - urlEntityType, projectName, _, _, err := entity.ParseURL(*u) + urlEntityType, projectName, location, pathArgs, err := entity.ParseURL(*u) if err != nil { return nil, fmt.Errorf("Failed to parse entity URL %q: %w", entityURL, err) } @@ -111,50 +112,65 @@ func (o *openfgaStore) Read(ctx context.Context, s string, key *openfgav1.TupleK return nil, err } - var tuples []*openfgav1.Tuple - switch relation { - case "project": + // We're returning a single relation between a parent and child. Set up the tuple key with the object and relation. + tupleKey := &openfgav1.TupleKey{ + Object: obj, + Relation: relation, + } + + // Our parent-child relations are always named as the entity type of the parent. + relationEntityType := entity.Type(relation) + switch relationEntityType { + case entity.TypeProject: // If the entity type is not project specific but we're looking for project relations then the input is invalid. // (Likely an error in the authorization driver). if !requiresProject { return nil, fmt.Errorf("Received unexpected query, entities of type %q do not have a project relation", entityType) } - // Return a tuple relating the object to its parent project. - tuples = []*openfgav1.Tuple{ - { - Key: &openfgav1.TupleKey{ - Object: obj, - Relation: relation, - User: fmt.Sprintf("%s:%s", entity.TypeProject, entity.ProjectURL(projectName).String()), - }, - }, - } + // Set the user to relate the object (child) to the user (parent). In this case a parent project. + tupleKey.User = string(entity.TypeProject) + ":" + entity.ProjectURL(projectName).String() - case "server": + case entity.TypeServer: // If the entity type is project specific but we're looking for server relations then the input is invalid. // (Likely an error in the authorization driver). if requiresProject { return nil, fmt.Errorf("Received unexpected query, entities of type %q do not have a server relation", entityType) } - // Return a tuple relating the object to the server. - tuples = []*openfgav1.Tuple{ - { - Key: &openfgav1.TupleKey{ - Object: obj, - Relation: relation, - User: fmt.Sprintf("%s:%s", entity.TypeServer, entity.ServerURL().String()), - }, - }, + // Set the user to relate the object (child) to the user (parent). In this case a parent server. + tupleKey.User = string(entity.TypeServer) + ":" + entity.ServerURL().String() + + case entity.TypeInstance: + if !shared.ValueInSlice(entityType, []entity.Type{entity.TypeInstanceBackup, entity.TypeInstanceSnapshot}) { + return nil, fmt.Errorf("Received unexpected query, entities of type %q do not have an instance relation", entityType) + } + + if len(pathArgs) < 1 { + return nil, fmt.Errorf("Received invalid entity URL %q with %q parent-child relation", entityURL, relation) } + // Set the user to relate the object (child) to the user (parent). In this case a parent instance. + tupleKey.User = string(entity.TypeInstance) + ":" + entity.InstanceURL(projectName, pathArgs[0]).String() + + case entity.TypeStorageVolume: + if !shared.ValueInSlice(entityType, []entity.Type{entity.TypeStorageVolumeBackup, entity.TypeStorageVolumeSnapshot}) { + return nil, fmt.Errorf("Received unexpected query, entities of type %q do not have an instance relation", entityType) + } + + if len(pathArgs) < 3 { + return nil, fmt.Errorf("Received invalid entity URL %q with %q parent-child relation", entityURL, relation) + } + + // Set the user to relate the object (child) to the user (parent). In this case a parent storage volume. + tupleKey.User = string(entity.TypeStorageVolume) + ":" + entity.StorageVolumeURL(projectName, location, pathArgs[0], pathArgs[1], pathArgs[2]).String() + default: // Return an error if we get an unexpected relation. return nil, fmt.Errorf("Relation %q not supported", relation) } - return storage.NewStaticTupleIterator(tuples), nil + return storage.NewStaticTupleIterator([]*openfgav1.Tuple{{Key: tupleKey}}), nil } // ReadUserTuple reads a single tuple from the store. @@ -192,8 +208,9 @@ func (o *openfgaStore) ReadUserTuple(ctx context.Context, store string, tk *open // ReadUsersetTuples is called on check requests. It is used to read all the "users" that have a given relation to // a given object. In this context, the "user" may not be the identity making requests to LXD. In OpenFGA, a "user" // is any entity that can be related to an object (https://openfga.dev/docs/concepts#what-is-a-user). For example, in -// our model, `project` can be related to `instance` via a `project` relation, so `project:/1.0/projects/default` could -// be considered a user. The opposite is not true, so an `instance` cannot be a user. +// our model, `project` can be related to `profile` via a `project` relation, so `project:/1.0/projects/default` could +// be considered a user. The opposite is not true, so an `profile` cannot be a user. An `instance` can be related to an +// `instance_snapshot` via the `instance` relation, so an instance can be considered a `user`. // // Observations: // - The input filter always has an object and a relation. @@ -371,13 +388,14 @@ func (o *openfgaStore) ReadStartingWithUser(ctx context.Context, store string, f return nil, fmt.Errorf("ReadStartingWithUser: Unexpected user entity URL %q: %w", userURL, err) } + // Our parent-child relations are always named as the entity type of the parent. + relationEntityType := entity.Type(filter.Relation) + // If the relation is "project" or "server", we are listing all resources under the project/server. - if filter.Relation == "project" || filter.Relation == "server" { - // Expect that the user entity type is expected for the relation. - if filter.Relation == "project" && userEntityType != entity.TypeProject { - return nil, fmt.Errorf("ReadStartingWithUser: Cannot list project relations for non-project entities") - } else if filter.Relation == "server" && userEntityType != entity.TypeServer { - return nil, fmt.Errorf("ReadStartingWithUser: Cannot list server relations for non-server entities") + if shared.ValueInSlice(relationEntityType, []entity.Type{entity.TypeProject, entity.TypeServer, entity.TypeInstance, entity.TypeStorageVolume}) { + if filter.Relation != string(userEntityType) { + // Expect that the user entity type is expected for the relation. + return nil, fmt.Errorf("ReadStartingWithUser: Relation %q is not valid for entities of type %q", filter.Relation, userEntityType) } // Get the entity URLs with the given type and project (if set). @@ -397,23 +415,57 @@ func (o *openfgaStore) ReadStartingWithUser(ctx context.Context, store string, f // Compose the expected tuples relating the server/project to the entities. var tuples []*openfgav1.Tuple for _, entityURL := range entityURLs[entityType] { - if filter.Relation == "project" { - tuples = append(tuples, &openfgav1.Tuple{ - Key: &openfgav1.TupleKey{ - Object: fmt.Sprintf("%s:%s", entityType, entityURL.String()), - Relation: "project", - User: fmt.Sprintf("%s:%s", entity.TypeProject, entity.ProjectURL(projectName)), - }, - }) - } else { - tuples = append(tuples, &openfgav1.Tuple{ - Key: &openfgav1.TupleKey{ - Object: fmt.Sprintf("%s:%s", entityType, entityURL.String()), - Relation: "server", - User: fmt.Sprintf("%s:%s", entity.TypeServer, entity.ServerURL()), - }, - }) + tupleKey := &openfgav1.TupleKey{Object: string(entityType) + ":" + entityURL.String(), Relation: filter.Relation} + switch relationEntityType { + case entity.TypeProject: + tupleKey.User = string(entity.TypeProject) + ":" + entity.ProjectURL(projectName).String() + case entity.TypeServer: + tupleKey.User = string(entity.TypeServer) + ":" + entity.ServerURL().String() + case entity.TypeInstance: + _, projectName, _, pathArgs, err := entity.ParseURL(entityURL.URL) + if err != nil { + return nil, fmt.Errorf("ReadStartingWithUser: Received invalid URL: %w", err) + } + + if len(pathArgs) < 1 { + return nil, fmt.Errorf("Received invalid object URL %q with %q parent-child relation", entityURL, filter.Relation) + } + + if len(userURLPathArguments) < 1 { + return nil, fmt.Errorf("Received invalid user URL %q with %q parent-child relation", userURL, filter.Relation) + } + + if userURLPathArguments[0] != pathArgs[0] { + // We're returning the parent instance of snapshots or backups here. + // It's only a parent if it has the same instance name. + continue + } + + tupleKey.User = string(entity.TypeInstance) + ":" + entity.InstanceURL(projectName, pathArgs[0]).String() + case entity.TypeStorageVolume: + _, projectName, location, pathArgs, err := entity.ParseURL(entityURL.URL) + if err != nil { + return nil, fmt.Errorf("ReadStartingWithUser: Received invalid URL: %w", err) + } + + if len(pathArgs) < 3 { + return nil, fmt.Errorf("Received invalid object URL %q with %q parent-child relation", entityURL, filter.Relation) + } + + if len(userURLPathArguments) < 3 { + return nil, fmt.Errorf("Received invalid user URL %q with %q parent-child relation", userURL, filter.Relation) + } + + if userURLPathArguments[0] != pathArgs[0] && userURLPathArguments[1] != pathArgs[1] && userURLPathArguments[2] != pathArgs[2] { + // We're returning the parent storage volume of snapshots or backups here. + // It's only a parent if it has the same storage pool, volume type, and volume name. + continue + } + + tupleKey.User = string(entity.TypeStorageVolume) + ":" + entity.StorageVolumeURL(projectName, location, pathArgs[0], pathArgs[1], pathArgs[2]).String() } + + tuples = append(tuples, &openfgav1.Tuple{Key: tupleKey}) } return storage.NewStaticTupleIterator(tuples), nil diff --git a/lxd/db/profiles.go b/lxd/db/profiles.go index f657cfe8d3bc..e03cb74b946b 100644 --- a/lxd/db/profiles.go +++ b/lxd/db/profiles.go @@ -60,7 +60,7 @@ func (c *ClusterTx) GetProfile(ctx context.Context, project, name string) (int64 profile := profiles[0] id := int64(profile.ID) - result, err := profile.ToAPI(ctx, c.tx) + result, err := profile.ToAPI(ctx, c.tx, nil, nil) if err != nil { return -1, nil, err } @@ -77,8 +77,20 @@ func (c *ClusterTx) GetProfiles(ctx context.Context, projectName string, profile return nil, err } + // Get all the profile configs. + profileConfigs, err := cluster.GetConfig(ctx, c.Tx(), "profile") + if err != nil { + return nil, err + } + + // Get all the profile devices. + profileDevices, err := cluster.GetDevices(ctx, c.Tx(), "profile") + if err != nil { + return nil, err + } + for i, profile := range dbProfiles { - apiProfile, err := profile.ToAPI(ctx, c.tx) + apiProfile, err := profile.ToAPI(ctx, c.tx, profileConfigs, profileDevices) if err != nil { return nil, err } diff --git a/lxd/db/storage_pools.go b/lxd/db/storage_pools.go index 83876edec5df..a41b638e5b3e 100644 --- a/lxd/db/storage_pools.go +++ b/lxd/db/storage_pools.go @@ -124,7 +124,7 @@ func (c *ClusterTx) UpdateStoragePoolAfterNodeJoin(poolID, nodeID int64) error { values := []any{poolID, nodeID, StoragePoolCreated} _, err := query.UpsertObject(c.tx, "storage_pools_nodes", columns, values) if err != nil { - return fmt.Errorf("failed to add storage pools node entry: %w", err) + return fmt.Errorf("failed to add storage pools cluster member entry: %w", err) } return nil @@ -138,11 +138,11 @@ func (c *ClusterTx) UpdateCephStoragePoolAfterNodeJoin(ctx context.Context, pool stmt := "SELECT node_id FROM storage_pools_nodes WHERE storage_pool_id=?" nodeIDs, err := query.SelectIntegers(ctx, c.tx, stmt, poolID) if err != nil { - return fmt.Errorf("failed to fetch IDs of nodes with ceph pool: %w", err) + return fmt.Errorf("failed to fetch IDs of cluster members with ceph pool: %w", err) } if len(nodeIDs) == 0 { - return fmt.Errorf("ceph pool is not linked to any node") + return fmt.Errorf("ceph pool is not linked to any cluster member") } otherNodeID := nodeIDs[0] @@ -154,7 +154,7 @@ INSERT INTO storage_volumes(name, storage_pool_id, node_id, type, description, p FROM storage_volumes WHERE storage_pool_id=? AND node_id=? `, nodeID, poolID, otherNodeID) if err != nil { - return fmt.Errorf("failed to create node ceph volumes: %w", err) + return fmt.Errorf("failed to create cluster member ceph volumes: %w", err) } // Create entries of all the ceph volumes configs for the new node. @@ -164,12 +164,12 @@ SELECT id FROM storage_volumes WHERE storage_pool_id=? AND node_id=? ` volumeIDs, err := query.SelectIntegers(ctx, c.tx, stmt, poolID, nodeID) if err != nil { - return fmt.Errorf("failed to get joining node's ceph volume IDs: %w", err) + return fmt.Errorf("failed to get joining cluster member's ceph volume IDs: %w", err) } otherVolumeIDs, err := query.SelectIntegers(ctx, c.tx, stmt, poolID, otherNodeID) if err != nil { - return fmt.Errorf("failed to get other node's ceph volume IDs: %w", err) + return fmt.Errorf("failed to get other cluster member's ceph volume IDs: %w", err) } if len(volumeIDs) != len(otherVolumeIDs) { // Quick check. @@ -532,7 +532,7 @@ WHERE storage_pools.id = ? AND storage_pools.state = ? } if len(missing) > 0 { - return nil, fmt.Errorf("Pool not defined on nodes: %s", strings.Join(missing, ", ")) + return nil, fmt.Errorf("Pool not defined on cluster members: %s", strings.Join(missing, ", ")) } configs := map[string]map[string]string{} diff --git a/lxd/db/storage_pools_test.go b/lxd/db/storage_pools_test.go index 3940bd14821b..6296d9f84ca2 100644 --- a/lxd/db/storage_pools_test.go +++ b/lxd/db/storage_pools_test.go @@ -87,7 +87,7 @@ func TestStoragePoolsCreatePending(t *testing.T) { // The initial node (whose name is 'none' by default) is missing. _, err = tx.GetStoragePoolNodeConfigs(context.Background(), poolID) - require.EqualError(t, err, "Pool not defined on nodes: none") + require.EqualError(t, err, "Pool not defined on cluster members: none") config = map[string]string{"source": "/egg"} err = tx.CreatePendingStoragePool(context.Background(), "none", "pool1", "dir", config) diff --git a/lxd/db/storage_volumes.go b/lxd/db/storage_volumes.go index 10f17a912c62..4b3ca03d6db2 100644 --- a/lxd/db/storage_volumes.go +++ b/lxd/db/storage_volumes.go @@ -668,7 +668,7 @@ func (c *ClusterTx) storageVolumeConfigGet(ctx context.Context, volumeID int64, // // Note, the code below doesn't deal with snapshots of snapshots. // To do that, we'll need to weed out based on # slashes in names. -func (c *ClusterTx) GetNextStorageVolumeSnapshotIndex(ctx context.Context, pool, name string, typ int, pattern string) int { +func (c *ClusterTx) GetNextStorageVolumeSnapshotIndex(ctx context.Context, pool, name string, typ int, pattern string) (nextIndex int) { remoteDrivers := StorageRemoteDriverNames() q := fmt.Sprintf(` @@ -693,8 +693,6 @@ SELECT storage_volumes_snapshots.name FROM storage_volumes_snapshots return 0 } - max := 0 - for _, r := range results { substr, ok := r[0].(string) if !ok { @@ -709,12 +707,12 @@ SELECT storage_volumes_snapshots.name FROM storage_volumes_snapshots continue } - if num >= max { - max = num + 1 + if num >= nextIndex { + nextIndex = num + 1 } } - return max + return nextIndex } // Updates the description of a storage volume. diff --git a/lxd/device/cdi/configure.go b/lxd/device/cdi/configure.go new file mode 100644 index 000000000000..cf09b3a06981 --- /dev/null +++ b/lxd/device/cdi/configure.go @@ -0,0 +1,364 @@ +package cdi + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "golang.org/x/sys/unix" + "tags.cncf.io/container-device-interface/specs-go" + + "github.com/canonical/lxd/lxd/instance" + "github.com/canonical/lxd/lxd/state" + "github.com/canonical/lxd/shared" + "github.com/canonical/lxd/shared/logger" +) + +// specDevToNativeDev builds a list of unix-char devices to be created from a CDI spec. +func specDevToNativeDev(configDevices *ConfigDevices, d specs.DeviceNode) error { + if d.Path == "" { + return fmt.Errorf("Device path is empty in the CDI device node: %v", d) + } + + hostPath := d.HostPath + if hostPath == "" { + hostPath = d.Path // When the hostPath is empty, the path is the device path in the container. + } + + if d.Major == 0 || d.Minor == 0 { + stat := unix.Stat_t{} + err := unix.Stat(hostPath, &stat) + if err != nil { + return err + } + + d.Major = int64(unix.Major(uint64(stat.Rdev))) + d.Minor = int64(unix.Minor(uint64(stat.Rdev))) + } + + configDevices.UnixCharDevs = append(configDevices.UnixCharDevs, map[string]string{"type": "unix-char", "source": hostPath, "path": d.Path, "major": fmt.Sprintf("%d", d.Major), "minor": fmt.Sprintf("%d", d.Minor)}) + return nil +} + +// specMountToNativeDev builds a list of disk mounts to be created from a CDI spec. +func specMountToNativeDev(configDevices *ConfigDevices, cdiID ID, mounts []*specs.Mount) ([]SymlinkEntry, error) { + if len(mounts) == 0 { + return nil, fmt.Errorf("CDI mounts are empty") + } + + indirectSymlinks := make([]SymlinkEntry, 0) + var chosenOpts []string + + rootPath := "" + if shared.InSnap() { + rootPath = "/var/lib/snapd/hostfs" + } + + for _, mount := range mounts { + if mount.HostPath == "" || mount.ContainerPath == "" { + return nil, fmt.Errorf("The hostPath or containerPath is empty in the CDI mount: %v", *mount) + } + + chosenOpts = []string{} + for _, opt := range mount.Options { + if !shared.ValueInSlice(opt, chosenOpts) { + chosenOpts = append(chosenOpts, opt) + } + } + + chosenOptsStr := strings.Join(chosenOpts, ",") + + // mount.HostPath can be a symbolic link, so we need to evaluate it + evaluatedHostPath, err := filepath.EvalSymlinks(mount.HostPath) + if err != nil { + return nil, err + } + + if evaluatedHostPath != mount.HostPath && mount.ContainerPath == strings.TrimPrefix(mount.HostPath, rootPath) { + indirectSymlinks = append(indirectSymlinks, SymlinkEntry{Target: strings.TrimPrefix(evaluatedHostPath, rootPath), Link: mount.ContainerPath}) + mount.ContainerPath = strings.TrimPrefix(evaluatedHostPath, rootPath) + } + + configDevices.BindMounts = append( + configDevices.BindMounts, + map[string]string{ + "type": "disk", + "source": evaluatedHostPath, + "path": mount.ContainerPath, + "raw.mount.options": chosenOptsStr, + }, + ) + } + + // If the user desires to run a nested docker container inside a LXD container, + // the Tegra CSV files also need to be mounted so that the NVIDIA docker runtime + // can be auto-enabled as 'csv' mode. + if cdiID.Vendor == NVIDIA && cdiID.Class == IGPU { + tegraCSVFilesCandidates := defaultNvidiaTegraCSVFiles(rootPath) + tegraCSVFiles := make([]string, 0) + for _, candidate := range tegraCSVFilesCandidates { + _, err := os.Stat(candidate) + if err == nil { + tegraCSVFiles = append(tegraCSVFiles, candidate) + } else if os.IsNotExist(err) { + continue + } else { + return nil, err + } + } + + if len(tegraCSVFiles) == 0 { + return nil, fmt.Errorf("No CSV files detected for Tegra iGPU") + } + + for _, tegraFile := range tegraCSVFiles { + configDevices.BindMounts = append( + configDevices.BindMounts, + map[string]string{ + "type": "disk", + "source": tegraFile, + "path": strings.TrimPrefix(tegraFile, rootPath), + "readonly": "true", + }, + ) + } + } + + return indirectSymlinks, nil +} + +// specHookToLXDCDIHook will translate a hook from a CDI spec into an entry in a `Hooks`. +// Some CDI hooks are not relevant for LXD and will be ignored. +func specHookToLXDCDIHook(hook *specs.Hook, hooks *Hooks, l logger.Logger) error { + if hook == nil { + l.Warn("CDI hook is nil") + return nil + } + + rootPath := "" + if shared.InSnap() { + rootPath = "/var/lib/snapd/hostfs" + } + + if len(hook.Args) < 3 { + return fmt.Errorf("Not enough arguments for CDI hook: %v", hook.Args) + } + + processCreateSymlinksHook := func(args []string) error { + // The list of arguments is either + // `--link :: --link :: ...` + // or `--link=:: --link=:: ...` + // and we need to handle both cases as they are both valid. + var targetWithLink string + for i := 0; i < len(args); i += 1 { + if args[i] == "--link" { + continue + } + + if strings.Contains(args[i], "=") { + // We can assume the arg is `--link=::` + splitted := strings.Split(args[i], "=") + if len(splitted) != 2 { + return fmt.Errorf("Invalid symlink arg %q", args[i]) + } + + targetWithLink = splitted[1] + } else { + // We can assume the arg is `::` + targetWithLink = args[i] + } + + entry := strings.Split(targetWithLink, "::") + if len(entry) != 2 { + return fmt.Errorf("Invalid symlink entry %q", targetWithLink) + } + + // `Link` is always an absolute path and `Target` (a `Link` points to a `Target`) is relative + // to the `Link` location in the CDI spec. A resolving operation will be needed to have the absolute + // path of the `Target` + hooks.Symlinks = append(hooks.Symlinks, SymlinkEntry{Target: strings.TrimPrefix(entry[0], rootPath), Link: strings.TrimPrefix(entry[1], rootPath)}) + } + + return nil + } + + processUpdateLdcacheHook := func(args []string) error { + // As above, the list of arguments is either + // `--folder --folder ...` + // or `--folder= --folder= ...` + // and we need to handle both cases as they are both valid. + var folder string + for i := 0; i < len(args); i += 1 { + if args[i] == "--folder" { + continue + } + + if strings.Contains(args[i], "=") { + // We can assume the arg is `--folder=` + splitted := strings.Split(args[i], "=") + if len(splitted) != 2 { + return fmt.Errorf("Invalid CDI folder arg %q", args[i]) + } + + folder = splitted[1] + } else { + // We can assume the arg is `` + folder = args[i] + } + + hooks.LDCacheUpdates = append(hooks.LDCacheUpdates, folder) + } + + return nil + } + + processHooks := map[string]func([]string) error{ + "create-symlinks": processCreateSymlinksHook, + "update-ldcache": processUpdateLdcacheHook, + } + + for i, arg := range hook.Args { + process, supported := processHooks[arg] + if supported { + if len(hook.Args) > i+1 { + // We pass in only the arguments, + // not the hook name which is not relevant in the process functions + return process(hook.Args[i+1:]) + } + } + } + + return nil +} + +// applyContainerEdits updates the configDevices and the hooks with CDI "container edits" +// (edits are user space libraries to mount and char device to pass to the container). +func applyContainerEdits(edits specs.ContainerEdits, configDevices *ConfigDevices, hooks *Hooks, existingMounts []*specs.Mount, l logger.Logger) ([]*specs.Mount, error) { + for _, d := range edits.DeviceNodes { + if d == nil { + l.Warn("One CDI DeviceNode is nil") + continue + } + + err := specDevToNativeDev(configDevices, *d) + if err != nil { + return nil, err + } + } + + for _, hook := range edits.Hooks { + err := specHookToLXDCDIHook(hook, hooks, l) + if err != nil { + return nil, err + } + } + + return append(existingMounts, edits.Mounts...), nil +} + +// GenerateFromCDI does several things: +// +// 1. It generates a CDI specification from a CDI ID and an instance. +// According the the specified 'vendor', 'class' and 'name' (this assembled triplet is called a fully-qualified CDI ID. We'll just call it ID in the context of this package), the CDI specification is generated. +// The CDI specification is a JSON-like format. It is divided into two parts: the 'specific device' configuration and the 'general device' configuration. +// - The 'specific device' configuration: this is a list of 'container edits' that can be added to the container runtime. +// According to the CDI ID (vendor, class, name), we only select the 'container edits' that matches the CDI ID. +// The 'container edits' are a list of device nodes, hooks and mounts that must be added to the container runtime. +// - The 'general device' configuration: this is a single 'container edits' entry runtime that must be passed to the container runtime in ant case. Which unix char devices need to be passed +// (e.g, special GPU memory controller device, etc.)? Which user space libraries need to be mounted (e.g, CUDA libraries for NVIDIA, etc.)? +// Which hooks need to be executed (e.g, symlinks to create, folder entries to add to ldcache, etc.))? +// In our case, these edits will be interpreted either as disk or unix char mounts passed to the container. +// The hooks will be centralized in a single resource file that will be read and executed as a LXC `lxc.hook.mount` hook, +// through LXD's `callhook` command. +// 2. We first process the 'specific device' configuration: we convert this information into a map of devices +// (keyed by their path given in the spec, it mapped to a map of device properties). We also collect the specific mounts (but we do not process them yet) and hooks. +// 3. We then process the 'general device' configuration in the same fashion. +// 4. Now we process all the mounts we collected from the spec in order to turn them into disk devices. +// This operations generate a side effect: it generates a list of indirect symlinks (see `specMountToNativeDev`) +// 5. Merge all the hooks (direct + indirect) into a single list of hooks. +func GenerateFromCDI(s *state.State, inst instance.Instance, cdiID ID, l logger.Logger) (*ConfigDevices, *Hooks, error) { + // 1. Generate the CDI specification + spec, err := generateSpec(s, cdiID, inst) + if err != nil { + return nil, nil, fmt.Errorf("Failed to generate CDI spec: %w", err) + } + + // Initialize the hooks as empty + hooks := &Hooks{ContainerRootFS: inst.RootfsPath()} + mounts := make([]*specs.Mount, 0) + configDevices := &ConfigDevices{UnixCharDevs: make([]map[string]string, 0), BindMounts: make([]map[string]string, 0)} + + // 2. Process the specific device configuration + lookedUpDevs := make(map[string]struct{}) + for _, device := range spec.Devices { + if cdiID.Name == All { + // When 'all' is selected as a CDI identifier, + // we should make the difference between CDI device index that are integer and the ones represented by a UUID + // that could contain the same cards. Having a lookup map avoid to add the same devices multiple times. + devToAdd := true + for _, devNode := range device.ContainerEdits.DeviceNodes { + _, ok := lookedUpDevs[devNode.Path] + if ok { + devToAdd = false + break + } + + lookedUpDevs[devNode.Path] = struct{}{} + } + + if devToAdd { + mounts, err = applyContainerEdits(device.ContainerEdits, configDevices, hooks, mounts, l) + if err != nil { + return nil, nil, err + } + } + } else { + if device.Name == cdiID.Name { + mounts, err = applyContainerEdits(device.ContainerEdits, configDevices, hooks, mounts, l) + if err != nil { + return nil, nil, err + } + + break + } + } + } + + // 3. Process general device configuration + mounts, err = applyContainerEdits(spec.ContainerEdits, configDevices, hooks, mounts, l) + if err != nil { + return nil, nil, err + } + + // 4. Process the mounts + indirectSymlinks, err := specMountToNativeDev(configDevices, cdiID, mounts) + if err != nil { + return nil, nil, err + } + + // 5. merge the indirectSymlinks to the list of symlinks to be create in the hooks + hooks.Symlinks = append(hooks.Symlinks, indirectSymlinks...) + return configDevices, hooks, nil +} + +// ReloadConfigDevicesFromDisk reads the paths to the CDI configuration devices file from the disk. +// This is useful in order to cache the CDI configuration devices file so that wee don't have to re-generate a CDI spec whhen stopping the container. +func ReloadConfigDevicesFromDisk(pathsToConfigDevicesFilePath string) (ConfigDevices, error) { + // Load the config devices file from the disk + pathsToCDIConfigDevicesFile, err := os.Open(pathsToConfigDevicesFilePath) + if err != nil { + return ConfigDevices{}, fmt.Errorf("Failed to open the paths to CDI conf file at %q: %w", pathsToConfigDevicesFilePath, err) + } + + defer pathsToCDIConfigDevicesFile.Close() + + configDevices := &ConfigDevices{} + err = json.NewDecoder(pathsToCDIConfigDevicesFile).Decode(configDevices) + if err != nil { + return ConfigDevices{}, fmt.Errorf("Failed to decode the paths to CDI conf file at %q: %w", pathsToConfigDevicesFilePath, err) + } + + return *configDevices, nil +} diff --git a/lxd/device/cdi/hooks.go b/lxd/device/cdi/hooks.go new file mode 100644 index 000000000000..bfd0e33cf75a --- /dev/null +++ b/lxd/device/cdi/hooks.go @@ -0,0 +1,44 @@ +package cdi + +const ( + // CDIHookDefinitionKey is used to reference a CDI hook definition in a run config as a file path. + // A CDI hook definition is a simple way to represent the symlinks to be created and the folder entries to add to the ld cache. + // This resource file is to be read and processed by LXD's `callhook` program. + CDIHookDefinitionKey = "cdiHookDefinitionKey" + // CDIHooksFileSuffix is the suffix for the file that contains the CDI hooks. + CDIHooksFileSuffix = "_cdi_hooks.json" + // CDIConfigDevicesFileSuffix is the suffix for the file that contains the CDI config devices. + CDIConfigDevicesFileSuffix = "_cdi_config_devices.json" + // CDIUnixPrefix is the prefix used for creating unix char devices + // (e.g. cdi.unix..). + CDIUnixPrefix = "cdi.unix" + // CDIDiskPrefix is the prefix used for creating bind mounts (or 'disk' devices) + // representing user space files required for a CDI passthrough + // (e.g. cdi.disk..). + CDIDiskPrefix = "cdi.disk" +) + +// SymlinkEntry represents a symlink entry. +type SymlinkEntry struct { + Target string `json:"target" yaml:"target"` + Link string `json:"link" yaml:"link"` +} + +// Hooks represents all the hook instructions that can be executed by +// `lxd-cdi-hook`. +type Hooks struct { + // ContainerRootFS is the path to the container's root filesystem. + ContainerRootFS string `json:"container_rootfs" yaml:"container_rootfs"` + // LdCacheUpdates is a list of entries to update the ld cache. + LDCacheUpdates []string `json:"ld_cache_updates" yaml:"ld_cache_updates"` + // SymLinks is a list of entries to create a symlink. + Symlinks []SymlinkEntry `json:"symlinks" yaml:"symlinks"` +} + +// ConfigDevices represents devices and mounts that need to be configured from a CDI specification. +type ConfigDevices struct { + // UnixCharDevs is a slice of unix-char device configuration. + UnixCharDevs []map[string]string `json:"unix_char_devs" yaml:"unix_char_devs"` + // BindMounts is a slice of mount configuration. + BindMounts []map[string]string `json:"bind_mounts" yaml:"bind_mounts"` +} diff --git a/lxd/device/cdi/id.go b/lxd/device/cdi/id.go new file mode 100644 index 000000000000..921c96565dc8 --- /dev/null +++ b/lxd/device/cdi/id.go @@ -0,0 +1,105 @@ +package cdi + +import ( + "fmt" + "net/http" + + "tags.cncf.io/container-device-interface/pkg/parser" + + "github.com/canonical/lxd/shared/api" +) + +// All represents a selection of all devices generated out of a CDI specification. +var All = "all" + +// Vendor represents the compatible CDI vendor. +type Vendor string + +const ( + // NVIDIA represents the Nvidia CDI vendor. + NVIDIA Vendor = "nvidia.com" +) + +// ToVendor converts a string to a CDI vendor. +func ToVendor(vendor string) (Vendor, error) { + switch vendor { + case string(NVIDIA): + return NVIDIA, nil + default: + return "", fmt.Errorf("Invalid CDI vendor (%q)", vendor) + } +} + +// Class represents the compatible CDI class. +type Class string + +const ( + // GPU is a single discrete GPU. + GPU Class = "gpu" + // IGPU is an integrated GPU. + IGPU Class = "igpu" + // MIG is a single MIG compatible GPU. + MIG Class = "mig" +) + +// ToClass converts a string to a CDI class. +func ToClass(c string) (Class, error) { + switch c { + case string(GPU): + return GPU, nil + case string(IGPU): + return IGPU, nil + case string(MIG): + return MIG, nil + default: + return "", fmt.Errorf("Invalid CDI class (%q)", c) + } +} + +// ID represents a Container Device Interface (CDI) identifier. +// +// +------------+-------+------------------------------------------+ +// | Vendor | Class | Name | +// +---------------------------------------------------------------+ +// | nvidia.com | gpu | [dev_idx], [dev_uuid] or `all` | +// | | mig | [dev_idx]:[mig_idx], [dev_uuid] or `all` | +// | | igpu | [dev_idx], [dev_uuid] or `all` | +// +------------+-------+------------------------------------------+ +// +// Examples: +// - nvidia.com/gpu=0 +// - nvidia.com/gpu=d1f1c76e-7a72-487e-b121-e6d2e5555dc8 +// - nvidia.com/gpu=all +// - nvidia.com/mig=0:1 +// - nvidia.com/igpu=0 +type ID struct { + Vendor Vendor + Class Class + Name string +} + +// String returns the string representation of the ID. +func (id ID) String() string { + return fmt.Sprintf("%s/%s=%s", id.Vendor, id.Class, id.Name) +} + +// ToCDI converts a string identifier to a CDI ID. +// Returns api.StatusError with status code set to http.StatusBadRequest if unable to parse CDI ID. +func ToCDI(id string) (*ID, error) { + vendor, class, name, err := parser.ParseQualifiedName(id) + if err != nil { + return nil, api.StatusErrorf(http.StatusBadRequest, "Invalid CDI ID: %w", err) + } + + vendorType, err := ToVendor(vendor) + if err != nil { + return nil, err + } + + classType, err := ToClass(class) + if err != nil { + return nil, err + } + + return &ID{Vendor: vendorType, Class: classType, Name: name}, nil +} diff --git a/lxd/device/cdi/id_test.go b/lxd/device/cdi/id_test.go new file mode 100644 index 000000000000..2c1b2445872e --- /dev/null +++ b/lxd/device/cdi/id_test.go @@ -0,0 +1,97 @@ +package cdi + +import ( + "reflect" + "testing" +) + +func TestToVendor(t *testing.T) { + tests := []struct { + name string + input string + want Vendor + wantErr bool + }{ + {"Valid Nvidia", "nvidia.com", NVIDIA, false}, + {"Invalid vendor", "amd.com", "", true}, + {"Empty string", "", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ToVendor(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("ToVendor() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if got != tt.want { + t.Errorf("ToVendor() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestToClass(t *testing.T) { + tests := []struct { + name string + input string + want Class + wantErr bool + }{ + {"Valid GPU", "gpu", GPU, false}, + {"Valid IGPU", "igpu", IGPU, false}, + {"Valid MIG", "mig", MIG, false}, + {"Invalid class", "cpu", "", true}, + {"Empty string", "", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ToClass(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("ToClass() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if got != tt.want { + t.Errorf("ToClass() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestToCDI(t *testing.T) { + tests := []struct { + name string + input string + want *ID + wantErr bool + }{ + {"Valid GPU", "nvidia.com/gpu=0", &ID{Vendor: NVIDIA, Class: GPU, Name: "0"}, false}, + {"Valid GPU all", "nvidia.com/gpu=all", &ID{Vendor: NVIDIA, Class: GPU, Name: "all"}, false}, + {"Valid MIG", "nvidia.com/mig=0:1", &ID{Vendor: NVIDIA, Class: MIG, Name: "0:1"}, false}, + {"Valid IGPU", "nvidia.com/igpu=0", &ID{Vendor: NVIDIA, Class: IGPU, Name: "0"}, false}, + {"Valid GPU with UUID", "nvidia.com/gpu=GPU-8da9a1ee-3495-a369-a73a-b9d8ffbc1220", &ID{Vendor: NVIDIA, Class: GPU, Name: "GPU-8da9a1ee-3495-a369-a73a-b9d8ffbc1220"}, false}, + {"Valid MIG with UUID", "nvidia.com/mig=MIG-8da9a1ee-3495-a369-a73a-b9d8ffbc1220", &ID{Vendor: NVIDIA, Class: MIG, Name: "MIG-8da9a1ee-3495-a369-a73a-b9d8ffbc1220"}, false}, + {"Invalid vendor", "amd.com/gpu=0", nil, true}, + {"Invalid class", "nvidia.com/cpu=0", nil, true}, + {"Valid MIG format (all MIG indexes in device)", "nvidia.com/mig=0", &ID{Vendor: NVIDIA, Class: MIG, Name: "0"}, false}, + {"Non-CDI format", "not-a-cdi-format", nil, true}, + {"DRM ID", "1", nil, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ToCDI(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("ToCDI() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ToCDI() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/lxd/device/cdi/log.go b/lxd/device/cdi/log.go new file mode 100644 index 000000000000..1a2f58697c97 --- /dev/null +++ b/lxd/device/cdi/log.go @@ -0,0 +1,52 @@ +package cdi + +import ( + "fmt" + + "github.com/canonical/lxd/shared/logger" +) + +// CDILogger reuses LXD's shared logger to log the internal operations of the CDI spec generator. +type CDILogger struct { + lxdLogger logger.Logger +} + +// NewCDILogger creates a new CDI logger from a LXD logger instance. +func NewCDILogger(l logger.Logger) *CDILogger { + return &CDILogger{lxdLogger: l} +} + +// Info logs a message (with optional context) at the INFO log level. +func (l *CDILogger) Info(args ...any) { + l.lxdLogger.Info(fmt.Sprint(args...)) +} + +// Infof logs at the INFO log level using a standard printf format string. +func (l *CDILogger) Infof(format string, args ...any) { + l.lxdLogger.Info(fmt.Sprintf(format, args...)) +} + +// Warning logs a message (with optional context) at the WARNING log level. +func (l *CDILogger) Warning(args ...any) { + l.lxdLogger.Warn(fmt.Sprint(args...)) +} + +// Warningf logs at the WARNING log level using a standard printf format string. +func (l *CDILogger) Warningf(format string, args ...any) { + l.lxdLogger.Warn(fmt.Sprintf(format, args...)) +} + +// Errorf logs at the ERROR log level using a standard printf format string. +func (l *CDILogger) Errorf(format string, args ...any) { + l.lxdLogger.Error(fmt.Sprintf(format, args...)) +} + +// Debugf logs at the DEBUG log level using a standard printf format string. +func (l *CDILogger) Debugf(format string, args ...any) { + l.lxdLogger.Debug(fmt.Sprintf(format, args...)) +} + +// Tracef logs at the TRACE log level using a standard printf format string. +func (l *CDILogger) Tracef(format string, args ...any) { + l.lxdLogger.Trace(fmt.Sprintf(format, args...)) +} diff --git a/lxd/device/cdi/spec.go b/lxd/device/cdi/spec.go new file mode 100644 index 000000000000..332068a0d112 --- /dev/null +++ b/lxd/device/cdi/spec.go @@ -0,0 +1,161 @@ +//go:build !armhf && !arm && !arm32 + +package cdi + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/NVIDIA/nvidia-container-toolkit/pkg/nvcdi" + "tags.cncf.io/container-device-interface/specs-go" + + "github.com/canonical/lxd/lxd/instance" + "github.com/canonical/lxd/lxd/state" + "github.com/canonical/lxd/shared" + "github.com/canonical/lxd/shared/logger" +) + +const ( + // defaultNvidiaTegraMountSpecPath is default location of CSV files that define the modifications required to the OCI spec. + defaultNvidiaTegraMountSpecPath = "/etc/nvidia-container-runtime/host-files-for-container.d" +) + +// defaultNvidiaTegraCSVFiles returns the default CSV files for the Nvidia Tegra platform. +func defaultNvidiaTegraCSVFiles(rootPath string) []string { + files := []string{ + "devices.csv", + "drivers.csv", + "l4t.csv", + } + + paths := make([]string, 0, len(files)) + for _, file := range files { + paths = append(paths, filepath.Join(rootPath, defaultNvidiaTegraMountSpecPath, file)) + } + + return paths +} + +// generateNvidiaSpec generates a CDI spec for an Nvidia vendor. +func generateNvidiaSpec(s *state.State, cdiID ID, inst instance.Instance) (*specs.Spec, error) { + l := logger.AddContext(logger.Ctx{"instanceName": inst.Name(), "projectName": inst.Project().Name, "cdiID": cdiID.String()}) + mode := nvcdi.ModeAuto + if cdiID.Class == IGPU { + mode = nvcdi.ModeCSV + } + + indexDeviceNamer, err := nvcdi.NewDeviceNamer(nvcdi.DeviceNameStrategyIndex) + if err != nil { + return nil, fmt.Errorf("Failed to create device namer with index strategy: %w", err) + } + + uuidDeviceNamer, err := nvcdi.NewDeviceNamer(nvcdi.DeviceNameStrategyUUID) + if err != nil { + return nil, fmt.Errorf("Failed to create device namer with uuid strategy: %w", err) + } + + nvidiaCTKPath, err := exec.LookPath("nvidia-ctk") + if err != nil { + return nil, fmt.Errorf("Failed to find the nvidia-ctk binary: %w", err) + } + + rootPath := "" + devRootPath := "" + if s.OS.InUbuntuCore() { + devRootPath = "/" + + gpuInterfaceProviderWrapper := os.Getenv("SNAP") + "/gpu-2404/bin/gpu-2404-provider-wrapper" + + // Let's ensure that user has mesa-2404 snap connected. + if !shared.PathExists(gpuInterfaceProviderWrapper) { + return nil, fmt.Errorf("Failed to find gpu-2404-provider-wrapper. Please ensure that mesa-2404 snap is connected to lxd.") + } + + // + // NVIDIA_DRIVER_ROOT environment variable name comes from: + // https://git.launchpad.net/~canonical-kernel-snaps/canonical-kernel-snaps/+git/kernel-snaps-u24.04/commit/?id=928d273d881abc8599f9cb754eeb753aa7113852 + // + // You may wonder why we need this + // gpu-2404-provider-wrapper printenv NVIDIA_DRIVER_ROOT + // machinery instead of simple os.Getenv("NVIDIA_DRIVER_ROOT"). + // Reason is that mesa-2404 or pc-kernel may be upgraded (refreshed) + // while LXD snap version remains the same and there is no guarantee + // that NVIDIA_DRIVER_ROOT value won't change between those refreshes... + // + cmd := []string{ + gpuInterfaceProviderWrapper, + "printenv", + "NVIDIA_DRIVER_ROOT", + } + + rootPath, err = shared.RunCommand(cmd[0], cmd[1:]...) + if err != nil { + return nil, fmt.Errorf("Failed to determine NVIDIA driver root path: %w", err) + } + + rootPath = strings.TrimSuffix(rootPath, "\n") + + // Let's ensure that user did: + // snap connect mesa-2404:kernel-gpu-2404 pc-kernel + if !shared.PathExists(rootPath + "/usr/bin/nvidia-smi") { + return nil, fmt.Errorf("Failed to find nvidia-smi tool in %q. Please ensure that pc-kernel snap is connected to mesa-2404.", rootPath) + } + } else if shared.InSnap() { + rootPath = "/var/lib/snapd/hostfs" + devRootPath = rootPath + } + + cdilib, err := nvcdi.New( + nvcdi.WithDeviceNamers(indexDeviceNamer, uuidDeviceNamer), + nvcdi.WithLogger(NewCDILogger(l)), + nvcdi.WithDriverRoot(rootPath), + nvcdi.WithDevRoot(devRootPath), + nvcdi.WithNVIDIACDIHookPath(nvidiaCTKPath), + nvcdi.WithMode(mode), + nvcdi.WithCSVFiles(defaultNvidiaTegraCSVFiles(rootPath)), + ) + if err != nil { + return nil, fmt.Errorf("Failed to create CDI library: %w", err) + } + + specIface, err := cdilib.GetSpec() + if err != nil { + return nil, fmt.Errorf("Failed to get CDI spec interface: %w", err) + } + + spec := specIface.Raw() + if spec == nil { + return nil, fmt.Errorf("CDI spec is nil") + } + + // The spec definition can be quite large so we log it to a file. + specPath := filepath.Join(inst.LogPath(), fmt.Sprintf("nvidia_cdi_spec.%s.log", strings.ReplaceAll(cdiID.String(), "/", "_"))) + specFile, err := os.Create(specPath) + if err != nil { + l.Warn("Failed to create a log file to hold a CDI spec", logger.Ctx{"specPath": specPath, "error": err}) + return spec, nil + } + + defer specFile.Close() + + _, err = specFile.WriteString(logger.Pretty(spec)) + if err != nil { + return nil, fmt.Errorf("Failed to write spec to %q: %v", specPath, err) + } + + l.Debug("CDI spec has been successfully generated", logger.Ctx{"specPath": specPath}) + return spec, nil +} + +// generateSpec generates a CDI spec for the given CDI ID. +func generateSpec(s *state.State, cdiID ID, inst instance.Instance) (*specs.Spec, error) { + switch cdiID.Vendor { + case NVIDIA: + return generateNvidiaSpec(s, cdiID, inst) + default: + return nil, fmt.Errorf("Unsupported CDI vendor (%q) for the spec generation", cdiID.Vendor) + } +} diff --git a/lxd/device/cdi/spec_unsupported.go b/lxd/device/cdi/spec_unsupported.go new file mode 100644 index 000000000000..a00d975c1499 --- /dev/null +++ b/lxd/device/cdi/spec_unsupported.go @@ -0,0 +1,19 @@ +//go:build armhf || arm || arm32 + +package cdi + +import ( + "fmt" + + "github.com/canonical/lxd/lxd/instance" + "github.com/canonical/lxd/lxd/state" + "tags.cncf.io/container-device-interface/specs-go" +) + +func defaultNvidiaTegraCSVFiles(rootPath string) []string { + return []string{} +} + +func generateSpec(s *state.State, cdiID ID, inst instance.Instance) (*specs.Spec, error) { + return nil, fmt.Errorf("NVIDIA CDI operations not supported on this platform") +} diff --git a/lxd/device/device_interface.go b/lxd/device/device_interface.go index 495fb4b24d2b..65ccaa853b9b 100644 --- a/lxd/device/device_interface.go +++ b/lxd/device/device_interface.go @@ -43,7 +43,7 @@ type Device interface { // PreStartCheck indicates if the device is available for starting. PreStartCheck() error - // Start peforms any host-side configuration required to start the device for the instance. + // Start performs any host-side configuration required to start the device for the instance. // This can be when a device is plugged into a running instance or the instance is starting. // Returns run-time configuration needed for configuring the instance with the new device. Start() (*deviceConfig.RunConfig, error) diff --git a/lxd/device/disk.go b/lxd/device/disk.go index ce924b9bdca0..21e547debb13 100644 --- a/lxd/device/disk.go +++ b/lxd/device/disk.go @@ -147,6 +147,36 @@ func (d *disk) sourceIsLocalPath(source string) bool { return true } +// Check that unshared custom storage block volumes are not added to profiles or multiple instances. +func (d *disk) checkBlockVolSharing(instanceType instancetype.Type, projectName string, volume *api.StorageVolume) error { + // Skip the checks if the volume is set to be shared or is not a block volume. + if volume.ContentType != cluster.StoragePoolVolumeContentTypeNameBlock || shared.IsTrue(volume.Config["security.shared"]) { + return nil + } + + if instanceType == instancetype.Any { + return fmt.Errorf("Cannot add custom storage block volume to profiles if security.shared is false or unset") + } + + err := storagePools.VolumeUsedByInstanceDevices(d.state, d.pool.Name(), projectName, volume, true, func(inst db.InstanceArgs, project api.Project, usedByDevices []string) error { + // Don't count the current instance. + if d.inst != nil && d.inst.Project().Name == inst.Project && d.inst.Name() == inst.Name { + return nil + } + + return db.ErrListStop + }) + if err != nil { + if err == db.ErrListStop { + return fmt.Errorf("Cannot add custom storage block volume to more than one instance if security.shared is false or unset") + } + + return err + } + + return nil +} + // validateConfig checks the supplied config for correctness. func (d *disk) validateConfig(instConf instance.ConfigReader) error { if !instanceSupported(instConf.Type(), instancetype.Container, instancetype.VM) { @@ -411,6 +441,7 @@ func (d *disk) validateConfig(instConf instance.ConfigReader) error { return fmt.Errorf("Missing source path %q for disk %q", d.config["source"], d.name) } + // Check if validating a storage volume disk. if d.config["pool"] != "" { if d.config["shift"] != "" { return fmt.Errorf(`The "shift" property cannot be used with custom storage volumes (set "security.shifted=true" on the volume instead)`) @@ -420,38 +451,51 @@ func (d *disk) validateConfig(instConf instance.ConfigReader) error { return fmt.Errorf("Storage volumes cannot be specified as absolute paths") } - // Only perform expensive instance pool volume checks when not validating a profile and after - // device expansion has occurred (to avoid doing it twice during instance load). - if d.inst != nil && !d.inst.IsSnapshot() && len(instConf.ExpandedDevices()) > 0 { + var dbCustomVolume *db.StorageVolume + var storageProjectName string + + // Check if validating an instance or a custom storage volume attached to a profile. + if (d.inst != nil && !d.inst.IsSnapshot()) || (d.inst == nil && instConf.Type() == instancetype.Any && !instancetype.IsRootDiskDevice(d.config)) { d.pool, err = storagePools.LoadByName(d.state, d.config["pool"]) if err != nil { return fmt.Errorf("Failed to get storage pool %q: %w", d.config["pool"], err) } - if d.pool.Status() == "Pending" { - return fmt.Errorf("Pool %q is pending", d.config["pool"]) - } - // Custom volume validation. - if d.config["source"] != "" && d.config["path"] != "/" { + if !instancetype.IsRootDiskDevice(d.config) { // Derive the effective storage project name from the instance config's project. - storageProjectName, err := project.StorageVolumeProject(d.state.DB.Cluster, instConf.Project().Name, cluster.StoragePoolVolumeTypeCustom) + storageProjectName, err = project.StorageVolumeProject(d.state.DB.Cluster, instConf.Project().Name, cluster.StoragePoolVolumeTypeCustom) if err != nil { return err } // GetStoragePoolVolume returns a volume with an empty Location field for remote drivers. - var dbVolume *db.StorageVolume err = d.state.DB.Cluster.Transaction(context.TODO(), func(ctx context.Context, tx *db.ClusterTx) error { - dbVolume, err = tx.GetStoragePoolVolume(ctx, d.pool.ID(), storageProjectName, cluster.StoragePoolVolumeTypeCustom, d.config["source"], true) + dbCustomVolume, err = tx.GetStoragePoolVolume(ctx, d.pool.ID(), storageProjectName, cluster.StoragePoolVolumeTypeCustom, d.config["source"], true) return err }) if err != nil { return fmt.Errorf("Failed loading custom volume: %w", err) } + err := d.checkBlockVolSharing(instConf.Type(), storageProjectName, &dbCustomVolume.StorageVolume) + if err != nil { + return err + } + } + } + + // Only perform expensive instance pool volume checks when not validating a profile and after + // device expansion has occurred (to avoid doing it twice during instance load). + if d.inst != nil && !d.inst.IsSnapshot() && len(instConf.ExpandedDevices()) > 0 { + if d.pool.Status() == "Pending" { + return fmt.Errorf("Pool %q is pending", d.config["pool"]) + } + + // Custom volume validation. + if dbCustomVolume != nil { // Check storage volume is available to mount on this cluster member. - remoteInstance, err := storagePools.VolumeUsedByExclusiveRemoteInstancesWithProfiles(d.state, d.config["pool"], storageProjectName, &dbVolume.StorageVolume) + remoteInstance, err := storagePools.VolumeUsedByExclusiveRemoteInstancesWithProfiles(d.state, d.config["pool"], storageProjectName, &dbCustomVolume.StorageVolume) if err != nil { return fmt.Errorf("Failed checking if custom volume is exclusively attached to another instance: %w", err) } @@ -461,12 +505,7 @@ func (d *disk) validateConfig(instConf instance.ConfigReader) error { } // Check that block volumes are *only* attached to VM instances. - contentType, err := storagePools.VolumeContentTypeNameToContentType(dbVolume.ContentType) - if err != nil { - return err - } - - if contentType == cluster.StoragePoolVolumeContentTypeBlock { + if dbCustomVolume.ContentType == cluster.StoragePoolVolumeContentTypeNameBlock { if instConf.Type() == instancetype.Container { return fmt.Errorf("Custom block volumes cannot be used on containers") } @@ -474,7 +513,7 @@ func (d *disk) validateConfig(instConf instance.ConfigReader) error { if d.config["path"] != "" { return fmt.Errorf("Custom block volumes cannot have a path defined") } - } else if contentType == cluster.StoragePoolVolumeContentTypeISO { + } else if dbCustomVolume.ContentType == cluster.StoragePoolVolumeContentTypeNameISO { if instConf.Type() == instancetype.Container { return fmt.Errorf("Custom ISO volumes cannot be used on containers") } diff --git a/lxd/device/gpu.go b/lxd/device/gpu.go index 96ffdd7b652e..5ce602372eae 100644 --- a/lxd/device/gpu.go +++ b/lxd/device/gpu.go @@ -35,7 +35,19 @@ func gpuValidationRules(requiredFields []string, optionalFields []string) map[st // type: string // shortdesc: Product ID of the parent GPU device "productid": validate.Optional(validate.IsDeviceID), - // lxdmeta:generate(entities=device-gpu-{physical+mdev+mig}; group=device-conf; key=id) + // lxdmeta:generate(entities=device-gpu-physical; group=device-conf; key=id) + // The ID can either be the DRM card ID of the GPU device (container or VM) or a fully-qualified Container Device Interface (CDI) name (container only). + // Here are some examples of fully-qualified CDI names: + // + // - `nvidia.com/gpu=0`: Instructs LXD to operate a discrete GPU (dGPU) pass-through of brand NVIDIA with the first discovered GPU on your system. You can use the `nvidia-smi` tool on your host to find out which identifier to use. + // - `nvidia.com/gpu=1833c8b5-9aa0-5382-b784-68b7e77eb185`: Instructs LXD to operate a discrete GPU (dGPU) pass-through of brand NVIDIA with a given GPU unique identifier. This identifier should also appear with `nvidia-smi -L`. + // - `nvidia.com/igpu=all`: Instructs LXD to pass all the host integrated GPUs (iGPU) of brand NVIDIA. The concept of an index does not currently map to iGPUs. It is possible to list them with the `nvidia-smi -L` command. A special `nvgpu` mention should appear in the generated list to indicate a device to be an iGPU. + // - `nvidia.com/gpu=all`: Instructs LXD to pass all the host GPUs of brand NVIDIA through to the container. + // --- + // type: string + // shortdesc: ID of the GPU device + + // lxdmeta:generate(entities=device-gpu-{mdev+mig}; group=device-conf; key=id) // // --- // type: string diff --git a/lxd/device/gpu_physical.go b/lxd/device/gpu_physical.go index 92d859f428d2..b2e3edb1bc45 100644 --- a/lxd/device/gpu_physical.go +++ b/lxd/device/gpu_physical.go @@ -1,7 +1,9 @@ package device import ( + "encoding/json" "fmt" + "net/http" "os" "path/filepath" "regexp" @@ -10,13 +12,17 @@ import ( "golang.org/x/sys/unix" + "github.com/canonical/lxd/lxd/device/cdi" deviceConfig "github.com/canonical/lxd/lxd/device/config" pcidev "github.com/canonical/lxd/lxd/device/pci" + "github.com/canonical/lxd/lxd/idmap" "github.com/canonical/lxd/lxd/instance" "github.com/canonical/lxd/lxd/instance/instancetype" "github.com/canonical/lxd/lxd/resources" + "github.com/canonical/lxd/lxd/storage/filesystem" "github.com/canonical/lxd/lxd/util" "github.com/canonical/lxd/shared" + "github.com/canonical/lxd/shared/api" ) const gpuDRIDevPath = "/dev/dri" @@ -70,6 +76,31 @@ func (d *gpuPhysical) validateConfig(instConf instance.ConfigReader) error { return fmt.Errorf(`Cannot use %q when "id" is set`, field) } } + + // Validate id is either integer DRM ID or CDI ID. + _, err = strconv.Atoi(d.config["id"]) + if err != nil { + cdiID, err := cdi.ToCDI(d.config["id"]) + if err != nil { + // Structurally incorrect CDI ID supplied. + if api.StatusErrorCheck(err, http.StatusBadRequest) { + return fmt.Errorf("ID must be integer DRM ID or CDI ID: %w", err) + } + + // Structurally correct CDI ID supplied, but still invalid for some reason. + return err + } + + // Forbid using CDI in conjunction with any nvidia.=true as CDI handles passing + // through the Nvidia runtime files. + if cdiID != nil { + for k, v := range instConf.ExpandedConfig() { + if strings.HasPrefix(k, "nvidia.") && shared.IsTrue(v) { + return fmt.Errorf("CDI mode is incompatible with any NVIDIA instance configuration option (%q)", k) + } + } + } + } } return nil @@ -98,10 +129,223 @@ func (d *gpuPhysical) Start() (*deviceConfig.RunConfig, error) { return d.startContainer() } +// startCDIDevices starts all the devices given in a CDI specification: +// * `unix-char` (representing the card and non-card devices) +// * `disk` (representing the mounts)). +func (d *gpuPhysical) startCDIDevices(configDevices cdi.ConfigDevices, runConf *deviceConfig.RunConfig) error { + srcFDHandlers := make([]*os.File, 0) + defer func() { + for _, f := range srcFDHandlers { + _ = f.Close() + } + }() + + for _, conf := range configDevices.UnixCharDevs { + if conf["source"] == "" { + return fmt.Errorf("The source of the unix-char device %v used for CDI is empty", conf) + } + + if conf["major"] == "" || conf["minor"] == "" { + return fmt.Errorf("The major or minor of the unix-char device %v used for CDI is empty", conf) + } + + major, err := strconv.ParseUint(conf["major"], 10, 32) + if err != nil { + return fmt.Errorf("Failed to parse major number %q when starting CDI device: %w", conf["major"], err) + } + + minor, err := strconv.ParseUint(conf["minor"], 10, 32) + if err != nil { + return fmt.Errorf("Failed to parse minor number %q when starting CDI device: %w", conf["minor"], err) + } + + // Here putting a `cdi.CDIUnixPrefix` prefix with 'd.name' as a device name will create an directory entry like: + // /devices//.. + // 'unixDeviceSetupCharNum' is already checking for dupe entries so we have no validation to do here. + err = unixDeviceSetupCharNum(d.state, d.inst.DevicesPath(), cdi.CDIUnixPrefix, d.name, conf, uint32(major), uint32(minor), conf["path"], false, runConf) + if err != nil { + return err + } + } + + // Create the devices directory if missing. + if !shared.PathExists(d.inst.DevicesPath()) { + err := os.Mkdir(d.inst.DevicesPath(), 0711) + if err != nil { + return err + } + } + + for _, conf := range configDevices.BindMounts { + if conf["source"] == "" { + return fmt.Errorf("The source of the disk device %v used for CDI is empty", conf) + } + + srcPath := shared.HostPath(conf["source"]) + destPath := conf["path"] + relativeDestPath := strings.TrimPrefix(destPath, "/") + + // This time, the created path will be like: + // /devices//.. + deviceName := filesystem.PathNameEncode(deviceJoinPath(cdi.CDIDiskPrefix, d.name, relativeDestPath)) + devPath := filepath.Join(d.inst.DevicesPath(), deviceName) + + ownerShift := deviceConfig.MountOwnerShiftNone + if idmap.CanIdmapMount(devPath, "") { + ownerShift = deviceConfig.MountOwnerShiftDynamic + } + + options := []string{"bind"} + mntOptions := shared.SplitNTrimSpace(conf["raw.mount.options"], ",", -1, true) + fsName := "none" + + fileInfo, err := os.Stat(srcPath) + if err != nil { + return fmt.Errorf("Failed accessing source path %q: %w", srcPath, err) + } + + fileMode := fileInfo.Mode() + isFile := false + if !fileMode.IsDir() { + isFile = true + } + + f, err := os.OpenFile(srcPath, unix.O_PATH|unix.O_CLOEXEC, 0) + if err != nil { + return fmt.Errorf("Failed opening source path %q: %w", srcPath, err) + } + + srcPath = fmt.Sprintf("/proc/self/fd/%d", f.Fd()) + srcFDHandlers = append(srcFDHandlers, f) + + // Clean any existing entry. + if shared.PathExists(devPath) { + err := os.Remove(devPath) + if err != nil { + return err + } + } + + // Create the mount point. + if isFile { + f, err := os.Create(devPath) + if err != nil { + return err + } + + srcFDHandlers = append(srcFDHandlers, f) + } else { + err := os.Mkdir(devPath, 0700) + if err != nil { + return err + } + } + + // Mount the fs. + err = DiskMount(srcPath, devPath, false, "", mntOptions, fsName) + if err != nil { + return err + } + + if isFile { + options = append(options, "create=file") + } else { + options = append(options, "create=dir") + } + + runConf.Mounts = append(runConf.Mounts, deviceConfig.MountEntryItem{ + DevName: deviceName, + DevPath: devPath, + TargetPath: relativeDestPath, + FSType: "none", + Opts: options, + OwnerShift: ownerShift, + }) + + runConf.PostHooks = append(runConf.PostHooks, func() error { + err := unix.Unmount(devPath, unix.MNT_DETACH) + if err != nil { + return err + } + + return nil + }) + } + + // Serialize the config devices inside the devices directory. + f, err := os.Create(d.generateCDIConfigDevicesFilePath()) + if err != nil { + return fmt.Errorf("Could not create the CDI config devices file: %w", err) + } + + defer f.Close() + err = json.NewEncoder(f).Encode(configDevices) + if err != nil { + return fmt.Errorf("Could not write to the CDI config devices file: %w", err) + } + + return nil +} + +func (d *gpuPhysical) generateCDIHooksFilePath() string { + return filepath.Join(d.inst.DevicesPath(), fmt.Sprintf("%s%s", d.name, cdi.CDIHooksFileSuffix)) +} + +func (d *gpuPhysical) generateCDIConfigDevicesFilePath() string { + return filepath.Join(d.inst.DevicesPath(), fmt.Sprintf("%s%s", d.name, cdi.CDIConfigDevicesFileSuffix)) +} + // startContainer detects the requested GPU devices and sets up unix-char devices. // Returns RunConfig populated with mount info required to pass the unix-char devices into the container. func (d *gpuPhysical) startContainer() (*deviceConfig.RunConfig, error) { runConf := deviceConfig.RunConfig{} + if d.config["id"] != "" { + // Check if the id of the device match a CDI format. + // The cdiID can be nil if the provided ID doesn't conform to the CDI (Container Device Interface) + // format and this will not be treated as an error, as we allow the program to continue processing. + // The ID might still be valid in other contexts, such as a DRM card ID. + // This flexibility allows for both CDI-compliant device specifications and legacy device IDs. + cdiID, _ := cdi.ToCDI(d.config["id"]) + if cdiID != nil { + if cdiID.Class == cdi.MIG { + return nil, fmt.Errorf(`MIG GPU notation detected for a "physical" gputype device. Choose a "mig" gputype device instead.`) + } + + configDevices, hooks, err := cdi.GenerateFromCDI(d.state, d.inst, *cdiID, d.logger) + if err != nil { + return nil, err + } + + // Start the devices needed by the CDI specification. + err = d.startCDIDevices(*configDevices, &runConf) + if err != nil { + return nil, err + } + + // Persist the hooks to be run on a `lxc.hook.mount` LXC hook. + hooksFile := d.generateCDIHooksFilePath() + f, err := os.Create(hooksFile) + if err != nil { + return nil, fmt.Errorf("Could not create the CDI hooks file: %w", err) + } + + defer f.Close() + err = json.NewEncoder(f).Encode(hooks) + if err != nil { + return nil, fmt.Errorf("Could not write to the CDI hooks file: %w", err) + } + + runConf.GPUDevice = append(runConf.GPUDevice, + []deviceConfig.RunConfigItem{ + {Key: cdi.CDIHookDefinitionKey, Value: filepath.Base(hooksFile)}, + }...) + + return &runConf, nil + } + } + + // If we use a non-CDI approach, we proceeds with the normal GPU detection approach using the provided DRM card id + // or PCI-e bus address. gpus, err := resources.GetGPU() if err != nil { return nil, err @@ -342,6 +586,62 @@ func (d *gpuPhysical) pciDeviceDriverOverrideIOMMU(pciDev pcidev.Device, driverO return nil } +// stopCDIDevices reads the configDevices and remove potential unix device and unmounts disk mounts. +func (d *gpuPhysical) stopCDIDevices(configDevices cdi.ConfigDevices, runConf *deviceConfig.RunConfig) error { + // Remove ALL the underlying unix-char dev entries created when the CDI device started. + err := unixDeviceRemove(d.inst.DevicesPath(), cdi.CDIUnixPrefix, d.name, "", runConf) + if err != nil { + return err + } + + for _, conf := range configDevices.BindMounts { + relativeDestPath := strings.TrimPrefix(conf["path"], "/") + devPath := filepath.Join(d.inst.DevicesPath(), filesystem.PathNameEncode(deviceJoinPath(cdi.CDIDiskPrefix, d.name, relativeDestPath))) + runConf.PostHooks = append(runConf.PostHooks, func() error { + // Clean any existing device mount entry. Should occur first before custom volume unmounts. + err := DiskMountClear(devPath) + if err != nil { + return err + } + + return nil + }) + + // The disk device doesn't exist do nothing. + if !shared.PathExists(devPath) { + return nil + } + + // Request an unmount of the device inside the instance. + runConf.Mounts = append(runConf.Mounts, deviceConfig.MountEntryItem{ + TargetPath: relativeDestPath, + }) + } + + return nil +} + +// CanHotPlug returns whether the device can be managed whilst the instance is running. +// CDI GPU are not hotpluggable because the configuration of a CDI GPU requires a LXC hook that +// is only run at instance start. A classic GPU device can be hotplugged. +func (d *gpuPhysical) CanHotPlug() bool { + if d.inst.Type() == instancetype.Container { + if d.config["id"] != "" { + // Check if the id of the device matches a CDI format. + cdiID, _ := cdi.ToCDI(d.config["id"]) + if cdiID != nil { + // CDI devices cannot be hot-plugged because they rely on a start hook for setting + // up files inside the container. + return false + } + } + + return true + } + + return false +} + // Stop is run when the device is removed from the instance. func (d *gpuPhysical) Stop() (*deviceConfig.RunConfig, error) { runConf := deviceConfig.RunConfig{ @@ -349,6 +649,25 @@ func (d *gpuPhysical) Stop() (*deviceConfig.RunConfig, error) { } if d.inst.Type() == instancetype.Container { + cdiID, _ := cdi.ToCDI(d.config["id"]) + if cdiID != nil { + // This is more efficient than GenerateFromCDI as we don't need to re-generate a CDI + // specification to parse it again. + configDevices, err := cdi.ReloadConfigDevicesFromDisk(d.generateCDIConfigDevicesFilePath()) + if err != nil { + return nil, err + } + + err = d.stopCDIDevices(configDevices, &runConf) + if err != nil { + return nil, err + } + + return &runConf, nil + } + + // In case of an 'id' not being CDI-compliant (e.g, a legacy DRM card id), + // we remove unix devices only as usual. err := unixDeviceRemove(d.inst.DevicesPath(), "unix", d.name, "", &runConf) if err != nil { return nil, err @@ -371,11 +690,31 @@ func (d *gpuPhysical) postStop() error { v := d.volatileGet() if d.inst.Type() == instancetype.Container { - // Remove host files for this device. - err := unixDeviceDeleteFiles(d.state, d.inst.DevicesPath(), "unix", d.name, "") - if err != nil { - return fmt.Errorf("Failed to delete files for device '%s': %w", d.name, err) + cdiID, _ := cdi.ToCDI(d.config["id"]) + if cdiID != nil { + err := unixDeviceDeleteFiles(d.state, d.inst.DevicesPath(), cdi.CDIUnixPrefix, d.name, "") + if err != nil { + return fmt.Errorf("Failed to delete files for CDI device '%s': %w", d.name, err) + } + + // Also remove the JSON files that were used to store the CDI related information. + err = os.Remove(d.generateCDIHooksFilePath()) + if err != nil { + return fmt.Errorf("Failed to delete CDI hooks file for device %q: %w", d.name, err) + } + + err = os.Remove(d.generateCDIConfigDevicesFilePath()) + if err != nil { + return fmt.Errorf("Failed to delete CDI paths to conf file for device %q: %w", d.name, err) + } + } else { + err := unixDeviceDeleteFiles(d.state, d.inst.DevicesPath(), "unix", d.name, "") + if err != nil { + return fmt.Errorf("Failed to delete files for device %q: %w", d.name, err) + } } + + return nil } // If VM physical pass through, unbind from vfio-pci and bind back to host driver. diff --git a/lxd/device/unix_common.go b/lxd/device/unix_common.go index f763aa89a0dc..b0dc27c5b573 100644 --- a/lxd/device/unix_common.go +++ b/lxd/device/unix_common.go @@ -63,13 +63,15 @@ func (d *unixCommon) validateConfig(instConf instance.ConfigReader) error { return &drivers.ErrInvalidPath{PrefixPath: d.state.DevMonitor.PrefixPath()} }, + // lxdmeta:generate(entities=device-unix-{char+block}; group=device-conf; key=path) // // --- // type: string // required: either `source` or `path` must be set - // shortdesc: Path inside the instance + // shortdesc: Path inside the container "path": validate.IsAny, + // lxdmeta:generate(entities=device-unix-{char+block}; group=device-conf; key=major) // // --- @@ -77,6 +79,7 @@ func (d *unixCommon) validateConfig(instConf instance.ConfigReader) error { // defaultdesc: device on host // shortdesc: Device major number "major": unixValidDeviceNum, + // lxdmeta:generate(entities=device-unix-{char+block}; group=device-conf; key=minor) // // --- @@ -84,12 +87,13 @@ func (d *unixCommon) validateConfig(instConf instance.ConfigReader) error { // defaultdesc: device on host // shortdesc: Device minor number "minor": unixValidDeviceNum, + // lxdmeta:generate(entities=device-unix-{char+block+hotplug}; group=device-conf; key=uid) // // --- // type: integer // defaultdesc: `0` - // shortdesc: UID of the device owner in the instance + // shortdesc: UID of the device owner in the container // lxdmeta:generate(entities=device-unix-usb; group=device-conf; key=uid) // @@ -97,14 +101,15 @@ func (d *unixCommon) validateConfig(instConf instance.ConfigReader) error { // type: integer // defaultdesc: `0` // condition: container - // shortdesc: UID of the device owner in the container + // shortdesc: UID of the device owner in the instance "uid": unixValidUserID, + // lxdmeta:generate(entities=device-unix-{char+block+hotplug}; group=device-conf; key=gid) // // --- // type: integer // defaultdesc: `0` - // shortdesc: GID of the device owner in the instance + // shortdesc: GID of the device owner in the container // lxdmeta:generate(entities=device-unix-usb; group=device-conf; key=gid) // @@ -112,14 +117,15 @@ func (d *unixCommon) validateConfig(instConf instance.ConfigReader) error { // type: integer // defaultdesc: `0` // condition: container - // shortdesc: GID of the device owner in the container + // shortdesc: GID of the device owner in the instance "gid": unixValidUserID, + // lxdmeta:generate(entities=device-unix-{char+block+hotplug}; group=device-conf; key=mode) // // --- // type: integer // defaultdesc: `0660` - // shortdesc: Mode of the device in the instance + // shortdesc: Mode of the device in the container // lxdmeta:generate(entities=device-unix-usb; group=device-conf; key=mode) // @@ -127,23 +133,24 @@ func (d *unixCommon) validateConfig(instConf instance.ConfigReader) error { // type: integer // defaultdesc: `0660` // condition: container - // shortdesc: Mode of the device in the container + // shortdesc: Mode of the device in the instance "mode": unixValidOctalFileMode, + // lxdmeta:generate(entities=device-unix-char; group=device-conf; key=required) // See {ref}`devices-unix-char-hotplugging` for more information. // --- // type: bool // defaultdesc: `true` - // shortdesc: Whether this device is required to start the instance + // shortdesc: Whether this device is required to start the container // lxdmeta:generate(entities=device-unix-block; group=device-conf; key=required) // See {ref}`devices-unix-block-hotplugging` for more information. // --- // type: bool // defaultdesc: `true` - // shortdesc: Whether this device is required to start the instance + // shortdesc: Whether this device is required to start the container - // lxdmeta:generate(entities=device-unix-{hotplug+usb}; group=device-conf; key=required) + // lxdmeta:generate(entities=device-unix-usb; group=device-conf; key=required) // The default is `false`, which means that all devices can be hotplugged. // --- // type: bool diff --git a/lxd/device/unix_hotplug.go b/lxd/device/unix_hotplug.go index fdd259879028..e39a118ed3db 100644 --- a/lxd/device/unix_hotplug.go +++ b/lxd/device/unix_hotplug.go @@ -16,16 +16,27 @@ import ( "github.com/canonical/lxd/shared/validate" ) -// unixHotplugIsOurDevice indicates whether the unixHotplug device event qualifies as part of our device. -// This function is not defined against the unixHotplug struct type so that it can be used in event -// callbacks without needing to keep a reference to the unixHotplug device struct. -func unixHotplugIsOurDevice(config deviceConfig.Device, unixHotplug *UnixHotplugEvent) bool { - // Check if event matches criteria for this device, if not return. - if (config["vendorid"] != "" && config["vendorid"] != unixHotplug.Vendor) || (config["productid"] != "" && config["productid"] != unixHotplug.Product) { +// unixHotplugDeviceMatch matches a unix-hotplug device based on vendorid and productid. USB bus and devices with a major number of 0 are ignored. This function is used to indicate whether a unix hotplug event qualifies as part of our registered devices, and to load matching devices. +func unixHotplugDeviceMatch(config deviceConfig.Device, vendorid string, productid string, subsystem string, major uint32) bool { + match := false + vendorIDMatch := vendorid == config["vendorid"] + productIDMatch := productid == config["productid"] + + // Ignore USB bus devices (handled by `usb` device type) since we don't want `unix-hotplug` and `usb` devices conflicting. We want to add all device nodes besides those with a `usb` subsystem. + // We ignore devices with a major number of 0, since this indicates they are unnamed devices (e.g. non-device mounts). + if strings.HasPrefix(subsystem, "usb") || major == 0 { return false } - return true + if config["vendorid"] != "" && config["productid"] != "" { + match = vendorIDMatch && productIDMatch + } else if config["vendorid"] != "" { + match = vendorIDMatch + } else if config["productid"] != "" { + match = productIDMatch + } + + return match } type unixHotplug struct { @@ -72,7 +83,14 @@ func (d *unixHotplug) validateConfig(instConf instance.ConfigReader) error { "uid": unixValidUserID, "gid": unixValidUserID, "mode": unixValidOctalFileMode, - "required": validate.Optional(validate.IsBool), + + // lxdmeta:generate(entities=device-unix-hotplug; group=device-conf; key=required) + // The default is `false`, which means that all devices can be hotplugged. + // --- + // type: bool + // defaultdesc: `false` + // shortdesc: Whether this device is required to start the container + "required": validate.Optional(validate.IsBool), } err := d.config.Validate(rules) @@ -89,8 +107,7 @@ func (d *unixHotplug) validateConfig(instConf instance.ConfigReader) error { // Register is run after the device is started or when LXD starts. func (d *unixHotplug) Register() error { - // Extract variables needed to run the event hook so that the reference to this device - // struct is not needed to be kept in memory. + // Extract variables needed to run the event hook so that the reference to this device struct is not required to be stored in memory. devicesPath := d.inst.DevicesPath() devConfig := d.config deviceName := d.name @@ -101,7 +118,7 @@ func (d *unixHotplug) Register() error { runConf := deviceConfig.RunConfig{} if e.Action == "add" { - if !unixHotplugIsOurDevice(devConfig, &e) { + if !unixHotplugDeviceMatch(devConfig, e.Vendor, e.Product, e.Subsystem, e.Major) { return nil, nil } @@ -149,29 +166,38 @@ func (d *unixHotplug) Start() (*deviceConfig.RunConfig, error) { runConf := deviceConfig.RunConfig{} runConf.PostHooks = []func() error{d.Register} - device := d.loadUnixDevice() - if d.isRequired() && device == nil { - return nil, fmt.Errorf("Required Unix Hotplug device not found") + devices := d.loadUnixDevices() + if d.isRequired() && len(devices) <= 0 { + return nil, fmt.Errorf("Required unix hotplug device not found") } - if device == nil { - return &runConf, nil - } + for _, device := range devices { + devnum := device.Devnum() + major := uint32(devnum.Major()) + minor := uint32(devnum.Minor()) - devnum := device.Devnum() - major := uint32(devnum.Major()) - minor := uint32(devnum.Minor()) + // Setup device. + var err error + if device.Subsystem() == "block" { + err = unixDeviceSetupBlockNum(d.state, d.inst.DevicesPath(), "unix", d.name, d.config, major, minor, device.Devnode(), false, &runConf) - // setup device - var err error - if device.Subsystem() == "block" { - err = unixDeviceSetupBlockNum(d.state, d.inst.DevicesPath(), "unix", d.name, d.config, major, minor, device.Devnode(), false, &runConf) - } else { - err = unixDeviceSetupCharNum(d.state, d.inst.DevicesPath(), "unix", d.name, d.config, major, minor, device.Devnode(), false, &runConf) - } + if err != nil { + return nil, err + } + } else { + err = unixDeviceSetupCharNum(d.state, d.inst.DevicesPath(), "unix", d.name, d.config, major, minor, device.Devnode(), false, &runConf) - if err != nil { - return nil, err + if err != nil { + return nil, err + } + } + + // Remove unix device on failure to setup device. + runConf.Revert = func() { _ = unixDeviceRemove(d.inst.DevicesPath(), "unix", d.name, "", &runConf) } + + if err != nil { + return nil, fmt.Errorf("Unable to setup unix hotplug device: %w", err) + } } return &runConf, nil @@ -203,9 +229,8 @@ func (d *unixHotplug) postStop() error { return nil } -// loadUnixDevice scans the host machine for unix devices with matching product/vendor ids -// and returns the first matching device with the subsystem type char or block. -func (d *unixHotplug) loadUnixDevice() *udev.Device { +// loadUnixDevices scans the host machine for unix devices with matching product/vendor ids and returns the matching devices with subsystem types of char or block. +func (d *unixHotplug) loadUnixDevices() []udev.Device { // Find device if exists u := udev.Udev{} e := u.NewEnumerate() @@ -230,27 +255,23 @@ func (d *unixHotplug) loadUnixDevice() *udev.Device { } devices, _ := e.Devices() - var device *udev.Device + var matchingDevices []udev.Device for i := range devices { - device = devices[i] + device := devices[i] - if device == nil { + // We ignore devices without an associated device node file name, as this indicates they are not accessible via the standard interface in /dev/. + if device == nil || device.Devnode() == "" { continue } - devnum := device.Devnum() - if devnum.Major() == 0 || devnum.Minor() == 0 { - continue - } + match := unixHotplugDeviceMatch(d.config, device.PropertyValue("ID_VENDOR_ID"), device.PropertyValue("ID_MODEL_ID"), device.Subsystem(), uint32(device.Devnum().Major())) - if device.Devnode() == "" { + if !match { continue } - if !strings.HasPrefix(device.Subsystem(), "usb") { - return device - } + matchingDevices = append(matchingDevices, *device) } - return nil + return matchingDevices } diff --git a/lxd/devices.go b/lxd/devices.go index dcff242bc110..8edbfeea2e92 100644 --- a/lxd/devices.go +++ b/lxd/devices.go @@ -20,8 +20,8 @@ import ( "github.com/canonical/lxd/lxd/resources" "github.com/canonical/lxd/lxd/state" "github.com/canonical/lxd/shared" - "github.com/canonical/lxd/shared/api" "github.com/canonical/lxd/shared/logger" + "github.com/canonical/lxd/shared/validate" ) type deviceTaskCPU struct { @@ -485,6 +485,14 @@ func deviceTaskBalance(s *state.State) { } } + // Determine CPU pinning strategy and static pinning settings. + // When pinning strategy does not equal auto (none or empty), don't auto pin CPUs. + cpuPinStrategy := conf["limits.cpu.pin_strategy"] + err = validate.IsStaticCPUPinning(cpulimit) + if err != nil && c.Type() == instancetype.VM && cpuPinStrategy != "auto" { + continue + } + // Check that the instance is running. // We use InitPID here rather than IsRunning because this task can be triggered during the container's // onStart hook, which is during the time that the start lock is held, which causes IsRunning to @@ -645,11 +653,6 @@ func deviceEventListener(stateFunc func() *state.State) { continue } - // VMs are currently not auto CPU pinned. - if e[0] != string(api.InstanceTypeContainer) { - continue - } - logger.Debugf("Scheduler: %s %s %s: re-balancing", e[0], e[1], e[2]) deviceTaskBalance(s) } diff --git a/lxd/devlxd.go b/lxd/devlxd.go index efdb7df9a28a..ec7411646a89 100644 --- a/lxd/devlxd.go +++ b/lxd/devlxd.go @@ -293,50 +293,6 @@ func devlxdDevicesGetHandler(d *Daemon, c instance.Instance, w http.ResponseWrit return response.DevLxdResponse(http.StatusOK, c.ExpandedDevices(), "json", c.Type() == instancetype.VM) } -var devlxdUbuntuProGet = devLxdHandler{ - path: "/1.0/ubuntu-pro", - handlerFunc: devlxdUbuntuProGetHandler, -} - -func devlxdUbuntuProGetHandler(d *Daemon, c instance.Instance, w http.ResponseWriter, r *http.Request) response.Response { - if shared.IsFalse(c.ExpandedConfig()["security.devlxd"]) { - return response.DevLxdErrorResponse(api.NewGenericStatusError(http.StatusForbidden), c.Type() == instancetype.VM) - } - - if r.Method != http.MethodGet { - return response.DevLxdErrorResponse(api.NewGenericStatusError(http.StatusMethodNotAllowed), c.Type() == instancetype.VM) - } - - settings := d.State().UbuntuPro.GuestAttachSettings(c.ExpandedConfig()["ubuntu_pro.guest_attach"]) - - // Otherwise, return the value from the instance configuration. - return response.DevLxdResponse(http.StatusOK, settings, "json", c.Type() == instancetype.VM) -} - -var devlxdUbuntuProTokenPost = devLxdHandler{ - path: "/1.0/ubuntu-pro/token", - handlerFunc: devlxdUbuntuProTokenPostHandler, -} - -func devlxdUbuntuProTokenPostHandler(d *Daemon, c instance.Instance, w http.ResponseWriter, r *http.Request) response.Response { - if shared.IsFalse(c.ExpandedConfig()["security.devlxd"]) { - return response.DevLxdErrorResponse(api.NewGenericStatusError(http.StatusForbidden), c.Type() == instancetype.VM) - } - - if r.Method != http.MethodPost { - return response.DevLxdErrorResponse(api.NewGenericStatusError(http.StatusMethodNotAllowed), c.Type() == instancetype.VM) - } - - // Return http.StatusForbidden if the host does not have guest attachment enabled. - tokenJSON, err := d.State().UbuntuPro.GetGuestToken(r.Context(), c.ExpandedConfig()["ubuntu_pro.guest_attach"]) - if err != nil { - return response.DevLxdErrorResponse(fmt.Errorf("Failed to get an Ubuntu Pro guest token: %w", err), c.Type() == instancetype.VM) - } - - // Pass it back to the guest. - return response.DevLxdResponse(http.StatusOK, tokenJSON, "json", c.Type() == instancetype.VM) -} - var handlers = []devLxdHandler{ { path: "/", @@ -351,8 +307,6 @@ var handlers = []devLxdHandler{ devlxdEventsGet, devlxdImageExport, devlxdDevicesGet, - devlxdUbuntuProGet, - devlxdUbuntuProTokenPost, } func hoistReq(f func(*Daemon, instance.Instance, http.ResponseWriter, *http.Request) response.Response, d *Daemon) func(http.ResponseWriter, *http.Request) { diff --git a/lxd/documentation.go b/lxd/documentation.go index 6206c4a5560b..a9b14f059312 100644 --- a/lxd/documentation.go +++ b/lxd/documentation.go @@ -7,10 +7,12 @@ import ( "github.com/canonical/lxd/lxd/response" "github.com/canonical/lxd/shared/api" + "github.com/canonical/lxd/shared/entity" ) var metadataConfigurationCmd = APIEndpoint{ - Path: "metadata/configuration", + Path: "metadata/configuration", + MetricsType: entity.TypeServer, Get: APIEndpointAction{Handler: metadataConfigurationGet, AllowUntrusted: true}, } diff --git a/lxd/events.go b/lxd/events.go index cca40808e296..5d33d4d8b080 100644 --- a/lxd/events.go +++ b/lxd/events.go @@ -10,6 +10,7 @@ import ( "github.com/canonical/lxd/lxd/db" "github.com/canonical/lxd/lxd/db/cluster" "github.com/canonical/lxd/lxd/events" + "github.com/canonical/lxd/lxd/metrics" "github.com/canonical/lxd/lxd/request" "github.com/canonical/lxd/lxd/response" "github.com/canonical/lxd/lxd/state" @@ -24,19 +25,26 @@ var eventTypes = []string{api.EventTypeLogging, api.EventTypeOperation, api.Even var privilegedEventTypes = []string{api.EventTypeLogging} var eventsCmd = APIEndpoint{ - Path: "events", + Path: "events", + MetricsType: entity.TypeServer, Get: APIEndpointAction{Handler: eventsGet, AccessHandler: allowProjectResourceList}, } type eventsServe struct { - req *http.Request - s *state.State + s *state.State } // Render starts event socket. func (r *eventsServe) Render(w http.ResponseWriter, req *http.Request) error { - return eventsSocket(r.s, r.req, w) + err := eventsSocket(r.s, req, w) + + if err == nil { + // If there was an error on Render, the callback function will be called during the error handling. + metrics.UseMetricsCallback(req, metrics.Success) + } + + return err } func (r *eventsServe) String() string { @@ -209,5 +217,5 @@ func eventsSocket(s *state.State, r *http.Request, w http.ResponseWriter) error // "500": // $ref: "#/responses/InternalServerError" func eventsGet(d *Daemon, r *http.Request) response.Response { - return &eventsServe{req: r, s: d.State()} + return &eventsServe{s: d.State()} } diff --git a/lxd/identities.go b/lxd/identities.go index 2c842113246e..c124524a1e4b 100644 --- a/lxd/identities.go +++ b/lxd/identities.go @@ -5,10 +5,14 @@ import ( "crypto/x509" "encoding/json" "encoding/pem" + "errors" "fmt" "net/http" "net/url" + "slices" + "time" + "github.com/google/uuid" "github.com/gorilla/mux" "github.com/canonical/lxd/client" @@ -20,81 +24,160 @@ import ( "github.com/canonical/lxd/lxd/lifecycle" "github.com/canonical/lxd/lxd/request" "github.com/canonical/lxd/lxd/response" + "github.com/canonical/lxd/lxd/state" "github.com/canonical/lxd/lxd/util" "github.com/canonical/lxd/shared" "github.com/canonical/lxd/shared/api" "github.com/canonical/lxd/shared/entity" "github.com/canonical/lxd/shared/logger" + "github.com/canonical/lxd/shared/revert" ) var identitiesCmd = APIEndpoint{ - Name: "identities", - Path: "auth/identities", + Name: "identities", + Path: "auth/identities", + MetricsType: entity.TypeIdentity, + + Get: APIEndpointAction{ + // Empty authentication method will return all identities. + Handler: getIdentities(""), + AccessHandler: allowAuthenticated, + }, +} + +var currentIdentityCmd = APIEndpoint{ + Name: "identities", + Path: "auth/identities/current", + MetricsType: entity.TypeIdentity, + + Get: APIEndpointAction{ + Handler: getCurrentIdentityInfo, + AccessHandler: allowAuthenticated, + }, +} + +var tlsIdentitiesCmd = APIEndpoint{ + Name: "identities", + Path: "auth/identities/tls", + MetricsType: entity.TypeIdentity, + + Get: APIEndpointAction{ + Handler: getIdentities(api.AuthenticationMethodTLS), + AccessHandler: allowAuthenticated, + }, + Post: APIEndpointAction{ + Handler: createIdentityTLS, + AllowUntrusted: true, + }, +} + +var oidcIdentitiesCmd = APIEndpoint{ + Name: "identities", + Path: "auth/identities/oidc", + MetricsType: entity.TypeIdentity, + Get: APIEndpointAction{ - Handler: getIdentities, + Handler: getIdentities(api.AuthenticationMethodOIDC), AccessHandler: allowAuthenticated, }, } -var identitiesByAuthenticationMethodCmd = APIEndpoint{ - Name: "identities", - Path: "auth/identities/{authenticationMethod}", +var tlsIdentityCmd = APIEndpoint{ + Name: "identity", + Path: "auth/identities/tls/{nameOrIdentifier}", + MetricsType: entity.TypeIdentity, + Get: APIEndpointAction{ - Handler: getIdentities, + Handler: getIdentity, + AccessHandler: identityAccessHandler(api.AuthenticationMethodTLS, auth.EntitlementCanView), + }, + Put: APIEndpointAction{ + Handler: updateIdentity(api.AuthenticationMethodTLS), AccessHandler: allowAuthenticated, }, + Patch: APIEndpointAction{ + Handler: patchIdentity(api.AuthenticationMethodTLS), + AccessHandler: allowAuthenticated, + }, + Delete: APIEndpointAction{ + Handler: deleteIdentity, + AccessHandler: identityAccessHandler(api.AuthenticationMethodTLS, auth.EntitlementCanDelete), + }, } -var identityCmd = APIEndpoint{ - Name: "identity", - Path: "auth/identities/{authenticationMethod}/{nameOrIdentifier}", +var oidcIdentityCmd = APIEndpoint{ + Name: "identity", + Path: "auth/identities/oidc/{nameOrIdentifier}", + MetricsType: entity.TypeIdentity, + Get: APIEndpointAction{ Handler: getIdentity, - AccessHandler: identityAccessHandler(auth.EntitlementCanView), + AccessHandler: identityAccessHandler(api.AuthenticationMethodOIDC, auth.EntitlementCanView), }, Put: APIEndpointAction{ - Handler: updateIdentity, - AccessHandler: identityAccessHandler(auth.EntitlementCanEdit), + Handler: updateIdentity(api.AuthenticationMethodOIDC), + AccessHandler: identityAccessHandler(api.AuthenticationMethodOIDC, auth.EntitlementCanEdit), }, Patch: APIEndpointAction{ - Handler: patchIdentity, - AccessHandler: identityAccessHandler(auth.EntitlementCanEdit), + Handler: patchIdentity(api.AuthenticationMethodOIDC), + AccessHandler: identityAccessHandler(api.AuthenticationMethodOIDC, auth.EntitlementCanEdit), + }, + Delete: APIEndpointAction{ + Handler: deleteIdentity, + AccessHandler: identityAccessHandler(api.AuthenticationMethodOIDC, auth.EntitlementCanDelete), }, } +// identityNotificationFunc is used when an identity is created, updated, or deleted. +// The signature is defined here as a convenience so that the function signature doesn't need to be written in full when used as an argument. +type identityNotificationFunc func(action lifecycle.IdentityAction, authenticationMethod string, identifier string, updateCache bool) (*api.EventLifecycle, error) + const ( // ctxClusterDBIdentity is used in the identityAccessHandler to set a cluster.Identity into the request context. // The database call is required for authorization and this avoids performing the same query twice. ctxClusterDBIdentity request.CtxKey = "cluster-db-identity" ) -// identityAccessHandler performs some initial validation of the request and gets the identity by its name or -// identifier. If one is found, the identifier is used in the URL that is passed to (auth.Authorizer).CheckPermission. -// The cluster.Identity is set in the request context. -func identityAccessHandler(entitlement auth.Entitlement) func(d *Daemon, r *http.Request) response.Response { - return func(d *Daemon, r *http.Request) response.Response { - muxVars := mux.Vars(r) - authenticationMethod := muxVars["authenticationMethod"] - err := identity.ValidateAuthenticationMethod(authenticationMethod) +// addIdentityDetailsToContext queries the database for the identity with the given authentication method and the +// `nameOrIdentifier` path argument. This expands the `nameOrIdentifier` so that we can get the fully qualified URL +// of the identity matching what is expected by the authorizer. It returns the Identity for convenience, and also adds +// it to the request context with the ctxClusterDBIdentity context key for later use. +func addIdentityDetailsToContext(s *state.State, r *http.Request, authenticationMethod string) (*dbCluster.Identity, error) { + muxVars := mux.Vars(r) + nameOrID, err := url.PathUnescape(muxVars["nameOrIdentifier"]) + if err != nil { + return nil, fmt.Errorf("Failed to unescape path argument: %w", err) + } + + var id *dbCluster.Identity + err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error { + id, err = dbCluster.GetIdentityByNameOrIdentifier(ctx, tx.Tx(), authenticationMethod, nameOrID) if err != nil { - return response.SmartError(err) + return err } - nameOrID, err := url.PathUnescape(muxVars["nameOrIdentifier"]) - if err != nil { - return response.InternalError(fmt.Errorf("Failed to unescape path argument: %w", err)) + return nil + }) + if err != nil { + if api.StatusErrorCheck(err, http.StatusNotFound) { + // Mask not found error to prevent discovery + return nil, api.NewGenericStatusError(http.StatusNotFound) } - s := d.State() - var id *dbCluster.Identity - err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error { - id, err = dbCluster.GetIdentityByNameOrIdentifier(ctx, tx.Tx(), authenticationMethod, nameOrID) - if err != nil { - return err - } + return nil, err + } - return nil - }) + request.SetCtxValue(r, ctxClusterDBIdentity, id) + return id, nil +} + +// identityAccessHandler performs some initial validation of the request and gets the identity by its name or +// identifier. If one is found, the identifier is used in the URL that is passed to (auth.Authorizer).CheckPermission. +// The cluster.Identity is set in the request context. +func identityAccessHandler(authenticationMethod string, entitlement auth.Entitlement) func(d *Daemon, r *http.Request) response.Response { + return func(d *Daemon, r *http.Request) response.Response { + s := d.State() + id, err := addIdentityDetailsToContext(s, r, authenticationMethod) if err != nil { return response.SmartError(err) } @@ -111,11 +194,358 @@ func identityAccessHandler(entitlement auth.Entitlement) func(d *Daemon, r *http } } - request.SetCtxValue(r, ctxClusterDBIdentity, id) return response.EmptySyncResponse } } +// swagger:operation POST /1.0/auth/identities/tls?public identities identities_post_tls_untrusted +// +// Add a TLS identity +// +// Adds a TLS identity as a trusted client. +// In this mode, the `token` property must be set to the correct value. +// The certificate that the client sent during the TLS handshake will be added. +// The `certificate` field must be omitted. +// +// The `?public` part of the URL isn't required, it's simply used to +// separate the two behaviors of this endpoint. +// +// --- +// consumes: +// - application/json +// produces: +// - application/json +// parameters: +// - in: body +// name: TLS identity +// description: TLS Identity +// required: true +// schema: +// $ref: "#/definitions/IdentitiesPostTLS" +// responses: +// "201": +// $ref: "#/responses/EmptySyncResponse" +// "400": +// $ref: "#/responses/BadRequest" +// "403": +// $ref: "#/responses/Forbidden" +// "500": +// $ref: "#/responses/InternalServerError" + +// swagger:operation POST /1.0/auth/identities/tls identities identities_post_tls +// +// Add a TLS identity. +// +// Adds a TLS identity as a trusted client, or creates a pending TLS identity and returns a token +// for use by an untrusted client. One of `token` or `certificate` must be set. +// +// --- +// consumes: +// - application/json +// produces: +// - application/json +// parameters: +// - in: body +// name: TLS identity +// description: TLS Identity +// required: true +// schema: +// $ref: "#/definitions/IdentitiesPostTLS" +// responses: +// "201": +// oneOf: +// - $ref: "#/responses/CertificateAddToken" +// - $ref: "#/responses/EmptySyncResponse" +// "400": +// $ref: "#/responses/BadRequest" +// "403": +// $ref: "#/responses/Forbidden" +// "500": +// $ref: "#/responses/InternalServerError" +func createIdentityTLS(d *Daemon, r *http.Request) response.Response { + s := d.State() + + // Parse the request. + req := api.IdentitiesTLSPost{} + err := json.NewDecoder(r.Body).Decode(&req) + if err != nil { + return response.BadRequest(err) + } + + networkCert := s.Endpoints.NetworkCert() + serverCert := s.ServerCert() + notify := newIdentityNotificationFunc(s, r, networkCert, serverCert) + + if !auth.IsTrusted(r.Context()) { + return createIdentityTLSUntrusted(r.Context(), s, r.TLS.PeerCertificates, networkCert, req, notify) + } + + return createIdentityTLSTrusted(r.Context(), s, networkCert, req, notify) +} + +// createIdentityTLSUntrusted handles requests to create an identity when the caller is not trusted. +func createIdentityTLSUntrusted(ctx context.Context, s *state.State, peerCertificates []*x509.Certificate, networkCert *shared.CertInfo, req api.IdentitiesTLSPost, notify identityNotificationFunc) response.Response { + // If not trusted a token must be provided. + if req.TrustToken == "" { + return response.Forbidden(errors.New("Trust token required")) + } + + // If not trusted other fields must not be populated. + if req.Token || req.Certificate != "" || req.Name != "" || len(req.Groups) > 0 { + return response.Forbidden(errors.New("Only trust token must be provided")) + } + + // If not trusted get the certificate from the request TLS config. + if len(peerCertificates) < 1 { + return response.BadRequest(fmt.Errorf("No client certificate provided")) + } + + cert := peerCertificates[len(peerCertificates)-1] + + // Validate certificate. + err := certificateValidate(networkCert, cert) + if err != nil { + return response.BadRequest(err) + } + + // Check if certificate add token is valid. + joinToken, err := shared.CertificateTokenDecode(req.TrustToken) + if err != nil { + return response.Forbidden(nil) + } + + // If so then check there is a matching pending TLS identity. + identifier, err := tlsIdentityTokenValidate(ctx, s, *joinToken) + if err != nil { + return response.InternalError(fmt.Errorf("Failed during search for pending TLS identity: %w", err)) + } + + // Activate the pending identity with the certificate. + err = s.DB.Cluster.Transaction(ctx, func(ctx context.Context, tx *db.ClusterTx) error { + return dbCluster.ActivateTLSIdentity(ctx, tx.Tx(), identifier, cert) + }) + if err != nil { + return response.SmartError(err) + } + + // Notify other members, update the cache, and send a lifecycle event. + lc, err := notify(lifecycle.IdentityUpdated, api.AuthenticationMethodTLS, identifier.String(), true) + if err != nil { + return response.SmartError(err) + } + + return response.SyncResponseLocation(true, nil, lc.Source) +} + +// createIdentityTLSTrusted handles requests to create an identity when the caller is trusted. +func createIdentityTLSTrusted(ctx context.Context, s *state.State, networkCert *shared.CertInfo, req api.IdentitiesTLSPost, notify identityNotificationFunc) response.Response { + // Check if the caller has permission to create identities. + err := s.Authorizer.CheckPermission(ctx, entity.ServerURL(), auth.EntitlementCanCreateIdentities) + if err != nil { + return response.SmartError(err) + } + + // A name is required whether getting a token or directly creating the identity with a certificate. + if req.Name == "" { + return response.BadRequest(fmt.Errorf("Identity name must be provided")) + } + + // If the caller is trusted, they should not be providing a trust token + if req.TrustToken != "" { + return response.Conflict(fmt.Errorf("Client already trusted")) + } + + // Can't request a token if a certificate is provided. + if req.Token && req.Certificate != "" { + return response.BadRequest(fmt.Errorf("Can't use certificate if token is requested")) + } + + // If a token is requested, create a pending TLS identity and return an api.CertificateAddToken. + if req.Token { + return createIdentityTLSPending(ctx, s, req, notify) + } + + fingerprint, metadata, err := validateIdentityCert(networkCert, req.Certificate) + if err != nil { + return response.SmartError(err) + } + + err = s.DB.Cluster.Transaction(ctx, func(ctx context.Context, tx *db.ClusterTx) error { + // Check if we already have the certificate. + _, err := dbCluster.GetIdentityID(ctx, tx.Tx(), api.AuthenticationMethodTLS, fingerprint) + if err == nil { + return api.StatusErrorf(http.StatusConflict, "Identity already exists") + } + + // Create the identity. + id, err := dbCluster.CreateIdentity(ctx, tx.Tx(), dbCluster.Identity{ + AuthMethod: api.AuthenticationMethodTLS, + Type: api.IdentityTypeCertificateClient, + Identifier: fingerprint, + Name: req.Name, + Metadata: metadata, + }) + if err != nil { + return err + } + + if len(req.Groups) > 0 { + return dbCluster.SetIdentityAuthGroups(ctx, tx.Tx(), int(id), req.Groups) + } + + return nil + }) + if err != nil { + return response.SmartError(err) + } + + // Notify other members, update the cache, and send a lifecycle event. + lc, err := notify(lifecycle.IdentityCreated, api.AuthenticationMethodTLS, fingerprint, true) + if err != nil { + return response.SmartError(err) + } + + return response.SyncResponseLocation(true, nil, lc.Source) +} + +func createIdentityTLSPending(ctx context.Context, s *state.State, req api.IdentitiesTLSPost, notify identityNotificationFunc) response.Response { + localHTTPSAddress := s.LocalConfig.HTTPSAddress() + + // Tokens are useless if the server isn't listening (how will the untrusted client contact the server?) + if localHTTPSAddress == "" { + return response.BadRequest(fmt.Errorf("Can't issue token when server isn't listening on network")) + } + + // Get all addresses the server is listening on. This is encoded in the certificate token, + // so that the client will not have to specify a server address. The client will iterate + // through all these addresses until it can connect to one of them. + addresses, err := util.ListenAddresses(localHTTPSAddress) + if err != nil { + return response.InternalError(err) + } + + // Generate join secret for new client. This will be stored inside the join token operation and will be + // supplied by the joining client (encoded inside the join token) which will allow us to lookup the correct + // operation in order to validate the requested joining client name is correct and authorised. + joinSecret, err := shared.RandomCryptoString() + if err != nil { + return response.InternalError(err) + } + + // Generate fingerprint of network certificate so joining member can automatically trust the correct + // certificate when it is presented during the join process. + fingerprint, err := shared.CertFingerprintStr(string(s.Endpoints.NetworkPublicKey())) + if err != nil { + return response.InternalError(err) + } + + // Calculate an expiry for the pending TLS identity. + expiry := s.GlobalConfig.RemoteTokenExpiry() + var expiresAt time.Time + if expiry != "" { + expiresAt, err = shared.GetExpiry(time.Now(), expiry) + if err != nil { + return response.InternalError(err) + } + } + + // Generate an identifier for the identity and calculate its metadata. + identifier := uuid.New() + metadata := dbCluster.PendingTLSMetadata{ + Secret: joinSecret, + Expiry: expiresAt, + } + + metadataJSON, err := json.Marshal(metadata) + if err != nil { + return response.InternalError(fmt.Errorf("Failed to encode pending TLS identity metadata: %w", err)) + } + + // Create the identity. + err = s.DB.Cluster.Transaction(ctx, func(ctx context.Context, tx *db.ClusterTx) error { + id, err := dbCluster.CreateIdentity(ctx, tx.Tx(), dbCluster.Identity{ + AuthMethod: api.AuthenticationMethodTLS, + Type: api.IdentityTypeCertificateClientPending, + Identifier: identifier.String(), + Name: req.Name, + Metadata: string(metadataJSON), + }) + if err != nil { + return err + } + + if len(req.Groups) > 0 { + return dbCluster.SetIdentityAuthGroups(ctx, tx.Tx(), int(id), req.Groups) + } + + return nil + }) + if err != nil { + return response.InternalError(fmt.Errorf("Failed to create pending TLS identity: %w", err)) + } + + // Return the CertificateAddToken. + token := api.CertificateAddToken{ + ClientName: req.Name, + Fingerprint: fingerprint, + Addresses: addresses, + Secret: joinSecret, + ExpiresAt: expiresAt, + // Set the Type field so that the client can differentiate + // between tokens meant for the certificates API and the auth API. + Type: api.IdentityTypeCertificateClient, + } + + // Notify other members, update the cache, and send a lifecycle event. + lc, err := notify(lifecycle.IdentityCreated, api.AuthenticationMethodTLS, identifier.String(), false) + if err != nil { + return response.SmartError(err) + } + + return response.SyncResponseLocation(true, token, lc.Source) +} + +func tlsIdentityTokenValidate(ctx context.Context, s *state.State, token api.CertificateAddToken) (uuid.UUID, error) { + var id *dbCluster.Identity + err := s.DB.Cluster.Transaction(ctx, func(ctx context.Context, tx *db.ClusterTx) error { + var err error + id, err = dbCluster.GetPendingTLSIdentityByTokenSecret(ctx, tx.Tx(), token.Secret) + return err + }) + if err != nil { + return uuid.UUID{}, fmt.Errorf("Failed to find a matching pending identity: %w", err) + } + + reverter := revert.New() + defer reverter.Fail() + + reverter.Add(func() { + err := s.DB.Cluster.Transaction(s.ShutdownCtx, func(ctx context.Context, tx *db.ClusterTx) error { + return dbCluster.DeleteIdentity(ctx, tx.Tx(), id.AuthMethod, id.Identifier) + }) + if err != nil { + logger.Warn("Failed to delete invalid or expired pending TLS identity", logger.Ctx{"err": err, "identity_id": id.Identifier}) + } + }) + + metadata, err := id.PendingTLSMetadata() + if err != nil { + return uuid.UUID{}, fmt.Errorf("Failed extracting pending TLS identity metadata: %w", err) + } + + if !metadata.Expiry.IsZero() && metadata.Expiry.Before(time.Now()) { + return uuid.UUID{}, api.StatusErrorf(http.StatusForbidden, "Token has expired") + } + + uid, err := uuid.Parse(id.Identifier) + if err != nil { + return uuid.UUID{}, fmt.Errorf("Unexpected identifier format for pending TLS identity: %w", err) + } + + reverter.Success() + return uid, nil +} + // swagger:operation GET /1.0/auth/identities identities identities_get // // Get the identities @@ -197,11 +627,11 @@ func identityAccessHandler(entitlement auth.Entitlement) func(d *Daemon, r *http // "500": // $ref: "#/responses/InternalServerError" -// swagger:operation GET /1.0/auth/identities/{authenticationMethod} identities identities_get_by_auth_method +// swagger:operation GET /1.0/auth/identities/tls identities identities_get_tls // -// Get the identities +// Get the TLS identities // -// Returns a list of identities (URLs). +// Returns a list of TLS identities (URLs). // // --- // produces: @@ -233,18 +663,18 @@ func identityAccessHandler(entitlement auth.Entitlement) func(d *Daemon, r *http // example: |- // [ // "/1.0/auth/identities/tls/e1e06266e36f67431c996d5678e66d732dfd12fe5073c161e62e6360619fc226", -// "/1.0/auth/identities/oidc/auth0|4daf5e37ce230e455b64b65b" +// "/1.0/auth/identities/tls/6d5678e66d732dfd12fe5073c161eec9962e6360619fc2261e06266e36f67431" // ] // "403": // $ref: "#/responses/Forbidden" // "500": // $ref: "#/responses/InternalServerError" -// swagger:operation GET /1.0/auth/identities/{authenticationMethod}?recursion=1 identities identities_get_by_auth_method_recursion1 +// swagger:operation GET /1.0/auth/identities/oidc identities identities_get_oidc // -// Get the identities +// Get the OIDC identities // -// Returns a list of identities. +// Returns a list of OIDC identities (URLs). // // --- // produces: @@ -270,144 +700,262 @@ func identityAccessHandler(entitlement auth.Entitlement) func(d *Daemon, r *http // example: 200 // metadata: // type: array -// description: List of identities +// description: List of endpoints // items: -// $ref: "#/definitions/Identity" +// type: string +// example: |- +// [ +// "/1.0/auth/identities/oidc/jane.doe@example.com", +// "/1.0/auth/identities/oidc/joe.bloggs@example.com" +// ] // "403": // $ref: "#/responses/Forbidden" // "500": // $ref: "#/responses/InternalServerError" -func getIdentities(d *Daemon, r *http.Request) response.Response { - authenticationMethod, err := url.PathUnescape(mux.Vars(r)["authenticationMethod"]) - if err != nil { - return response.InternalError(fmt.Errorf("Failed to unescape path argument: %w", err)) - } - - if authenticationMethod == "current" { - return getCurrentIdentityInfo(d, r) - } else if authenticationMethod != "" { - err = identity.ValidateAuthenticationMethod(authenticationMethod) - if err != nil { - return response.SmartError(err) - } - } - - recursion := r.URL.Query().Get("recursion") - s := d.State() - canViewIdentity, err := s.Authorizer.GetPermissionChecker(r.Context(), auth.EntitlementCanView, entity.TypeIdentity) - if err != nil { - return response.SmartError(err) - } - - canViewCertificate, err := s.Authorizer.GetPermissionChecker(r.Context(), auth.EntitlementCanView, entity.TypeCertificate) - if err != nil { - return response.SmartError(err) - } - - canView := func(id dbCluster.Identity) bool { - if identity.IsFineGrainedIdentityType(string(id.Type)) { - return canViewIdentity(entity.IdentityURL(string(id.AuthMethod), id.Identifier)) - } - - return canViewCertificate(entity.CertificateURL(id.Identifier)) - } - canViewGroup, err := s.Authorizer.GetPermissionChecker(r.Context(), auth.EntitlementCanView, entity.TypeAuthGroup) - if err != nil { - return response.SmartError(err) - } +// swagger:operation GET /1.0/auth/identities/tls?recursion=1 identities identities_get_tls_recursion1 +// +// Get the TLS identities +// +// Returns a list of TLS identities. +// +// --- +// produces: +// - application/json +// responses: +// "200": +// description: API endpoints +// schema: +// type: object +// description: Sync response +// properties: +// type: +// type: string +// description: Response type +// example: sync +// status: +// type: string +// description: Status description +// example: Success +// status_code: +// type: integer +// description: Status code +// example: 200 +// metadata: +// type: array +// description: List of identities +// items: +// $ref: "#/definitions/Identity" +// "403": +// $ref: "#/responses/Forbidden" +// "500": +// $ref: "#/responses/InternalServerError" - var identities []dbCluster.Identity - var groupsByIdentityID map[int][]dbCluster.AuthGroup - var apiIdentity *api.Identity - err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error { - // Get all identities, filter by authentication method if present. - var filters []dbCluster.IdentityFilter - if authenticationMethod != "" { - clusterAuthMethod := dbCluster.AuthMethod(authenticationMethod) - filters = append(filters, dbCluster.IdentityFilter{AuthMethod: &clusterAuthMethod}) +// swagger:operation GET /1.0/auth/identities/oidc?recursion=1 identities identities_get_oidc_recursion1 +// +// Get the OIDC identities +// +// Returns a list of OIDC identities. +// +// --- +// produces: +// - application/json +// responses: +// "200": +// description: API endpoints +// schema: +// type: object +// description: Sync response +// properties: +// type: +// type: string +// description: Response type +// example: sync +// status: +// type: string +// description: Status description +// example: Success +// status_code: +// type: integer +// description: Status code +// example: 200 +// metadata: +// type: array +// description: List of identities +// items: +// $ref: "#/definitions/Identity" +// "403": +// $ref: "#/responses/Forbidden" +// "500": +// $ref: "#/responses/InternalServerError" +func getIdentities(authenticationMethod string) func(d *Daemon, r *http.Request) response.Response { + return func(d *Daemon, r *http.Request) response.Response { + recursion := r.URL.Query().Get("recursion") + s := d.State() + canViewIdentity, err := s.Authorizer.GetPermissionChecker(r.Context(), auth.EntitlementCanView, entity.TypeIdentity) + if err != nil { + return response.SmartError(err) } - allIdentities, err := dbCluster.GetIdentitys(ctx, tx.Tx(), filters...) + canViewCertificate, err := s.Authorizer.GetPermissionChecker(r.Context(), auth.EntitlementCanView, entity.TypeCertificate) if err != nil { - return err + return response.SmartError(err) } - // Filter results by what the user is allowed to view. - for _, id := range allIdentities { - if canView(id) { - identities = append(identities, id) + canView := func(id dbCluster.Identity) bool { + if identity.IsFineGrainedIdentityType(string(id.Type)) { + return canViewIdentity(entity.IdentityURL(string(id.AuthMethod), id.Identifier)) } + + return canViewCertificate(entity.CertificateURL(id.Identifier)) } - if len(identities) == 0 { - return nil + canViewGroup, err := s.Authorizer.GetPermissionChecker(r.Context(), auth.EntitlementCanView, entity.TypeAuthGroup) + if err != nil { + return response.SmartError(err) } - if recursion == "1" && len(identities) == 1 { - // It's likely that the user can only view themselves. If so we can optimise here by only getting the - // groups for that user. - apiIdentity, err = identities[0].ToAPI(ctx, tx.Tx(), canViewGroup) - if err != nil { - return err + var identities []dbCluster.Identity + var groupsByIdentityID map[int][]dbCluster.AuthGroup + var apiIdentity *api.Identity + err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error { + // Get all identities, filter by authentication method if present. + var filters []dbCluster.IdentityFilter + if authenticationMethod != "" { + clusterAuthMethod := dbCluster.AuthMethod(authenticationMethod) + filters = append(filters, dbCluster.IdentityFilter{AuthMethod: &clusterAuthMethod}) } - } else if recursion == "1" { - // Otherwise, get all groups and populate the identities outside of the transaction. - groupsByIdentityID, err = dbCluster.GetAllAuthGroupsByIdentityIDs(ctx, tx.Tx()) + + allIdentities, err := dbCluster.GetIdentitys(ctx, tx.Tx(), filters...) if err != nil { return err } + + // Filter results by what the user is allowed to view. + for _, id := range allIdentities { + if canView(id) { + identities = append(identities, id) + } + } + + if len(identities) == 0 { + return nil + } + + if recursion == "1" && len(identities) == 1 { + // If there is only one identity to return (either the caller can only view themselves, or there is only one identity in database) + // we can optimise here by only getting the groups for that user. This sets the value of `apiIdentity` + // which is to be returned if non-nil. + apiIdentity, err = identities[0].ToAPI(ctx, tx.Tx(), canViewGroup) + if err != nil { + return err + } + } else if recursion == "1" { + // Otherwise, get all groups and populate the identities outside of the transaction. + // This optimisation prevents us from iterating through each identity and querying the database for the + // groups of each identity in turn. + groupsByIdentityID, err = dbCluster.GetAllAuthGroupsByIdentityIDs(ctx, tx.Tx()) + if err != nil { + return err + } + } + + return nil + }) + if err != nil { + return response.SmartError(err) } - return nil - }) - if err != nil { - return response.SmartError(err) - } + // Optimisation for when only one identity is present on the system. + if apiIdentity != nil { + return response.SyncResponse(true, []api.Identity{*apiIdentity}) + } - // Optimisation for user that can only view themselves. - if apiIdentity != nil { - return response.SyncResponse(true, []api.Identity{*apiIdentity}) - } + if recursion == "1" { + // Convert the []cluster.Group in the groupsByIdentityID map to string slices of the group names. + groupNamesByIdentityID := make(map[int][]string, len(groupsByIdentityID)) + for identityID, groups := range groupsByIdentityID { + for _, group := range groups { + if canViewGroup(entity.AuthGroupURL(group.Name)) { + groupNamesByIdentityID[identityID] = append(groupNamesByIdentityID[identityID], group.Name) + } + } + } - if recursion == "1" { - // Convert the []cluster.Group in the groupsByIdentityID map to string slices of the group names. - groupNamesByIdentityID := make(map[int][]string, len(groupsByIdentityID)) - for identityID, groups := range groupsByIdentityID { - for _, group := range groups { - if canViewGroup(entity.AuthGroupURL(group.Name)) { - groupNamesByIdentityID[identityID] = append(groupNamesByIdentityID[identityID], group.Name) + apiIdentities := make([]api.Identity, 0, len(identities)) + for _, id := range identities { + var certificate string + if id.AuthMethod == api.AuthenticationMethodTLS && id.Type != api.IdentityTypeCertificateClientPending { + metadata, err := id.CertificateMetadata() + if err != nil { + return response.SmartError(err) + } + + certificate = metadata.Certificate } + + apiIdentities = append(apiIdentities, api.Identity{ + AuthenticationMethod: string(id.AuthMethod), + Type: string(id.Type), + Identifier: id.Identifier, + Name: id.Name, + Groups: groupNamesByIdentityID[id.ID], + TLSCertificate: certificate, + }) } + + return response.SyncResponse(true, apiIdentities) } - apiIdentities := make([]api.Identity, 0, len(identities)) + urls := make([]string, 0, len(identities)) for _, id := range identities { - apiIdentities = append(apiIdentities, api.Identity{ - AuthenticationMethod: string(id.AuthMethod), - Type: string(id.Type), - Identifier: id.Identifier, - Name: id.Name, - Groups: groupNamesByIdentityID[id.ID], - }) + urls = append(urls, entity.IdentityURL(string(id.AuthMethod), id.Identifier).String()) } - return response.SyncResponse(true, apiIdentities) - } - - urls := make([]string, 0, len(identities)) - for _, id := range identities { - urls = append(urls, entity.IdentityURL(string(id.AuthMethod), id.Identifier).String()) + return response.SyncResponse(true, urls) } - - return response.SyncResponse(true, urls) } -// swagger:operation GET /1.0/auth/identities/{authenticationMethod}/{nameOrIdentifier} identities identity_get +// swagger:operation GET /1.0/auth/identities/tls/{nameOrIdentifier} identities identity_get_tls // -// Get the identity +// Get the TLS identity // -// Gets a specific identity. +// Gets a specific TLS identity. +// +// --- +// produces: +// - application/json +// responses: +// "200": +// description: API endpoints +// schema: +// type: object +// description: Sync response +// properties: +// type: +// type: string +// description: Response type +// example: sync +// status: +// type: string +// description: Status description +// example: Success +// status_code: +// type: integer +// description: Status code +// example: 200 +// metadata: +// $ref: "#/definitions/Identity" +// "403": +// $ref: "#/responses/Forbidden" +// "500": +// $ref: "#/responses/InternalServerError" + +// swagger:operation GET /1.0/auth/identities/oidc/{nameOrIdentifier} identities identity_get_oidc +// +// Get the OIDC identity +// +// Gets a specific OIDC identity. // // --- // produces: @@ -514,78 +1062,311 @@ func getCurrentIdentityInfo(d *Daemon, r *http.Request) response.Response { // Must be a remote API request. err = identity.ValidateAuthenticationMethod(protocol) if err != nil { - return response.BadRequest(fmt.Errorf("Current identity information must be requested via the HTTPS API")) + return response.BadRequest(fmt.Errorf("Current identity information must be requested via the HTTPS API")) + } + + // Identity provider groups may not be present. + identityProviderGroupNames, _ := request.GetCtxValue[[]string](r.Context(), request.CtxIdentityProviderGroups) + + s := d.State() + var apiIdentity *api.Identity + var effectiveGroups []string + var effectivePermissions []api.Permission + err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error { + id, err := dbCluster.GetIdentity(ctx, tx.Tx(), dbCluster.AuthMethod(protocol), identifier) + if err != nil { + return fmt.Errorf("Failed to get current identity from database: %w", err) + } + + // Using a permission checker here is redundant, we know who the user is, and we know that they are allowed + // to view the groups that they are a member of. + apiIdentity, err = id.ToAPI(ctx, tx.Tx(), func(entityURL *api.URL) bool { return true }) + if err != nil { + return fmt.Errorf("Failed to populate LXD groups: %w", err) + } + + effectiveGroups = apiIdentity.Groups + mappedGroups, err := dbCluster.GetDistinctAuthGroupNamesFromIDPGroupNames(ctx, tx.Tx(), identityProviderGroupNames) + if err != nil { + return fmt.Errorf("Failed to get effective groups: %w", err) + } + + for _, mappedGroup := range mappedGroups { + if !shared.ValueInSlice(mappedGroup, effectiveGroups) { + effectiveGroups = append(effectiveGroups, mappedGroup) + } + } + + permissions, err := dbCluster.GetDistinctPermissionsByGroupNames(ctx, tx.Tx(), effectiveGroups) + if err != nil { + return fmt.Errorf("Failed to get effective permissions: %w", err) + } + + permissions, entityURLs, err := dbCluster.GetPermissionEntityURLs(ctx, tx.Tx(), permissions) + if err != nil { + return fmt.Errorf("Failed to get entity URLs for effective permissions: %w", err) + } + + effectivePermissions = make([]api.Permission, 0, len(permissions)) + for _, permission := range permissions { + effectivePermissions = append(effectivePermissions, api.Permission{ + EntityType: string(permission.EntityType), + EntityReference: entityURLs[entity.Type(permission.EntityType)][permission.EntityID].String(), + Entitlement: string(permission.Entitlement), + }) + } + + return nil + }) + if err != nil { + return response.SmartError(err) + } + + return response.SyncResponse(true, api.IdentityInfo{ + Identity: *apiIdentity, + EffectiveGroups: effectiveGroups, + EffectivePermissions: effectivePermissions, + }) +} + +// swagger:operation PUT /1.0/auth/identities/tls/{nameOrIdentifier} identities identity_put_tls +// +// Update the TLS identity +// +// Replaces the editable fields of a TLS identity +// +// --- +// consumes: +// - application/json +// produces: +// - application/json +// parameters: +// - in: body +// name: identity +// description: Update request +// schema: +// $ref: "#/definitions/IdentityPut" +// responses: +// "200": +// $ref: "#/responses/EmptySyncResponse" +// "400": +// $ref: "#/responses/BadRequest" +// "403": +// $ref: "#/responses/Forbidden" +// "412": +// $ref: "#/responses/PreconditionFailed" +// "500": +// $ref: "#/responses/InternalServerError" +// "501": +// $ref: "#/responses/NotImplemented" + +// swagger:operation PUT /1.0/auth/identities/oidc/{nameOrIdentifier} identities identity_put_oidc +// +// Update the OIDC identity +// +// Replaces the editable fields of an OIDC identity +// +// --- +// consumes: +// - application/json +// produces: +// - application/json +// parameters: +// - in: body +// name: identity +// description: Update request +// schema: +// $ref: "#/definitions/IdentityPut" +// responses: +// "200": +// $ref: "#/responses/EmptySyncResponse" +// "400": +// $ref: "#/responses/BadRequest" +// "403": +// $ref: "#/responses/Forbidden" +// "412": +// $ref: "#/responses/PreconditionFailed" +// "500": +// $ref: "#/responses/InternalServerError" +// "501": +// $ref: "#/responses/NotImplemented" +func updateIdentity(authenticationMethod string) func(d *Daemon, r *http.Request) response.Response { + return func(d *Daemon, r *http.Request) response.Response { + s := d.State() + id, err := addIdentityDetailsToContext(s, r, authenticationMethod) + if err != nil { + return response.SmartError(err) + } + + if !identity.IsFineGrainedIdentityType(string(id.Type)) { + return response.NotImplemented(fmt.Errorf("Identities of type %q cannot be modified via this API", id.Type)) + } + + var identityPut api.IdentityPut + err = json.NewDecoder(r.Body).Decode(&identityPut) + if err != nil { + return response.BadRequest(fmt.Errorf("Failed to unmarshal request body: %w", err)) + } + + if id.Type != api.IdentityTypeCertificateClient && identityPut.TLSCertificate != "" { + return response.BadRequest(fmt.Errorf("Cannot update certificate for identities of type %q", id.Type)) + } + + err = s.Authorizer.CheckPermission(r.Context(), entity.IdentityURL(authenticationMethod, id.Identifier), auth.EntitlementCanEdit) + if err == nil { + return updateIdentityPrivileged(s, r, *id, identityPut) + } else if !auth.IsDeniedError(err) { + return response.SmartError(err) + } + + // Only identities of type api.IdentityTypeCertificateClient may update their own certificate + if id.Type != api.IdentityTypeCertificateClient { + return response.Forbidden(nil) + } + + username, err := auth.GetUsernameFromCtx(r.Context()) + if err != nil { + return response.SmartError(err) + } + + // Identities may only update their own certificate + if username != id.Identifier { + return response.Forbidden(nil) + } + + return updateSelfIdentityUnprivileged(s, r, *id, identityPut) + } +} + +// updateSelfIdentityUnprivileged is only invoked when an identity of type api.IdentityTypeClientCertificate updates their +// own identity and does not have permission to change their own groups. +func updateSelfIdentityUnprivileged(s *state.State, r *http.Request, id dbCluster.Identity, identityPut api.IdentityPut) response.Response { + // Validate the given certificate + fingerprint, metadata, err := validateIdentityCert(s.Endpoints.NetworkCert(), identityPut.TLSCertificate) + if err != nil { + return response.SmartError(err) + } + + // We need to perform an ETag check. To do so we need to convert the DB Identity to an API identity and this + // requires a permission checker on groups. We know that the caller is updating themselves and they are able to view + // all groups that they are a member of, so we can return true for any url here. + canViewGroup := func(entityURL *api.URL) bool { return true } + + err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error { + apiIdentity, err := id.ToAPI(ctx, tx.Tx(), canViewGroup) + if err != nil { + return err + } + + err = util.EtagCheck(r, apiIdentity) + if err != nil { + return err + } + + // Return an error if the caller tries to update their own groups. + if !slices.Equal(identityPut.Groups, apiIdentity.Groups) { + return api.NewStatusError(http.StatusForbidden, "Only the certificate may be changed") + } + + // We needed to start this transaction to check the ETag and the list of groups. However, the only property + // that the unprivileged caller is allowed to update is the certificate. If the given certificate is identical + // to the existing certificate there is no reason to perform the update and we can return without an error + // (making the request idempotent). + if fingerprint == id.Identifier { + return nil + } + + return dbCluster.UpdateIdentity(ctx, tx.Tx(), id.AuthMethod, id.Identifier, dbCluster.Identity{ + AuthMethod: id.AuthMethod, + Type: id.Type, + Identifier: fingerprint, + Name: id.Name, + Metadata: metadata, + }) + }) + if err != nil { + return response.SmartError(err) + } + + // Notify other cluster members to update their identity cache. + notify := newIdentityNotificationFunc(s, r, s.Endpoints.NetworkCert(), s.ServerCert()) + _, err = notify(lifecycle.IdentityUpdated, string(id.AuthMethod), id.Identifier, true) + if err != nil { + return response.SmartError(err) } - // Identity provider groups may not be present. - identityProviderGroupNames, _ := request.GetCtxValue[[]string](r.Context(), request.CtxIdentityProviderGroups) + return response.EmptySyncResponse +} - s := d.State() - var apiIdentity *api.Identity - var effectiveGroups []string - var effectivePermissions []api.Permission - err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error { - id, err := dbCluster.GetIdentity(ctx, tx.Tx(), dbCluster.AuthMethod(protocol), identifier) +// updateIdentityPrivileged is called when the caller has `can_edit` on the identity. It must account for both OIDC and TLS identities. +func updateIdentityPrivileged(s *state.State, r *http.Request, id dbCluster.Identity, identityPut api.IdentityPut) response.Response { + // Validate certificate if given (not present for OIDC or pending TLS identities). + var fingerprint string + var metadata string + if identityPut.TLSCertificate != "" { + var err error + fingerprint, metadata, err = validateIdentityCert(s.Endpoints.NetworkCert(), identityPut.TLSCertificate) if err != nil { - return fmt.Errorf("Failed to get current identity from database: %w", err) + return response.SmartError(err) } + } - // Using a permission checker here is redundant, we know who the user is, and we know that they are allowed - // to view the groups that they are a member of. - apiIdentity, err = id.ToAPI(ctx, tx.Tx(), func(entityURL *api.URL) bool { return true }) - if err != nil { - return fmt.Errorf("Failed to populate LXD groups: %w", err) - } + // We need to perform an ETag check. To do so we need to convert the DB Identity to an API identity and this + // requires a permission checker on groups. + canViewGroup, err := s.Authorizer.GetPermissionChecker(r.Context(), auth.EntitlementCanView, entity.TypeAuthGroup) + if err != nil { + return response.SmartError(err) + } - effectiveGroups = apiIdentity.Groups - mappedGroups, err := dbCluster.GetDistinctAuthGroupNamesFromIDPGroupNames(ctx, tx.Tx(), identityProviderGroupNames) + err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error { + apiIdentity, err := id.ToAPI(ctx, tx.Tx(), canViewGroup) if err != nil { - return fmt.Errorf("Failed to get effective groups: %w", err) - } - - for _, mappedGroup := range mappedGroups { - if !shared.ValueInSlice(mappedGroup, effectiveGroups) { - effectiveGroups = append(effectiveGroups, mappedGroup) - } + return err } - permissions, err := dbCluster.GetDistinctPermissionsByGroupNames(ctx, tx.Tx(), effectiveGroups) + err = util.EtagCheck(r, apiIdentity) if err != nil { - return fmt.Errorf("Failed to get effective permissions: %w", err) + return err } - permissions, entityURLs, err := dbCluster.GetPermissionEntityURLs(ctx, tx.Tx(), permissions) + // Set the groups + err = dbCluster.SetIdentityAuthGroups(ctx, tx.Tx(), id.ID, identityPut.Groups) if err != nil { - return fmt.Errorf("Failed to get entity URLs for effective permissions: %w", err) + return err } - effectivePermissions = make([]api.Permission, 0, len(permissions)) - for _, permission := range permissions { - effectivePermissions = append(effectivePermissions, api.Permission{ - EntityType: string(permission.EntityType), - EntityReference: entityURLs[entity.Type(permission.EntityType)][permission.EntityID].String(), - Entitlement: string(permission.Entitlement), - }) + if identityPut.TLSCertificate == "" || fingerprint == id.Identifier { + return nil } - return nil + // Only update certificate if present and different to the existing one. + return dbCluster.UpdateIdentity(ctx, tx.Tx(), id.AuthMethod, id.Identifier, dbCluster.Identity{ + AuthMethod: id.AuthMethod, + Type: id.Type, + Identifier: fingerprint, + Name: id.Name, + Metadata: metadata, + }) }) if err != nil { return response.SmartError(err) } - return response.SyncResponse(true, api.IdentityInfo{ - Identity: *apiIdentity, - EffectiveGroups: effectiveGroups, - EffectivePermissions: effectivePermissions, - }) + // Notify other cluster members to update their identity cache. + notify := newIdentityNotificationFunc(s, r, s.Endpoints.NetworkCert(), s.ServerCert()) + _, err = notify(lifecycle.IdentityUpdated, string(id.AuthMethod), id.Identifier, true) + if err != nil { + return response.SmartError(err) + } + + return response.EmptySyncResponse } -// swagger:operation PUT /1.0/auth/identities/{authenticationMethod}/{nameOrIdentifier} identities identity_put +// swagger:operation PATCH /1.0/auth/identities/tls/{nameOrIdentifier} identities identity_patch_tls // -// Update the identity +// Partially update the TLS identity // -// Replaces the editable fields of an identity +// Updates the editable fields of a TLS identity // // --- // consumes: @@ -605,32 +1386,119 @@ func getCurrentIdentityInfo(d *Daemon, r *http.Request) response.Response { // $ref: "#/responses/BadRequest" // "403": // $ref: "#/responses/Forbidden" +// "412": +// $ref: "#/responses/PreconditionFailed" // "500": // $ref: "#/responses/InternalServerError" -func updateIdentity(d *Daemon, r *http.Request) response.Response { - id, err := request.GetCtxValue[*dbCluster.Identity](r.Context(), ctxClusterDBIdentity) - if err != nil { - return response.SmartError(err) - } +// "501": +// $ref: "#/responses/NotImplemented" - var identityPut api.IdentityPut - err = json.NewDecoder(r.Body).Decode(&identityPut) - if err != nil { - return response.BadRequest(fmt.Errorf("Failed to unmarshal request body: %w", err)) +// swagger:operation PATCH /1.0/auth/identities/oidc/{nameOrIdentifier} identities identity_patch_oidc +// +// Partially update the OIDC identity +// +// Updates the editable fields of an OIDC identity +// +// --- +// consumes: +// - application/json +// produces: +// - application/json +// parameters: +// - in: body +// name: identity +// description: Update request +// schema: +// $ref: "#/definitions/IdentityPut" +// responses: +// "200": +// $ref: "#/responses/EmptySyncResponse" +// "400": +// $ref: "#/responses/BadRequest" +// "403": +// $ref: "#/responses/Forbidden" +// "412": +// $ref: "#/responses/PreconditionFailed" +// "500": +// $ref: "#/responses/InternalServerError" +// "501": +// $ref: "#/responses/NotImplemented" +func patchIdentity(authenticationMethod string) func(d *Daemon, r *http.Request) response.Response { + return func(d *Daemon, r *http.Request) response.Response { + s := d.State() + id, err := addIdentityDetailsToContext(s, r, authenticationMethod) + if err != nil { + return response.SmartError(err) + } + + if !identity.IsFineGrainedIdentityType(string(id.Type)) { + return response.NotImplemented(fmt.Errorf("Identities of type %q cannot be modified via this API", id.Type)) + } + + var identityPut api.IdentityPut + err = json.NewDecoder(r.Body).Decode(&identityPut) + if err != nil { + return response.BadRequest(fmt.Errorf("Failed to unmarshal request body: %w", err)) + } + + if id.Type != api.IdentityTypeCertificateClient && identityPut.TLSCertificate != "" { + return response.BadRequest(fmt.Errorf("Cannot update certificate for identities of type %q", id.Type)) + } + + if len(identityPut.Groups) == 0 && identityPut.TLSCertificate == "" { + // Nothing to do + return response.EmptySyncResponse + } + + err = s.Authorizer.CheckPermission(r.Context(), entity.IdentityURL(authenticationMethod, id.Identifier), auth.EntitlementCanEdit) + if err == nil { + return patchIdentityPrivileged(s, r, *id, identityPut) + } else if !auth.IsDeniedError(err) { + return response.SmartError(err) + } + + // Only identities of type api.IdentityTypeCertificateClient may update their own certificate + if id.Type != api.IdentityTypeCertificateClient { + return response.Forbidden(nil) + } + + username, err := auth.GetUsernameFromCtx(r.Context()) + if err != nil { + return response.SmartError(err) + } + + // Identities may only update their own certificate + if username != id.Identifier { + return response.Forbidden(nil) + } + + return patchSelfIdentityUnprivileged(s, r, *id, identityPut) } +} - if !identity.IsFineGrainedIdentityType(string(id.Type)) { - return response.NotImplemented(fmt.Errorf("Identities of type %q cannot be modified via this API", id.Type)) +// patchIdentityPrivileged is invoked when the caller has `can_edit` on the identity. It must handle both OIDC and TLS identities. +func patchIdentityPrivileged(s *state.State, r *http.Request, id dbCluster.Identity, identityPut api.IdentityPut) response.Response { + // Parse the certificate if given. + var fingerprint string + var metadata string + if identityPut.TLSCertificate != "" { + var err error + fingerprint, metadata, err = validateIdentityCert(s.Endpoints.NetworkCert(), identityPut.TLSCertificate) + if err != nil { + return response.SmartError(err) + } } - s := d.State() + // We need to perform an ETag check. To do so we need to convert the DB Identity to an API identity and this + // requires a permission checker on groups. canViewGroup, err := s.Authorizer.GetPermissionChecker(r.Context(), auth.EntitlementCanView, entity.TypeAuthGroup) if err != nil { return response.SmartError(err) } + var apiIdentity *api.Identity err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error { - apiIdentity, err := id.ToAPI(ctx, tx.Tx(), canViewGroup) + apiIdentity, err = id.ToAPI(ctx, tx.Tx(), canViewGroup) if err != nil { return err } @@ -640,11 +1508,29 @@ func updateIdentity(d *Daemon, r *http.Request) response.Response { return err } + for _, groupName := range identityPut.Groups { + if !shared.ValueInSlice(groupName, apiIdentity.Groups) { + apiIdentity.Groups = append(apiIdentity.Groups, groupName) + } + } + err = dbCluster.SetIdentityAuthGroups(ctx, tx.Tx(), id.ID, identityPut.Groups) if err != nil { return err } + // Only update the certificate if it is given. Additionally, we don't need to update it if it's the same as the + // existing one. + if identityPut.TLSCertificate != "" && fingerprint != id.Identifier { + return dbCluster.UpdateIdentity(ctx, tx.Tx(), id.AuthMethod, id.Identifier, dbCluster.Identity{ + AuthMethod: id.AuthMethod, + Type: id.Type, + Identifier: fingerprint, + Name: id.Name, + Metadata: metadata, + }) + } + return nil }) if err != nil { @@ -652,45 +1538,108 @@ func updateIdentity(d *Daemon, r *http.Request) response.Response { } // Notify other cluster members to update their identity cache. - notifier, err := cluster.NewNotifier(s, s.Endpoints.NetworkCert(), s.ServerCert(), cluster.NotifyAlive) + notify := newIdentityNotificationFunc(s, r, s.Endpoints.NetworkCert(), s.ServerCert()) + _, err = notify(lifecycle.IdentityUpdated, string(id.AuthMethod), id.Identifier, true) if err != nil { return response.SmartError(err) } - err = notifier(func(client lxd.InstanceServer) error { - _, _, err := client.RawQuery(http.MethodPost, "/internal/identity-cache-refresh", nil, "") - return err - }) + return response.EmptySyncResponse +} + +// patchSelfIdentityUnprivileged is only invoked when an identity of type api.IdentityTypeClientCertificate updates their +// own identity and does not have permission to change their own groups. +func patchSelfIdentityUnprivileged(s *state.State, r *http.Request, id dbCluster.Identity, identityPut api.IdentityPut) response.Response { + if len(identityPut.Groups) > 0 { + return response.Forbidden(errors.New("Only the certificate may be changed")) + } + + if identityPut.TLSCertificate == "" { + // Can only edit the TLS certificate, if one wasn't provided there's nothing to do. + return response.EmptySyncResponse + } + + fingerprint, metadata, err := validateIdentityCert(s.Endpoints.NetworkCert(), identityPut.TLSCertificate) if err != nil { return response.SmartError(err) } - // Send a lifecycle event for the identity update. - lc := lifecycle.IdentityUpdated.Event(string(id.AuthMethod), id.Identifier, request.CreateRequestor(r), nil) - s.Events.SendLifecycle(api.ProjectDefaultName, lc) + if fingerprint == id.Identifier { + // The only property that the unprivileged caller is allowed to update is the certificate. If the given + // certificate is identical to the existing certificate there is no reason to perform the update and we can + // return without an error (making the request idempotent). + return response.EmptySyncResponse + } + + // We need to perform an ETag check. To do so we need to convert the DB Identity to an API identity and this + // requires a permission checker on groups. We know that the caller is updating themselves and they are able to view + // all groups that they are a member of, so we can return true for any url here. + canViewGroup := func(entityURL *api.URL) bool { return true } + + var apiIdentity *api.Identity + err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error { + apiIdentity, err = id.ToAPI(ctx, tx.Tx(), canViewGroup) + if err != nil { + return err + } + + err = util.EtagCheck(r, apiIdentity) + if err != nil { + return err + } + + return dbCluster.UpdateIdentity(ctx, tx.Tx(), id.AuthMethod, id.Identifier, dbCluster.Identity{ + AuthMethod: id.AuthMethod, + Type: id.Type, + Identifier: fingerprint, + Name: id.Name, + Metadata: metadata, + }) + }) + if err != nil { + return response.SmartError(err) + } - s.UpdateIdentityCache() + // Notify other cluster members to update their identity cache. + notify := newIdentityNotificationFunc(s, r, s.Endpoints.NetworkCert(), s.ServerCert()) + _, err = notify(lifecycle.IdentityUpdated, string(id.AuthMethod), id.Identifier, true) + if err != nil { + return response.SmartError(err) + } return response.EmptySyncResponse } -// swagger:operation PATCH /1.0/auth/identities/{authenticationMethod}/{nameOrIdentifier} identities identity_patch +// swagger:operation DELETE /1.0/auth/identities/tls/{nameOrIdentifier} identities identity_delete_tls // -// Partially update the identity +// Delete the TLS identity // -// Updates the editable fields of an identity +// Removes the TLS identity and revokes trust. // // --- -// consumes: +// produces: // - application/json +// responses: +// "200": +// $ref: "#/responses/EmptySyncResponse" +// "400": +// $ref: "#/responses/BadRequest" +// "403": +// $ref: "#/responses/Forbidden" +// "500": +// $ref: "#/responses/InternalServerError" +// "501": +// $ref: "#/responses/NotImplemented" + +// swagger:operation DELETE /1.0/auth/identities/oidc/{nameOrIdentifier} identities identity_delete_oidc +// +// Delete the OIDC identity +// +// Removes the OIDC identity. +// +// --- // produces: // - application/json -// parameters: -// - in: body -// name: identity -// description: Update request -// schema: -// $ref: "#/definitions/IdentityPut" // responses: // "200": // $ref: "#/responses/EmptySyncResponse" @@ -700,78 +1649,89 @@ func updateIdentity(d *Daemon, r *http.Request) response.Response { // $ref: "#/responses/Forbidden" // "500": // $ref: "#/responses/InternalServerError" -func patchIdentity(d *Daemon, r *http.Request) response.Response { +// "501": +// $ref: "#/responses/NotImplemented" +func deleteIdentity(d *Daemon, r *http.Request) response.Response { id, err := request.GetCtxValue[*dbCluster.Identity](r.Context(), ctxClusterDBIdentity) if err != nil { return response.SmartError(err) } - var identityPut api.IdentityPut - err = json.NewDecoder(r.Body).Decode(&identityPut) - if err != nil { - return response.BadRequest(fmt.Errorf("Failed to unmarshal request body: %w", err)) - } - if !identity.IsFineGrainedIdentityType(string(id.Type)) { return response.NotImplemented(fmt.Errorf("Identities of type %q cannot be modified via this API", id.Type)) } s := d.State() - canViewGroup, err := s.Authorizer.GetPermissionChecker(r.Context(), auth.EntitlementCanView, entity.TypeAuthGroup) + err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error { + return dbCluster.DeleteIdentity(ctx, tx.Tx(), id.AuthMethod, id.Identifier) + }) if err != nil { return response.SmartError(err) } - var apiIdentity *api.Identity - err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error { - apiIdentity, err = id.ToAPI(ctx, tx.Tx(), canViewGroup) - if err != nil { - return err - } + // Notify other cluster members to update their identity cache. + notify := newIdentityNotificationFunc(s, r, s.Endpoints.NetworkCert(), s.ServerCert()) + _, err = notify(lifecycle.IdentityDeleted, string(id.AuthMethod), id.Identifier, true) + if err != nil { + return response.SmartError(err) + } - err = util.EtagCheck(r, apiIdentity) - if err != nil { - return err - } + return response.EmptySyncResponse +} - for _, groupName := range identityPut.Groups { - if !shared.ValueInSlice(groupName, apiIdentity.Groups) { - apiIdentity.Groups = append(apiIdentity.Groups, groupName) +// newIdentityNotificationFunc returns a function that creates and sends a lifecycle event for the identity. +// If updateCache is true, the local identity cache is updated and a notification is sent to other members to do the same. +func newIdentityNotificationFunc(s *state.State, r *http.Request, networkCert *shared.CertInfo, serverCert *shared.CertInfo) identityNotificationFunc { + return func(action lifecycle.IdentityAction, authenticationMethod string, identifier string, updateCache bool) (*api.EventLifecycle, error) { + if updateCache { + // Send a notification to other cluster members to refresh their identity cache. + notifier, err := cluster.NewNotifier(s, networkCert, serverCert, cluster.NotifyAlive) + if err != nil { + return nil, err } - } - err = dbCluster.SetIdentityAuthGroups(ctx, tx.Tx(), id.ID, identityPut.Groups) - if err != nil { - return err + err = notifier(func(member db.NodeInfo, client lxd.InstanceServer) error { + _, _, err := client.RawQuery(http.MethodPost, "/internal/identity-cache-refresh", nil, "") + return err + }) + if err != nil { + return nil, err + } + + // Reload the identity cache to add the new certificate. + s.UpdateIdentityCache() } - return nil - }) - if err != nil { - return response.SmartError(err) + lc := action.Event(authenticationMethod, identifier, request.CreateRequestor(r), nil) + s.Events.SendLifecycle(api.ProjectDefaultName, lc) + + return &lc, nil + } +} + +// validateIdentityCert validates the certificate and returns the fingerprint and dbCluster.CertificateMetadata for the +// identity encoded as JSON. +func validateIdentityCert(networkCert *shared.CertInfo, cert string) (fingerprint string, metadataJSON string, err error) { + if cert == "" { + return "", "", api.NewStatusError(http.StatusBadRequest, "Must provide a certificate") } - // Notify other cluster members to update their identity cache. - notifier, err := cluster.NewNotifier(s, s.Endpoints.NetworkCert(), s.ServerCert(), cluster.NotifyAlive) + x509Cert, err := shared.ParseCert([]byte(cert)) if err != nil { - return response.SmartError(err) + return "", "", api.StatusErrorf(http.StatusBadRequest, "Failed to parse certificate: %w", err) } - err = notifier(func(client lxd.InstanceServer) error { - _, _, err := client.RawQuery(http.MethodPost, "/internal/identity-cache-refresh", nil, "") - return err - }) + err = certificateValidate(networkCert, x509Cert) if err != nil { - return response.SmartError(err) + return "", "", fmt.Errorf("Invalid certificate: %w", err) } - // Send a lifecycle event for the identity update. - lc := lifecycle.IdentityUpdated.Event(string(id.AuthMethod), id.Identifier, request.CreateRequestor(r), nil) - s.Events.SendLifecycle(api.ProjectDefaultName, lc) - - s.UpdateIdentityCache() + b, err := json.Marshal(dbCluster.CertificateMetadata{Certificate: cert}) + if err != nil { + return "", "", fmt.Errorf("Failed to encode certificate metadata: %w", err) + } - return response.EmptySyncResponse + return shared.CertFingerprint(x509Cert), string(b), nil } // updateIdentityCache reads all identities from the database and sets them in the identity.Cache. @@ -836,9 +1796,23 @@ func updateIdentityCache(d *Daemon) { return } + cacheableIdentityTypes := []string{ + api.IdentityTypeCertificateClientRestricted, + api.IdentityTypeCertificateClientUnrestricted, + api.IdentityTypeCertificateClient, + api.IdentityTypeCertificateServer, + api.IdentityTypeCertificateMetricsRestricted, + api.IdentityTypeCertificateMetricsUnrestricted, + api.IdentityTypeOIDCClient, + } + identityCacheEntries := make([]identity.CacheEntry, 0, len(identities)) var localServerCerts []dbCluster.Certificate for _, id := range identities { + if !shared.ValueInSlice(string(id.Type), cacheableIdentityTypes) { + continue + } + cacheEntry := identity.CacheEntry{ Identifier: id.Identifier, Name: id.Name, diff --git a/lxd/identity/util.go b/lxd/identity/util.go index 4aec2452cc2f..028b6e981008 100644 --- a/lxd/identity/util.go +++ b/lxd/identity/util.go @@ -10,7 +10,7 @@ import ( // IsFineGrainedIdentityType returns true if permissions of the identity type are managed via group membership. func IsFineGrainedIdentityType(identityType string) bool { - return shared.ValueInSlice(identityType, []string{api.IdentityTypeOIDCClient}) + return shared.ValueInSlice(identityType, []string{api.IdentityTypeOIDCClient, api.IdentityTypeCertificateClient, api.IdentityTypeCertificateClientPending}) } // IsRestrictedIdentityType returns whether the given identity is restricted or not. Identity types that are not @@ -28,7 +28,7 @@ func IsRestrictedIdentityType(identityType string) (bool, error) { // identity types must correspond to an authentication method. An error is returned if the identity type is not recognised. func AuthenticationMethodFromIdentityType(identityType string) (string, error) { switch identityType { - case api.IdentityTypeCertificateClientRestricted, api.IdentityTypeCertificateClientUnrestricted, api.IdentityTypeCertificateServer, api.IdentityTypeCertificateMetricsRestricted, api.IdentityTypeCertificateMetricsUnrestricted: + case api.IdentityTypeCertificateClientRestricted, api.IdentityTypeCertificateClientUnrestricted, api.IdentityTypeCertificateServer, api.IdentityTypeCertificateMetricsRestricted, api.IdentityTypeCertificateMetricsUnrestricted, api.IdentityTypeCertificateClient: return api.AuthenticationMethodTLS, nil case api.IdentityTypeOIDCClient: return api.AuthenticationMethodOIDC, nil diff --git a/lxd/identity_provider_groups.go b/lxd/identity_provider_groups.go index 6b1dc97261f4..ac7274137580 100644 --- a/lxd/identity_provider_groups.go +++ b/lxd/identity_provider_groups.go @@ -24,8 +24,9 @@ import ( ) var identityProviderGroupsCmd = APIEndpoint{ - Name: "identity_provider_groups", - Path: "auth/identity-provider-groups", + Name: "identity_provider_groups", + Path: "auth/identity-provider-groups", + MetricsType: entity.TypeIdentity, Get: APIEndpointAction{ Handler: getIdentityProviderGroups, AccessHandler: allowAuthenticated, @@ -37,8 +38,9 @@ var identityProviderGroupsCmd = APIEndpoint{ } var identityProviderGroupCmd = APIEndpoint{ - Name: "identity_provider_group", - Path: "auth/identity-provider-groups/{idpGroupName}", + Name: "identity_provider_group", + Path: "auth/identity-provider-groups/{idpGroupName}", + MetricsType: entity.TypeIdentity, Get: APIEndpointAction{ Handler: getIdentityProviderGroup, AccessHandler: allowPermission(entity.TypeIdentityProviderGroup, auth.EntitlementCanView, "idpGroupName"), @@ -324,7 +326,7 @@ func createIdentityProviderGroup(d *Daemon, r *http.Request) response.Response { return response.SmartError(err) } - err = notifier(func(client lxd.InstanceServer) error { + err = notifier(func(member db.NodeInfo, client lxd.InstanceServer) error { _, _, err := client.RawQuery(http.MethodPost, "/internal/identity-cache-refresh", nil, "") return err }) @@ -393,7 +395,7 @@ func renameIdentityProviderGroup(d *Daemon, r *http.Request) response.Response { return response.SmartError(err) } - err = notifier(func(client lxd.InstanceServer) error { + err = notifier(func(member db.NodeInfo, client lxd.InstanceServer) error { _, _, err := client.RawQuery(http.MethodPost, "/internal/identity-cache-refresh", nil, "") return err }) @@ -482,7 +484,7 @@ func updateIdentityProviderGroup(d *Daemon, r *http.Request) response.Response { return response.SmartError(err) } - err = notifier(func(client lxd.InstanceServer) error { + err = notifier(func(member db.NodeInfo, client lxd.InstanceServer) error { _, _, err := client.RawQuery(http.MethodPost, "/internal/identity-cache-refresh", nil, "") return err }) @@ -578,7 +580,7 @@ func patchIdentityProviderGroup(d *Daemon, r *http.Request) response.Response { return response.SmartError(err) } - err = notifier(func(client lxd.InstanceServer) error { + err = notifier(func(member db.NodeInfo, client lxd.InstanceServer) error { _, _, err := client.RawQuery(http.MethodPost, "/internal/identity-cache-refresh", nil, "") return err }) @@ -633,7 +635,7 @@ func deleteIdentityProviderGroup(d *Daemon, r *http.Request) response.Response { return response.SmartError(err) } - err = notifier(func(client lxd.InstanceServer) error { + err = notifier(func(member db.NodeInfo, client lxd.InstanceServer) error { _, _, err := client.RawQuery(http.MethodPost, "/internal/identity-cache-refresh", nil, "") return err }) diff --git a/lxd/images.go b/lxd/images.go index 4de531ebd939..4afe63993ceb 100644 --- a/lxd/images.go +++ b/lxd/images.go @@ -58,14 +58,16 @@ import ( ) var imagesCmd = APIEndpoint{ - Path: "images", + Path: "images", + MetricsType: entity.TypeImage, Get: APIEndpointAction{Handler: imagesGet, AllowUntrusted: true}, Post: APIEndpointAction{Handler: imagesPost, AllowUntrusted: true}, } var imageCmd = APIEndpoint{ - Path: "images/{fingerprint}", + Path: "images/{fingerprint}", + MetricsType: entity.TypeImage, Delete: APIEndpointAction{Handler: imageDelete, AccessHandler: imageAccessHandler(auth.EntitlementCanDelete)}, Get: APIEndpointAction{Handler: imageGet, AllowUntrusted: true}, @@ -74,33 +76,38 @@ var imageCmd = APIEndpoint{ } var imageExportCmd = APIEndpoint{ - Path: "images/{fingerprint}/export", + Path: "images/{fingerprint}/export", + MetricsType: entity.TypeImage, Get: APIEndpointAction{Handler: imageExport, AllowUntrusted: true}, Post: APIEndpointAction{Handler: imageExportPost, AccessHandler: imageAccessHandler(auth.EntitlementCanEdit)}, } var imageSecretCmd = APIEndpoint{ - Path: "images/{fingerprint}/secret", + Path: "images/{fingerprint}/secret", + MetricsType: entity.TypeImage, Post: APIEndpointAction{Handler: imageSecret, AccessHandler: imageAccessHandler(auth.EntitlementCanEdit)}, } var imageRefreshCmd = APIEndpoint{ - Path: "images/{fingerprint}/refresh", + Path: "images/{fingerprint}/refresh", + MetricsType: entity.TypeImage, Post: APIEndpointAction{Handler: imageRefresh, AccessHandler: imageAccessHandler(auth.EntitlementCanEdit)}, } var imageAliasesCmd = APIEndpoint{ - Path: "images/aliases", + Path: "images/aliases", + MetricsType: entity.TypeImage, Get: APIEndpointAction{Handler: imageAliasesGet, AccessHandler: allowProjectResourceList}, Post: APIEndpointAction{Handler: imageAliasesPost, AccessHandler: allowPermission(entity.TypeProject, auth.EntitlementCanCreateImageAliases)}, } var imageAliasCmd = APIEndpoint{ - Path: "images/aliases/{name:.*}", + Path: "images/aliases/{name:.*}", + MetricsType: entity.TypeImage, Delete: APIEndpointAction{Handler: imageAliasDelete, AccessHandler: imageAliasAccessHandler(auth.EntitlementCanDelete)}, Get: APIEndpointAction{Handler: imageAliasGet, AllowUntrusted: true}, @@ -1222,7 +1229,7 @@ func imagesPost(d *Daemon, r *http.Request) response.Response { /* Processing image upload */ info, err = getImgPostInfo(s, r, builddir, projectName, post, imageMetadata) } else { - if req.Source.Type == "image" { + if req.Source.Type == api.SourceTypeImage { /* Processing image copy from remote */ info, err = imgPostRemoteInfo(s, r, req, op, projectName, budget) } else if req.Source.Type == "url" { @@ -1445,30 +1452,53 @@ func getImageMetadata(fname string) (*api.ImageMetadata, string, error) { return &result, imageType, nil } -func doImagesGet(ctx context.Context, tx *db.ClusterTx, recursion bool, projectName string, public bool, clauses *filter.ClauseSet, hasPermission auth.PermissionChecker) (any, error) { +func doImagesGet(ctx context.Context, tx *db.ClusterTx, recursion bool, projectName string, public bool, clauses *filter.ClauseSet, hasPermission auth.PermissionChecker, allProjects bool) (any, error) { mustLoadObjects := recursion || (clauses != nil && len(clauses.Clauses) > 0) - fingerprints, err := tx.GetImagesFingerprints(ctx, projectName, public) - if err != nil { - return err, err + imagesProjectsMap := map[string][]string{} + if allProjects { + var err error + + imagesProjectsMap, err = tx.GetImages(ctx) + if err != nil { + return nil, err + } + } else { + fingerprints, err := tx.GetImagesFingerprints(ctx, projectName, public) + if err != nil { + return nil, err + } + + for _, fingerprint := range fingerprints { + imagesProjectsMap[fingerprint] = []string{projectName} + } } var resultString []string var resultMap []*api.Image if recursion { - resultMap = make([]*api.Image, 0, len(fingerprints)) + resultMap = make([]*api.Image, 0, len(imagesProjectsMap)) } else { - resultString = make([]string, 0, len(fingerprints)) + resultString = make([]string, 0, len(imagesProjectsMap)) } - for _, fingerprint := range fingerprints { - image, err := doImageGet(ctx, tx, projectName, fingerprint, public) + for fingerprint, projects := range imagesProjectsMap { + hasAccess := false + + image, err := doImageGet(ctx, tx, projects[0], fingerprint, public) if err != nil { continue } - if !image.Public && !hasPermission(entity.ImageURL(projectName, fingerprint)) { + for _, project := range projects { + if image.Public || hasPermission(entity.ImageURL(project, fingerprint)) { + hasAccess = true + break + } + } + + if !hasAccess { continue } @@ -1521,6 +1551,10 @@ func doImagesGet(ctx context.Context, tx *db.ClusterTx, recursion bool, projectN // description: Collection filter // type: string // example: default +// - in: query +// name: all-projects +// description: Retrieve images from all projects +// type: boolean // responses: // "200": // description: API endpoints @@ -1575,6 +1609,10 @@ func doImagesGet(ctx context.Context, tx *db.ClusterTx, recursion bool, projectN // description: Collection filter // type: string // example: default +// - in: query +// name: all-projects +// description: Retrieve images from all projects +// type: boolean // responses: // "200": // description: API endpoints @@ -1624,6 +1662,10 @@ func doImagesGet(ctx context.Context, tx *db.ClusterTx, recursion bool, projectN // description: Collection filter // type: string // example: default +// - in: query +// name: all-projects +// description: Retrieve images from all projects +// type: boolean // responses: // "200": // description: API endpoints @@ -1678,6 +1720,11 @@ func doImagesGet(ctx context.Context, tx *db.ClusterTx, recursion bool, projectN // description: Collection filter // type: string // example: default +// - in: query +// name: all-projects +// description: Retrieve images from all projects +// type: boolean +// example: default // responses: // "200": // description: API endpoints @@ -1708,8 +1755,14 @@ func doImagesGet(ctx context.Context, tx *db.ClusterTx, recursion bool, projectN // $ref: "#/responses/InternalServerError" func imagesGet(d *Daemon, r *http.Request) response.Response { projectName := request.ProjectParam(r) + allProjects := shared.IsTrue(r.FormValue("all-projects")) filterStr := r.FormValue("filter") + // ProjectParam returns default if not set + if allProjects && projectName != api.ProjectDefaultName { + return response.BadRequest(fmt.Errorf("Cannot specify a project when requesting all projects")) + } + s := d.State() var effectiveProjectName string err := s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error { @@ -1723,8 +1776,14 @@ func imagesGet(d *Daemon, r *http.Request) response.Response { request.SetCtxValue(r, request.CtxEffectiveProjectName, effectiveProjectName) - // If the caller is not trusted, we only want to list public images. - publicOnly := !auth.IsTrusted(r.Context()) + // If the caller is not trusted, we only want to list public images in the default project. + trusted := auth.IsTrusted(r.Context()) + publicOnly := !trusted + + // Untrusted callers can't request images from all projects or projects other than default. + if !trusted && (allProjects || projectName != api.ProjectDefaultName) { + return response.Forbidden(errors.New("Untrusted callers may only access public images in the default project")) + } // Get a permission checker. If the caller is not authenticated, the permission checker will deny all. // However, the permission checker is only called when an image is private. Both trusted and untrusted clients will @@ -1741,7 +1800,7 @@ func imagesGet(d *Daemon, r *http.Request) response.Response { var result any err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error { - result, err = doImagesGet(ctx, tx, util.IsRecursionRequest(r), projectName, publicOnly, clauses, canViewImage) + result, err = doImagesGet(ctx, tx, util.IsRecursionRequest(r), projectName, publicOnly, clauses, canViewImage, allProjects) if err != nil { return err } @@ -2698,17 +2757,9 @@ func pruneExpiredImages(ctx context.Context, s *state.State, op *operations.Oper } // Remove main image file. - fname := filepath.Join(s.OS.VarDir, "images", fingerprint) - err = os.Remove(fname) - if err != nil && !os.IsNotExist(err) { - return fmt.Errorf("Error deleting image file %q: %w", fname, err) - } - - // Remove the rootfs file for the image. - fname = filepath.Join(s.OS.VarDir, "images", fingerprint) + ".rootfs" - err = os.Remove(fname) - if err != nil && !os.IsNotExist(err) { - return fmt.Errorf("Error deleting image file %q: %w", fname, err) + err := imageDeleteFromDisk(fingerprint) + if err != nil { + return err } logger.Info("Deleted expired cached image files and volumes", logger.Ctx{"fingerprint": fingerprint}) @@ -2814,7 +2865,7 @@ func imageDelete(d *Daemon, r *http.Request) response.Response { return err } - err = notifier(func(client lxd.InstanceServer) error { + err = notifier(func(member db.NodeInfo, client lxd.InstanceServer) error { op, err := client.UseProject(projectName).DeleteImage(details.image.Fingerprint) if err != nil { return fmt.Errorf("Failed to request to delete image from peer node: %w", err) @@ -2868,6 +2919,12 @@ func imageDelete(d *Daemon, r *http.Request) response.Response { } } + // Remove main image file from disk. + err = imageDeleteFromDisk(details.image.Fingerprint) + if err != nil { + return err + } + // Remove the database entry. if !isClusterNotification(r) { err = s.DB.Cluster.Transaction(context.TODO(), func(ctx context.Context, tx *db.ClusterTx) error { @@ -2878,9 +2935,6 @@ func imageDelete(d *Daemon, r *http.Request) response.Response { } } - // Remove main image file from disk. - imageDeleteFromDisk(details.image.Fingerprint) - s.Events.SendLifecycle(projectName, lifecycle.ImageDeleted.Event(details.image.Fingerprint, projectName, op.Requestor(), nil)) return nil @@ -2897,14 +2951,14 @@ func imageDelete(d *Daemon, r *http.Request) response.Response { return operations.OperationResponse(op) } -// Helper to delete an image file from the local images directory. -func imageDeleteFromDisk(fingerprint string) { +// imageDeleteFromDisk removes the main image file and rootfs file of an image. +func imageDeleteFromDisk(fingerprint string) error { // Remove main image file. fname := shared.VarPath("images", fingerprint) if shared.PathExists(fname) { err := os.Remove(fname) if err != nil && !os.IsNotExist(err) { - logger.Errorf("Error deleting image file %s: %s", fname, err) + return fmt.Errorf("Error deleting image file %s: %s", fname, err) } } @@ -2913,9 +2967,11 @@ func imageDeleteFromDisk(fingerprint string) { if shared.PathExists(fname) { err := os.Remove(fname) if err != nil && !os.IsNotExist(err) { - logger.Errorf("Error deleting image file %s: %s", fname, err) + return fmt.Errorf("Error deleting image file %s: %s", fname, err) } } + + return nil } func doImageGet(ctx context.Context, tx *db.ClusterTx, project, fingerprint string, public bool) (*api.Image, error) { @@ -4251,7 +4307,7 @@ func imageExport(d *Daemon, r *http.Request) response.Response { requestor := request.CreateRequestor(r) s.Events.SendLifecycle(projectName, lifecycle.ImageRetrieved.Event(imgInfo.Fingerprint, projectName, requestor, nil)) - return response.FileResponse(r, files, nil) + return response.FileResponse(files, nil) } files := make([]response.FileResponseEntry, 1) @@ -4262,7 +4318,7 @@ func imageExport(d *Daemon, r *http.Request) response.Response { requestor := request.CreateRequestor(r) s.Events.SendLifecycle(projectName, lifecycle.ImageRetrieved.Event(imgInfo.Fingerprint, projectName, requestor, nil)) - return response.FileResponse(r, files, nil) + return response.FileResponse(files, nil) } // swagger:operation POST /1.0/images/{fingerprint}/export images images_export_post @@ -4599,21 +4655,19 @@ func autoSyncImagesTask(d *Daemon) (task.Func, task.Schedule) { f := func(ctx context.Context) { s := d.State() - // In order to only have one task operation executed per image when syncing the images - // across the cluster, only leader node can launch the task, no others. - localClusterAddress := s.LocalConfig.ClusterAddress() - - leader, err := d.gateway.LeaderAddress() + leaderInfo, err := s.LeaderInfo() if err != nil { - if errors.Is(err, cluster.ErrNodeIsNotClustered) { - return // No error if not clustered. - } - logger.Error("Failed to get leader cluster member address", logger.Ctx{"err": err}) return } - if localClusterAddress != leader { + if !leaderInfo.Clustered { + return + } + + // In order to only have one task operation executed per image when syncing the images + // across the cluster, only leader node can launch the task, no others. + if !leaderInfo.Leader { logger.Debug("Skipping image synchronization task since we're not leader") return } diff --git a/lxd/instance.go b/lxd/instance.go index 339b80665319..caf089e636a0 100644 --- a/lxd/instance.go +++ b/lxd/instance.go @@ -179,6 +179,15 @@ func instanceCreateFromImage(s *state.State, img *api.Image, args db.InstanceArg return fmt.Errorf("Failed loading instance storage pool: %w", err) } + // Lock this operation to ensure that concurrent image operations don't conflict. + // Other operations will wait for this one to finish. + unlock, err := imageOperationLock(img.Fingerprint) + if err != nil { + return err + } + + defer unlock() + err = pool.CreateInstanceFromImage(inst, img.Fingerprint, op) if err != nil { return fmt.Errorf("Failed creating instance from image: %w", err) diff --git a/lxd/instance/drivers/driver_common.go b/lxd/instance/drivers/driver_common.go index 65cbc7fcce96..4ba392ee3bc6 100644 --- a/lxd/instance/drivers/driver_common.go +++ b/lxd/instance/drivers/driver_common.go @@ -1035,6 +1035,30 @@ func (d *common) validateStartup(statusCode api.StatusCode) error { return nil } +// Returns an api status code for any ongoing instance operations, or nil if no +// operation is ongoing. +func (d *common) operationStatusCode() *api.StatusCode { + op := operationlock.Get(d.Project().Name, d.Name()) + if op != nil { + if op.Action() == operationlock.ActionStart { + stopped := api.Stopped + return &stopped + } + + if op.Action() == operationlock.ActionStop { + if shared.IsTrue(d.LocalConfig()["volatile.last_state.ready"]) { + ready := api.Ready + return &ready + } + + running := api.Running + return &running + } + } + + return nil +} + // onStopOperationSetup creates or picks up the relevant operation. This is used in the stopns and stop hooks to // ensure that a lock on their activities is held before the instance process is stopped. This prevents a start // request run at the same time from overlapping with the stop process. diff --git a/lxd/instance/drivers/driver_lxc.go b/lxd/instance/drivers/driver_lxc.go index 8993108d5cb5..9f996ef8b79d 100644 --- a/lxd/instance/drivers/driver_lxc.go +++ b/lxd/instance/drivers/driver_lxc.go @@ -41,6 +41,7 @@ import ( "github.com/canonical/lxd/lxd/db/cluster" "github.com/canonical/lxd/lxd/db/operationtype" "github.com/canonical/lxd/lxd/device" + "github.com/canonical/lxd/lxd/device/cdi" deviceConfig "github.com/canonical/lxd/lxd/device/config" "github.com/canonical/lxd/lxd/device/nictype" "github.com/canonical/lxd/lxd/idmap" @@ -936,14 +937,24 @@ func (d *lxc) initLXC(config bool) (*liblxc.Container, error) { return nil, err } + // Instruct liblxc to use the lxd-stophook wrapper in the bin directory of the snap instead of lxd in sbin. + // This is the historical path where the lxd binary used to be, but it was replaced with a small wrapper + // script which redirects the stop hook requests to lxd-user (which is statically compiled) so that stop + // hook notifications to LXD work when the snap base version is changed. + lxdStopHookPath := d.state.OS.ExecPath + if shared.InSnap() && strings.HasSuffix(lxdStopHookPath, "sbin/lxd") { + // Convert /snap/lxd/current/sbin/lxd into /snap/lxd/current/bin/lxd. + lxdStopHookPath = strings.TrimSuffix(lxdStopHookPath, "sbin/lxd") + "bin/lxd" + } + // Call the onstopns hook on stop but before namespaces are unmounted. - err = lxcSetConfigItem(cc, "lxc.hook.stop", fmt.Sprintf("%s callhook %s %s %s stopns", d.state.OS.ExecPath, shared.VarPath(""), strconv.Quote(d.Project().Name), strconv.Quote(d.Name()))) + err = lxcSetConfigItem(cc, "lxc.hook.stop", fmt.Sprintf("%s callhook %s %s %s stopns", lxdStopHookPath, shared.VarPath(""), strconv.Quote(d.Project().Name), strconv.Quote(d.Name()))) if err != nil { return nil, err } // Call the onstop hook on stop. - err = lxcSetConfigItem(cc, "lxc.hook.post-stop", fmt.Sprintf("%s callhook %s %s %s stop", d.state.OS.ExecPath, shared.VarPath(""), strconv.Quote(d.Project().Name), strconv.Quote(d.Name()))) + err = lxcSetConfigItem(cc, "lxc.hook.post-stop", fmt.Sprintf("%s callhook %s %s %s stop", lxdStopHookPath, shared.VarPath(""), strconv.Quote(d.Project().Name), strconv.Quote(d.Name()))) if err != nil { return nil, err } @@ -1356,7 +1367,7 @@ func (d *lxc) IdmappedStorage(path string, fstype string) idmap.IdmapStorageType if bindMount { err := unix.Statfs(path, buf) if err != nil { - d.logger.Error("Failed to statfs", logger.Ctx{"path": path, "err": err}) + d.logger.Warn("Failed to statfs", logger.Ctx{"path": path, "err": err}) return mode } } @@ -2057,6 +2068,7 @@ func (d *lxc) startCommon() (string, []func() error, error) { // Create the devices nicID := -1 nvidiaDevices := []string{} + cdiConfigFiles := []string{} sortedDevices := d.expandedDevices.Sorted() startDevices := make([]device.Device, 0, len(sortedDevices)) @@ -2227,6 +2239,10 @@ func (d *lxc) startCommon() (string, []func() error, error) { if entry.Key == device.GPUNvidiaDeviceKey { nvidiaDevices = append(nvidiaDevices, entry.Value) } + + if entry.Key == cdi.CDIHookDefinitionKey { + cdiConfigFiles = append(cdiConfigFiles, entry.Value) + } } } } @@ -2239,6 +2255,13 @@ func (d *lxc) startCommon() (string, []func() error, error) { } } + if len(cdiConfigFiles) > 0 { + err = lxcSetConfigItem(cc, "lxc.hook.mount", fmt.Sprintf("%s callhook %s %s %s startmountns --devicesRootFolder %s %s", d.state.OS.ExecPath, shared.VarPath(""), strconv.Quote(d.Project().Name), strconv.Quote(d.Name()), d.DevicesPath(), strings.Join(cdiConfigFiles, " "))) + if err != nil { + return "", nil, fmt.Errorf("Unable to set the startmountns callhook to process CDI hooks files (%q) for instance %q in project %q: %w", strings.Join(cdiConfigFiles, ","), d.Name(), d.Project().Name, err) + } + } + // Load the LXC raw config. err = d.loadRawLXCConfig(cc) if err != nil { @@ -2563,7 +2586,7 @@ func (d *lxc) onStart(_ map[string]string) error { } // Trigger a rebalance - cgroup.TaskSchedulerTrigger("container", d.name, "started") + cgroup.TaskSchedulerTrigger(d.dbType, d.name, "started") // Record last start state. err = d.recordLastState() @@ -3042,7 +3065,7 @@ func (d *lxc) onStop(args map[string]string) error { } // Trigger a rebalance - cgroup.TaskSchedulerTrigger("container", d.name, "stopped") + cgroup.TaskSchedulerTrigger(d.dbType, d.name, "stopped") // Destroy ephemeral containers if d.ephemeral { @@ -4859,7 +4882,7 @@ func (d *lxc) Update(args db.InstanceArgs, userRequested bool) error { if cpuLimitWasChanged { // Trigger a scheduler re-run - cgroup.TaskSchedulerTrigger("container", d.name, "changed") + cgroup.TaskSchedulerTrigger(d.dbType, d.name, "changed") } if userRequested { @@ -6967,7 +6990,7 @@ func (d *lxc) FileSFTPConn() (net.Conn, error) { // Wait for completion. err = forkfile.Wait() if err != nil { - d.logger.Error("SFTP server stopped with error", logger.Ctx{"err": err, "stderr": strings.TrimSpace(stderr.String())}) + d.logger.Warn("SFTP server stopped with error", logger.Ctx{"err": err, "stderr": strings.TrimSpace(stderr.String())}) return } }() @@ -8129,19 +8152,9 @@ func (d *lxc) NextIdmap() (*idmap.IdmapSet, error) { // statusCode returns instance status code. func (d *lxc) statusCode() api.StatusCode { // Shortcut to avoid spamming liblxc during ongoing operations. - op := operationlock.Get(d.Project().Name, d.Name()) - if op != nil { - if op.Action() == operationlock.ActionStart { - return api.Stopped - } - - if op.Action() == operationlock.ActionStop { - if shared.IsTrue(d.LocalConfig()["volatile.last_state.ready"]) { - return api.Ready - } - - return api.Running - } + operationStatus := d.operationStatusCode() + if operationStatus != nil { + return *operationStatus } state, err := d.getLxcState() @@ -8149,6 +8162,17 @@ func (d *lxc) statusCode() api.StatusCode { return api.Error } + // The state that we get from LXC could be stale; if the container is self-stopping, + // the on-stop hook handler may be called while we are waiting for getLxcState. If + // that happens, we might return `STOPPED` even though a Stop operation is still + // running. + if state == liblxc.STOPPED { + operationStatus = d.operationStatusCode() + if operationStatus != nil { + return *operationStatus + } + } + statusCode := lxcStatusCode(state) if statusCode == api.Running && shared.IsTrue(d.LocalConfig()["volatile.last_state.ready"]) { diff --git a/lxd/instance/drivers/driver_qemu.go b/lxd/instance/drivers/driver_qemu.go index e5d6b3f22225..83d9f63ab013 100644 --- a/lxd/instance/drivers/driver_qemu.go +++ b/lxd/instance/drivers/driver_qemu.go @@ -47,6 +47,7 @@ import ( deviceConfig "github.com/canonical/lxd/lxd/device/config" "github.com/canonical/lxd/lxd/device/nictype" "github.com/canonical/lxd/lxd/instance" + "github.com/canonical/lxd/lxd/instance/drivers/edk2" "github.com/canonical/lxd/lxd/instance/drivers/qmp" "github.com/canonical/lxd/lxd/instance/drivers/uefi" "github.com/canonical/lxd/lxd/instance/instancetype" @@ -107,37 +108,6 @@ const qemuDeviceNameMaxLength = 31 // qemuMigrationNBDExportName is the name of the disk device export by the migration NBD server. const qemuMigrationNBDExportName = "lxd_root" -// VM firmwares. -type vmFirmware struct { - code string - vars string -} - -// Debug version of the "default" firmware. -var vmDebugFirmware = "OVMF_CODE.4MB.debug.fd" - -var vmGenericFirmwares = []vmFirmware{ - {code: "OVMF_CODE.4MB.fd", vars: "OVMF_VARS.4MB.fd"}, - {code: "OVMF_CODE.2MB.fd", vars: "OVMF_VARS.2MB.fd"}, - {code: "OVMF_CODE.fd", vars: "OVMF_VARS.fd"}, - {code: "OVMF_CODE.fd", vars: "qemu.nvram"}, -} - -var vmSecurebootFirmwares = []vmFirmware{ - {code: "OVMF_CODE.4MB.fd", vars: "OVMF_VARS.4MB.ms.fd"}, - {code: "OVMF_CODE.2MB.fd", vars: "OVMF_VARS.2MB.ms.fd"}, - {code: "OVMF_CODE.fd", vars: "OVMF_VARS.ms.fd"}, - {code: "OVMF_CODE.fd", vars: "qemu.nvram"}, -} - -// Only valid for x86_64. -var vmLegacyFirmwares = []vmFirmware{ - {code: "bios-256k.bin", vars: "bios-256k.bin"}, - {code: "OVMF_CODE.4MB.CSM.fd", vars: "OVMF_VARS.4MB.CSM.fd"}, - {code: "OVMF_CODE.2MB.CSM.fd", vars: "OVMF_VARS.2MB.CSM.fd"}, - {code: "OVMF_CODE.CSM.fd", vars: "OVMF_VARS.CSM.fd"}, -} - // qemuSparseUSBPorts is the amount of sparse USB ports for VMs. // 4 are reserved, and the other 4 can be used for any USB device. const qemuSparseUSBPorts = 8 @@ -356,6 +326,9 @@ func qemuCreate(s *state.State, args db.InstanceArgs, p api.Project) (instance.I type qemu struct { common + // Path to firmware, set at start time. + firmwarePath string + // Cached handles. // Do not use these variables directly, instead use their associated get functions so they // will be initialised on demand. @@ -788,29 +761,6 @@ func (d *qemu) Rebuild(img *api.Image, op *operations.Operation) error { return d.rebuildCommon(d, img, op) } -func (*qemu) fwPath(filename string) string { - qemuFwPathsArr, err := util.GetQemuFwPaths() - if err != nil { - return "" - } - - // GetQemuFwPaths resolves symlinks for us, but we still need EvalSymlinks() in here, - // because filename itself can be a symlink. - for _, path := range qemuFwPathsArr { - filePath := filepath.Join(path, filename) - filePath, err := filepath.EvalSymlinks(filePath) - if err != nil { - continue - } - - if shared.PathExists(filePath) { - return filePath - } - } - - return "" -} - // killQemuProcess kills specified process. Optimistically attempts to wait for the process to fully exit, but does // not return an error if the Wait call fails. This is because this function is used in scenarios where LXD has // been restarted after the VM has been started and is no longer the parent of the QEMU process. @@ -1272,14 +1222,44 @@ func (d *qemu) start(stateful bool, op *operationlock.InstanceOperation) error { return err } - // Copy VM firmware settings firmware to nvram file if needed. + // Copy EDK2 settings firmware to nvram file if needed. // This firmware file can be modified by the VM so it must be copied from the defaults. - if d.architectureSupportsUEFI(d.architecture) && (!shared.PathExists(d.nvramPath()) || shared.IsTrue(d.localConfig["volatile.apply_nvram"])) { - err = d.setupNvram() - if err != nil { + if d.architectureSupportsUEFI(d.architecture) { + // ovmfNeedsUpdate checks if nvram file needs to be regenerated using new template. + ovmfNeedsUpdate := func(nvramTarget string) bool { + if shared.InSnap() && strings.Contains(nvramTarget, "OVMF") { + // The 2MB firmware was deprecated in the LXD snap. + // Detect this by the absence of "4MB" in the nvram file target. + if !strings.Contains(nvramTarget, "4MB") { + return true + } + + // The EDK2-based CSM firmwares were replaced with Seabios in the LXD snap. + // Detect this by the presence of "CSM" in the nvram file target. + if strings.Contains(nvramTarget, "CSM") { + return true + } + } + + return false + } + + // Check if nvram path and its target exist. + nvramPath := d.nvramPath() + nvramTarget, err := filepath.EvalSymlinks(nvramPath) + if err != nil && !errors.Is(err, fs.ErrNotExist) { op.Done(err) return err } + + // Decide if nvram file needs to be setup/refreshed. + if errors.Is(err, fs.ErrNotExist) || shared.IsTrue(d.localConfig["volatile.apply_nvram"]) || ovmfNeedsUpdate(nvramTarget) { + err = d.setupNvram() + if err != nil { + op.Done(err) + return err + } + } } // Clear volatile.apply_nvram if set. @@ -1718,7 +1698,7 @@ func (d *qemu) start(stateful bool, op *operationlock.InstanceOperation) error { } // Trigger a rebalance procedure which will set vCPU affinity (pinning) (explicit or implicit) - cgroup.TaskSchedulerTrigger("virtual-machine", d.name, "started") + cgroup.TaskSchedulerTrigger(d.dbType, d.name, "started") // Run monitor hooks from devices. for _, monHook := range monHooks { @@ -1818,6 +1798,11 @@ func (d *qemu) start(stateful bool, op *operationlock.InstanceOperation) error { return nil } +// FirmwarePath returns the path to firmware, set at start time. +func (d *qemu) FirmwarePath() string { + return d.firmwarePath +} + func (d *qemu) setupSEV(fdFiles *[]*os.File) (*qemuSevOpts, error) { if d.architecture != osarch.ARCH_64BIT_INTEL_X86 { return nil, errors.New("AMD SEV support is only available on x86_64 systems") @@ -1983,55 +1968,57 @@ func (d *qemu) setupNvram() error { d.logger.Debug("Generating NVRAM") - // Cleanup existing variables. - for _, firmwares := range [][]vmFirmware{vmGenericFirmwares, vmSecurebootFirmwares, vmLegacyFirmwares} { - for _, firmware := range firmwares { - err := os.Remove(filepath.Join(d.Path(), firmware.vars)) - if err != nil && !os.IsNotExist(err) { - return err - } + // Cleanup existing variables file. + for _, varsName := range edk2.GetAchitectureFirmwareVarsCandidates(d.architecture) { + err := os.Remove(filepath.Join(d.Path(), varsName)) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("Failed removing firmware vars file %q: %w", varsName, err) } } // Determine expected firmware. - firmwares := vmGenericFirmwares + var firmwares []edk2.FirmwarePair if shared.IsTrue(d.expandedConfig["security.csm"]) { - firmwares = vmLegacyFirmwares + firmwares = edk2.GetArchitectureFirmwarePairsForUsage(d.architecture, edk2.CSM) } else if shared.IsTrueOrEmpty(d.expandedConfig["security.secureboot"]) { - firmwares = vmSecurebootFirmwares + firmwares = edk2.GetArchitectureFirmwarePairsForUsage(d.architecture, edk2.SECUREBOOT) + } else { + firmwares = edk2.GetArchitectureFirmwarePairsForUsage(d.architecture, edk2.GENERIC) } // Find the template file. - var vmfVarsPath string - var vmfVarsName string + var vmFirmwarePath string + var vmFirmwareName string for _, firmware := range firmwares { - varsPath := d.fwPath(firmware.vars) + varsPath, err := filepath.EvalSymlinks(firmware.Vars) + if err != nil { + continue + } - if varsPath != "" { - vmfVarsPath = varsPath - vmfVarsName = firmware.vars + if shared.PathExists(varsPath) { + vmFirmwarePath = varsPath + vmFirmwareName = filepath.Base(firmware.Vars) break } } - if vmfVarsPath == "" { - return fmt.Errorf("Couldn't find one of the required firmware files: %+v", firmwares) + if vmFirmwarePath == "" { + return fmt.Errorf("Couldn't find one of the required VM firmware files: %+v", firmwares) } // Copy the template. - err = shared.FileCopy(vmfVarsPath, filepath.Join(d.Path(), vmfVarsName)) + err = shared.FileCopy(vmFirmwarePath, filepath.Join(d.Path(), vmFirmwareName)) if err != nil { return err } - // Generate a symlink if needed. - // This is so qemu.nvram can always be assumed to be the VM firmware vars file. + // Generate a symlink. + // This is so qemu.nvram can always be assumed to be the EDK2 vars file. // The real file name is then used to determine what firmware must be selected. - if !shared.PathExists(d.nvramPath()) { - err = os.Symlink(vmfVarsName, d.nvramPath()) - if err != nil { - return err - } + _ = os.Remove(d.nvramPath()) + err = os.Symlink(vmFirmwareName, d.nvramPath()) + if err != nil { + return err } return nil @@ -3183,54 +3170,40 @@ func (d *qemu) generateQemuConfigFile(cpuInfo *cpuTopology, mountInfo *storagePo } // Determine expected firmware. - firmwares := vmGenericFirmwares + var firmwares []edk2.FirmwarePair if shared.IsTrue(d.expandedConfig["security.csm"]) { - firmwares = vmLegacyFirmwares + firmwares = edk2.GetArchitectureFirmwarePairsForUsage(d.architecture, edk2.CSM) } else if shared.IsTrueOrEmpty(d.expandedConfig["security.secureboot"]) { - firmwares = vmSecurebootFirmwares + firmwares = edk2.GetArchitectureFirmwarePairsForUsage(d.architecture, edk2.SECUREBOOT) + } else { + firmwares = edk2.GetArchitectureFirmwarePairsForUsage(d.architecture, edk2.GENERIC) } - var vmfCode string + var efiCode string for _, firmware := range firmwares { - if shared.PathExists(filepath.Join(d.Path(), firmware.vars)) { - vmfCode = firmware.code + if shared.PathExists(filepath.Join(d.Path(), filepath.Base(firmware.Vars))) { + efiCode = firmware.Code break } } - if vmfCode == "" { - return "", nil, fmt.Errorf("Unable to locate matching firmware: %+v", firmwares) + if efiCode == "" { + return "", nil, fmt.Errorf("Unable to locate matching VM firmware: %+v", firmwares) } - // As 2MB firmware was deprecated in the LXD snap we have to regenerate NVRAM for VMs which used the 2MB one. - // As EDK2-based CSM firmwares were deprecated in the LXD snap we want to force VMs to start using SeaBIOS directly. - isOVMF2MB := (strings.Contains(vmfCode, "OVMF") && !strings.Contains(vmfCode, "4MB")) - isOVMFCSM := (strings.Contains(vmfCode, "OVMF") && strings.Contains(vmfCode, "CSM")) - if shared.InSnap() && (isOVMF2MB || isOVMFCSM) { - err = d.setupNvram() - if err != nil { - return "", nil, err - } - - // force to use a top-priority firmware - vmfCode = firmwares[0].code - } - - // Use debug version of firmware. (Only works for "default" (4MB, no CSM) firmware flavor) - if shared.IsTrue(d.localConfig["boot.debug_edk2"]) && vmfCode == vmGenericFirmwares[0].code { - vmfCode = vmDebugFirmware - } - - fwPath := d.fwPath(vmfCode) - if fwPath == "" { - return "", nil, fmt.Errorf("Unable to locate the file for firmware %q", vmfCode) + // Use debug version of firmware. (Only works for "preferred" (OVMF 4MB, no CSM) firmware flavor) + if shared.IsTrue(d.localConfig["boot.debug_edk2"]) && efiCode == firmwares[0].Code { + efiCode = filepath.Join(filepath.Dir(efiCode), edk2.OVMFDebugFirmware) } driveFirmwareOpts := qemuDriveFirmwareOpts{ - roPath: fwPath, + roPath: efiCode, nvramPath: fmt.Sprintf("/dev/fd/%d", d.addFileDescriptor(fdFiles, nvRAMFile)), } + // Set firmware path for apparmor profile. + d.firmwarePath = driveFirmwareOpts.roPath + cfg = append(cfg, qemuDriveFirmware(&driveFirmwareOpts)...) } @@ -3735,6 +3708,7 @@ func (d *qemu) addRootDriveConfig(qemuDev map[string]string, mountInfo *storageP DevPath: mountInfo.DiskPath, Opts: rootDriveConf.Opts, TargetPath: rootDriveConf.TargetPath, + Limits: rootDriveConf.Limits, } if d.storagePool.Driver().Info().Remote { @@ -4155,7 +4129,7 @@ func (d *qemu) addDriveConfig(qemuDev map[string]string, bootIndexes map[string] revert := revert.New() defer revert.Fail() - nodeName := fmt.Sprintf("%s%s", qemuDeviceNamePrefix, escapedDeviceName) + nodeName := qemuDeviceNameOrID(qemuDeviceNamePrefix, escapedDeviceName, "", qemuDeviceNameMaxLength) if isRBDImage { secretID := fmt.Sprintf("pool_%s_%s", blockDev["pool"], blockDev["user"]) @@ -4979,7 +4953,7 @@ func (d *qemu) Stop(stateful bool) error { } // Trigger a rebalance - cgroup.TaskSchedulerTrigger("virtual-machine", d.name, "stopped") + cgroup.TaskSchedulerTrigger(d.dbType, d.name, "stopped") return nil } @@ -5652,6 +5626,7 @@ func (d *qemu) Update(args db.InstanceArgs, userRequested bool) error { "security.agent.metrics", "security.csm", "security.devlxd", + "security.devlxd.images", "security.secureboot", } @@ -5849,7 +5824,7 @@ func (d *qemu) Update(args db.InstanceArgs, userRequested bool) error { if cpuLimitWasChanged { // Trigger a scheduler re-run - cgroup.TaskSchedulerTrigger("virtual-machine", d.name, "changed") + cgroup.TaskSchedulerTrigger(d.dbType, d.name, "changed") } if isRunning { @@ -8276,19 +8251,9 @@ func (d *qemu) InitPID() int { func (d *qemu) statusCode() api.StatusCode { // Shortcut to avoid spamming QMP during ongoing operations. - op := operationlock.Get(d.Project().Name, d.Name()) - if op != nil { - if op.Action() == operationlock.ActionStart { - return api.Stopped - } - - if op.Action() == operationlock.ActionStop { - if shared.IsTrue(d.LocalConfig()["volatile.last_state.ready"]) { - return api.Ready - } - - return api.Running - } + operationStatus := d.operationStatusCode() + if operationStatus != nil { + return *operationStatus } // Connect to the monitor. @@ -8691,18 +8656,21 @@ func (d *qemu) checkFeatures(hostArch int, qemuPath string) (map[string]any, err } if d.architectureSupportsUEFI(hostArch) { - vmfCode := "OVMF_CODE.fd" - - if shared.InSnap() { - vmfCode = vmGenericFirmwares[0].code + // Try to locate a UEFI firmware. + var efiPath string + for _, firmwarePair := range edk2.GetArchitectureFirmwarePairsForUsage(hostArch, edk2.GENERIC) { + if shared.PathExists(firmwarePair.Code) { + logger.Info("Found VM UEFI firmware", logger.Ctx{"code": firmwarePair.Code, "vars": firmwarePair.Vars}) + efiPath = firmwarePair.Code + break + } } - fwPath := d.fwPath(vmfCode) - if fwPath == "" { - return nil, fmt.Errorf("Unable to locate the file for firmware %q", vmfCode) + if efiPath == "" { + return nil, fmt.Errorf("Unable to locate a VM UEFI firmware") } - qemuArgs = append(qemuArgs, "-drive", fmt.Sprintf("if=pflash,format=raw,readonly=on,file=%s", fwPath)) + qemuArgs = append(qemuArgs, "-drive", fmt.Sprintf("if=pflash,format=raw,readonly=on,file=%s", efiPath)) } var stderr bytes.Buffer diff --git a/lxd/instance/drivers/edk2/edk2.go b/lxd/instance/drivers/edk2/edk2.go new file mode 100644 index 000000000000..e921f71b9280 --- /dev/null +++ b/lxd/instance/drivers/edk2/edk2.go @@ -0,0 +1,216 @@ +package edk2 + +import ( + "os" + "path/filepath" + "strings" + + "github.com/canonical/lxd/shared" + "github.com/canonical/lxd/shared/osarch" +) + +// FirmwarePair represents a combination of firmware code (Code) and storage (Vars). +type FirmwarePair struct { + Code string + Vars string +} + +// Installation represents a set of available firmware at a given location on the system. +type Installation struct { + Paths []string + Usage map[FirmwareUsage][]FirmwarePair +} + +// FirmwareUsage represents the situation in which a given firmware file will be used. +type FirmwareUsage int + +const ( + // GENERIC is a generic EDK2 firmware. + GENERIC FirmwareUsage = iota + + // SECUREBOOT is a UEFI Secure Boot enabled firmware. + SECUREBOOT + + // CSM is a firmware with the UEFI Compatibility Support Module enabled to boot BIOS-only operating systems. + CSM +) + +// OVMFDebugFirmware is the debug version of the "preferred" firmware. +const OVMFDebugFirmware = "OVMF_CODE.4MB.debug.fd" + +var architectureInstallations = map[int][]Installation{ + osarch.ARCH_64BIT_INTEL_X86: {{ + Paths: GetenvEdk2Paths("/usr/share/OVMF"), + Usage: map[FirmwareUsage][]FirmwarePair{ + GENERIC: { + {Code: "OVMF_CODE.4MB.fd", Vars: "OVMF_VARS.4MB.fd"}, + {Code: "OVMF_CODE_4M.fd", Vars: "OVMF_VARS_4M.fd"}, + {Code: "OVMF_CODE.4m.fd", Vars: "OVMF_VARS.4m.fd"}, + {Code: "OVMF_CODE.2MB.fd", Vars: "OVMF_VARS.2MB.fd"}, + {Code: "OVMF_CODE.fd", Vars: "OVMF_VARS.fd"}, + {Code: "OVMF_CODE.fd", Vars: "qemu.nvram"}, + }, + SECUREBOOT: { + {Code: "OVMF_CODE.4MB.fd", Vars: "OVMF_VARS.4MB.ms.fd"}, + {Code: "OVMF_CODE_4M.ms.fd", Vars: "OVMF_VARS_4M.ms.fd"}, + {Code: "OVMF_CODE_4M.secboot.fd", Vars: "OVMF_VARS_4M.secboot.fd"}, + {Code: "OVMF_CODE.secboot.4m.fd", Vars: "OVMF_VARS.4m.fd"}, + {Code: "OVMF_CODE.secboot.fd", Vars: "OVMF_VARS.secboot.fd"}, + {Code: "OVMF_CODE.secboot.fd", Vars: "OVMF_VARS.fd"}, + {Code: "OVMF_CODE.2MB.fd", Vars: "OVMF_VARS.2MB.ms.fd"}, + {Code: "OVMF_CODE.fd", Vars: "OVMF_VARS.ms.fd"}, + {Code: "OVMF_CODE.fd", Vars: "qemu.nvram"}, + }, + CSM: { + {Code: "seabios.bin", Vars: "seabios.bin"}, + {Code: "OVMF_CODE.4MB.CSM.fd", Vars: "OVMF_VARS.4MB.CSM.fd"}, + {Code: "OVMF_CODE.csm.4m.fd", Vars: "OVMF_VARS.4m.fd"}, + {Code: "OVMF_CODE.2MB.CSM.fd", Vars: "OVMF_VARS.2MB.CSM.fd"}, + {Code: "OVMF_CODE.CSM.fd", Vars: "OVMF_VARS.CSM.fd"}, + {Code: "OVMF_CODE.csm.fd", Vars: "OVMF_VARS.fd"}, + }, + }, + }, { + Paths: GetenvEdk2Paths("/usr/share/qemu"), + Usage: map[FirmwareUsage][]FirmwarePair{ + GENERIC: { + {Code: "ovmf-x86_64-4m-code.bin", Vars: "ovmf-x86_64-4m-vars.bin"}, + {Code: "ovmf-x86_64.bin", Vars: "ovmf-x86_64-code.bin"}, + }, + SECUREBOOT: { + {Code: "ovmf-x86_64-ms-4m-vars.bin", Vars: "ovmf-x86_64-ms-4m-code.bin"}, + {Code: "ovmf-x86_64-ms-code.bin", Vars: "ovmf-x86_64-ms-vars.bin"}, + }, + CSM: { + {Code: "seabios.bin", Vars: "seabios.bin"}, + }, + }, + }, { + Paths: GetenvEdk2Paths("/usr/share/edk2/x64"), + Usage: map[FirmwareUsage][]FirmwarePair{ + GENERIC: { + {Code: "OVMF_CODE.4m.fd", Vars: "OVMF_VARS.4m.fd"}, + {Code: "OVMF_CODE.fd", Vars: "OVMF_VARS.fd"}, + }, + SECUREBOOT: { + {Code: "OVMF_CODE.secure.4m.fd", Vars: "OVMF_VARS.4m.fd"}, + {Code: "OVMF_CODE.secure.fd", Vars: "OVMF_VARS.fd"}, + }, + }, + }, { + Paths: GetenvEdk2Paths("/usr/share/OVMF/x64"), + Usage: map[FirmwareUsage][]FirmwarePair{ + GENERIC: { + {Code: "OVMF_CODE.4m.fd", Vars: "OVMF_VARS.4m.fd"}, + {Code: "OVMF_CODE.fd", Vars: "OVMF_VARS.fd"}, + }, + CSM: { + {Code: "OVMF_CODE.csm.4m.fd", Vars: "OVMF_VARS.4m.fd"}, + {Code: "OVMF_CODE.csm.fd", Vars: "OVMF_VARS.fd"}, + }, + SECUREBOOT: { + {Code: "OVMF_CODE.secboot.4m.fd", Vars: "OVMF_VARS.4m.fd"}, + {Code: "OVMF_CODE.secboot.fd", Vars: "OVMF_VARS.fd"}, + }, + }, + }, { + Paths: GetenvEdk2Paths("/usr/share/seabios"), + Usage: map[FirmwareUsage][]FirmwarePair{ + CSM: { + {Code: "bios-256k.bin", Vars: "bios-256k.bin"}, + }, + }, + }}, + osarch.ARCH_64BIT_ARMV8_LITTLE_ENDIAN: {{ + Paths: GetenvEdk2Paths("/usr/share/AAVMF"), + Usage: map[FirmwareUsage][]FirmwarePair{ + GENERIC: { + {Code: "AAVMF_CODE.fd", Vars: "AAVMF_VARS.fd"}, + {Code: "OVMF_CODE.4MB.fd", Vars: "OVMF_VARS.4MB.fd"}, + {Code: "OVMF_CODE_4M.fd", Vars: "OVMF_VARS_4M.fd"}, + {Code: "OVMF_CODE.4m.fd", Vars: "OVMF_VARS.4m.fd"}, + {Code: "OVMF_CODE.2MB.fd", Vars: "OVMF_VARS.2MB.fd"}, + {Code: "OVMF_CODE.fd", Vars: "OVMF_VARS.fd"}, + {Code: "OVMF_CODE.fd", Vars: "qemu.nvram"}, + }, + SECUREBOOT: { + {Code: "AAVMF_CODE.ms.fd", Vars: "AAVMF_VARS.ms.fd"}, + {Code: "OVMF_CODE.4MB.fd", Vars: "OVMF_VARS.4MB.ms.fd"}, + {Code: "OVMF_CODE_4M.ms.fd", Vars: "OVMF_VARS_4M.ms.fd"}, + {Code: "OVMF_CODE_4M.secboot.fd", Vars: "OVMF_VARS_4M.secboot.fd"}, + {Code: "OVMF_CODE.secboot.4m.fd", Vars: "OVMF_VARS.4m.fd"}, + {Code: "OVMF_CODE.secboot.fd", Vars: "OVMF_VARS.secboot.fd"}, + {Code: "OVMF_CODE.secboot.fd", Vars: "OVMF_VARS.fd"}, + {Code: "OVMF_CODE.2MB.fd", Vars: "OVMF_VARS.2MB.ms.fd"}, + {Code: "OVMF_CODE.fd", Vars: "OVMF_VARS.ms.fd"}, + {Code: "OVMF_CODE.fd", Vars: "qemu.nvram"}, + }, + }, + }}, +} + +// GetAchitectureFirmwareVarsCandidates returns a unique list of candidate vars names for hostArch for all usages. +// It does not check whether the associated firmware files are present on the host now. +// This can be used to check for the existence of previously used firmware vars files in an existing VM instance. +func GetAchitectureFirmwareVarsCandidates(hostArch int) (varsNames []string) { + for _, installation := range architectureInstallations[hostArch] { + for _, usage := range installation.Usage { + for _, fwPair := range usage { + if !shared.ValueInSlice(fwPair.Vars, varsNames) { + varsNames = append(varsNames, fwPair.Vars) + } + } + } + } + + return varsNames +} + +// GetArchitectureFirmwarePairsForUsage returns FirmwarePair slice for a host architecture and usage combination. +// It only includes FirmwarePairs where both the firmware and its vars file are found on the host. +func GetArchitectureFirmwarePairsForUsage(hostArch int, usage FirmwareUsage) []FirmwarePair { + firmwares := make([]FirmwarePair, 0) + + for _, installation := range architectureInstallations[hostArch] { + usage, found := installation.Usage[usage] + if found { + for _, firmwarePair := range usage { + for _, searchPath := range installation.Paths { + codePath := filepath.Join(searchPath, firmwarePair.Code) + varsPath := filepath.Join(searchPath, firmwarePair.Vars) + + // Check both firmware code and vars paths exist - otherwise skip pair. + if !shared.PathExists(codePath) || !shared.PathExists(varsPath) { + continue + } + + firmwares = append(firmwares, FirmwarePair{ + Code: codePath, + Vars: varsPath, + }) + } + } + } + } + + return firmwares +} + +// GetenvEdk2Paths returns a list of paths to search for VM firmwares. +// If LXD_QEMU_FW_PATH or LXD_OVMF_PATH env vars are set then these values are split on ":" and prefixed to the +// returned slice of paths. +// The defaultPath argument is returned as the last element in the slice. +func GetenvEdk2Paths(defaultPath string) []string { + var qemuFwPaths []string + + for _, v := range []string{"LXD_QEMU_FW_PATH", "LXD_OVMF_PATH"} { + searchPaths := os.Getenv(v) + if searchPaths == "" { + continue + } + + qemuFwPaths = append(qemuFwPaths, strings.Split(searchPaths, ":")...) + } + + return append(qemuFwPaths, defaultPath) +} diff --git a/lxd/instance/instance_interface.go b/lxd/instance/instance_interface.go index 8763436848d0..91a057240e29 100644 --- a/lxd/instance/instance_interface.go +++ b/lxd/instance/instance_interface.go @@ -193,6 +193,8 @@ type VM interface { AgentCertificate() *x509.Certificate + FirmwarePath() string + // UEFI vars handling. UEFIVars() (*api.InstanceUEFIVars, error) UEFIVarsUpdate(newUEFIVarsSet api.InstanceUEFIVars) error diff --git a/lxd/instance/instance_utils.go b/lxd/instance/instance_utils.go index 03d6a6da1241..28c145da40e9 100644 --- a/lxd/instance/instance_utils.go +++ b/lxd/instance/instance_utils.go @@ -36,6 +36,7 @@ import ( "github.com/canonical/lxd/shared/logger" "github.com/canonical/lxd/shared/osarch" "github.com/canonical/lxd/shared/revert" + "github.com/canonical/lxd/shared/validate" "github.com/canonical/lxd/shared/version" ) @@ -111,6 +112,18 @@ func ValidConfig(sysOS *sys.OS, config map[string]string, expanded bool, instanc return fmt.Errorf("nvidia.runtime is incompatible with privileged containers") } + if sysOS.InUbuntuCore() && shared.IsTrue(config["nvidia.runtime"]) { + return fmt.Errorf("nvidia.runtime is incompatible with Ubuntu Core") + } + + // Validate pinning strategy when limits.cpu specifies static pinning. + cpuPinStrategy := config["limits.cpu.pin_strategy"] + cpuLimit := config["limits.cpu"] + err = validate.IsStaticCPUPinning(cpuLimit) + if err == nil && !expanded && cpuPinStrategy == "auto" { + return fmt.Errorf(`CPU pinning specified, but pinning strategy is set to "auto"`) + } + return nil } @@ -534,7 +547,7 @@ func ResolveImage(ctx context.Context, tx *db.ClusterTx, projectName string, sou // A nil list indicates that we can't tell at this stage, typically for private images. func SuitableArchitectures(ctx context.Context, s *state.State, tx *db.ClusterTx, projectName string, sourceInst *cluster.Instance, sourceImageRef string, req api.InstancesPost) ([]int, error) { // Handle cases where the architecture is already provided. - if shared.ValueInSlice(req.Source.Type, []string{"migration", "none"}) && req.Architecture != "" { + if shared.ValueInSlice(req.Source.Type, []string{api.SourceTypeConversion, api.SourceTypeMigration, api.SourceTypeNone}) && req.Architecture != "" { id, err := osarch.ArchitectureId(req.Architecture) if err != nil { return nil, err @@ -543,23 +556,23 @@ func SuitableArchitectures(ctx context.Context, s *state.State, tx *db.ClusterTx return []int{id}, nil } - // For migration, an architecture must be specified in the req. - if req.Source.Type == "migration" && req.Architecture == "" { - return nil, api.StatusErrorf(http.StatusBadRequest, "An architecture must be specified in migration requests") + // For migration and conversion, an architecture must be specified in the req. + if shared.ValueInSlice(req.Source.Type, []string{api.SourceTypeConversion, api.SourceTypeMigration}) && req.Architecture == "" { + return nil, api.StatusErrorf(http.StatusBadRequest, "An architecture must be specified in migration or conversion requests") } // For none, allow any architecture. - if req.Source.Type == "none" { + if req.Source.Type == api.SourceTypeNone { return []int{}, nil } // For copy, always use the source architecture. - if req.Source.Type == "copy" { + if req.Source.Type == api.SourceTypeCopy { return []int{sourceInst.Architecture}, nil } // For image, things get a bit more complicated. - if req.Source.Type == "image" { + if req.Source.Type == api.SourceTypeImage { // Handle local images. if req.Source.Server == "" { _, img, err := tx.GetImageByFingerprintPrefix(ctx, sourceImageRef, cluster.ImageFilter{Project: &projectName}) diff --git a/lxd/instance/instancetype/instance.go b/lxd/instance/instancetype/instance.go index 9fa602e35cbf..4646947b2f23 100644 --- a/lxd/instance/instancetype/instance.go +++ b/lxd/instance/instancetype/instance.go @@ -121,7 +121,7 @@ var InstanceConfigKeysAny = map[string]func(value string) error{ // The number of seconds to wait after the instance started before starting the next one. // --- // type: integer - // defaultdesc: "0" + // defaultdesc: `0` // liveupdate: no // shortdesc: Delay after starting the instance "boot.autostart.delay": validate.Optional(validate.IsInt64), @@ -130,7 +130,7 @@ var InstanceConfigKeysAny = map[string]func(value string) error{ // The instance with the highest value is started first. // --- // type: integer - // defaultdesc: "0" + // defaultdesc: `0` // liveupdate: no // shortdesc: What order to start the instances in "boot.autostart.priority": validate.Optional(validate.IsInt64), @@ -139,7 +139,7 @@ var InstanceConfigKeysAny = map[string]func(value string) error{ // The instance with the highest value is shut down first. // --- // type: integer - // defaultdesc: "0" + // defaultdesc: `0` // liveupdate: no // shortdesc: What order to shut down the instances in "boot.stop.priority": validate.Optional(validate.IsInt64), @@ -148,7 +148,7 @@ var InstanceConfigKeysAny = map[string]func(value string) error{ // Number of seconds to wait for the instance to shut down before it is force-stopped. // --- // type: integer - // defaultdesc: "30" + // defaultdesc: `30` // liveupdate: yes // shortdesc: How long to wait for the instance to shut down "boot.host_shutdown_timeout": validate.Optional(validate.IsInt64), @@ -340,7 +340,7 @@ var InstanceConfigKeysAny = map[string]func(value string) error{ // --- // type: bool // defaultdesc: `false` - // liveupdate: no + // liveupdate: yes // shortdesc: Controls the availability of the `/1.0/images` API over `devlxd` "security.devlxd.images": validate.Optional(validate.IsBool), @@ -405,20 +405,6 @@ var InstanceConfigKeysAny = map[string]func(value string) error{ return err }, - // lxdmeta:generate(entities=instance; group=miscellaneous; key=ubuntu_pro.guest_attach) - // Indicate whether the guest should auto-attach Ubuntu Pro at start up. - // The allowed values are `off`, `on`, and `available`. - // If set to `off`, it will not be possible for the Ubuntu Pro client in the guest to obtain guest token via `devlxd`. - // If set to `available`, attachment via guest token is possible but will not be performed automatically by the Ubuntu Pro client in the guest at startup. - // If set to `on`, attachment will be performed automatically by the Ubuntu Pro client in the guest at startup. - // To allow guest attachment, the host must be an Ubuntu machine that is Pro attached, and guest attachment must be enabled via the Pro client. - // To do this, run `pro config set lxd_guest_attach=on`. - // --- - // type: string - // liveupdate: no - // shortdesc: Whether to auto-attach Ubuntu Pro. - "ubuntu_pro.guest_attach": validate.Optional(validate.IsOneOf("off", "on", "available")), - // Volatile keys. // lxdmeta:generate(entities=instance; group=volatile; key=volatile.apply_template) @@ -970,6 +956,19 @@ var InstanceConfigKeysVM = map[string]func(value string) error{ // shortdesc: Whether to back the instance using huge pages "limits.memory.hugepages": validate.Optional(validate.IsBool), + // lxdmeta:generate(entities=instance; group=resource-limits; key=limits.cpu.pin_strategy) + // Specify the strategy for VM CPU auto pinning. + // Possible values: `none` (disables CPU auto pinning) and `auto` (enables CPU auto pinning). + // + // See {ref}`instance-options-limits-cpu-vm` for more information. + // --- + // type: string + // defaultdesc: `none` + // liveupdate: no + // condition: virtual machine + // shortdesc: VM CPU auto pinning strategy + "limits.cpu.pin_strategy": validate.Optional(validate.IsOneOf("none", "auto")), + // lxdmeta:generate(entities=instance; group=migration; key=migration.stateful) // Enabling this option prevents the use of some features that are incompatible with it. // --- diff --git a/lxd/instance_backup.go b/lxd/instance_backup.go index 32ebe802c6bb..3c8a6e5631a9 100644 --- a/lxd/instance_backup.go +++ b/lxd/instance_backup.go @@ -11,6 +11,7 @@ import ( "github.com/gorilla/mux" + "github.com/canonical/lxd/lxd/auth" "github.com/canonical/lxd/lxd/db" "github.com/canonical/lxd/lxd/db/operationtype" "github.com/canonical/lxd/lxd/instance" @@ -24,6 +25,7 @@ import ( "github.com/canonical/lxd/lxd/util" "github.com/canonical/lxd/shared" "github.com/canonical/lxd/shared/api" + "github.com/canonical/lxd/shared/entity" "github.com/canonical/lxd/shared/version" ) @@ -162,10 +164,25 @@ func instanceBackupsGet(d *Daemon, r *http.Request) response.Response { resultString := []string{} resultMap := []*api.InstanceBackup{} + canView, err := s.Authorizer.GetPermissionChecker(r.Context(), auth.EntitlementCanView, entity.TypeInstanceBackup) + if err != nil { + return response.SmartError(err) + } + for _, backup := range backups { + _, backupName, ok := strings.Cut(backup.Name(), "/") + if !ok { + // Not adding the name to the error response here because we were unable to check if the caller is allowed to view it. + return response.InternalError(fmt.Errorf("Instance backup has invalid name")) + } + + if !canView(entity.InstanceBackupURL(projectName, c.Name(), backupName)) { + continue + } + if !recursion { url := fmt.Sprintf("/%s/instances/%s/backups/%s", - version.APIVersion, cname, strings.Split(backup.Name(), "/")[1]) + version.APIVersion, cname, backupName) resultString = append(resultString, url) } else { render := backup.Render() @@ -287,8 +304,9 @@ func instanceBackupsPost(d *Daemon, r *http.Request) response.Response { base := name + shared.SnapshotDelimiter + "backup" length := len(base) - max := 0 + backupNo := 0 + // Iterate over previous backups to autoincrement the backup number. for _, backup := range backups { // Ignore backups not containing base. if !strings.HasPrefix(backup.Name(), base) { @@ -302,12 +320,12 @@ func instanceBackupsPost(d *Daemon, r *http.Request) response.Response { continue } - if num >= max { - max = num + 1 + if num >= backupNo { + backupNo = num + 1 } } - req.Name = fmt.Sprintf("backup%d", max) + req.Name = fmt.Sprintf("backup%d", backupNo) } // Validate the name. @@ -701,5 +719,5 @@ func instanceBackupExportGet(d *Daemon, r *http.Request) response.Response { s.Events.SendLifecycle(projectName, lifecycle.InstanceBackupRetrieved.Event(fullName, backup.Instance(), nil)) - return response.FileResponse(r, []response.FileResponseEntry{ent}, nil) + return response.FileResponse([]response.FileResponseEntry{ent}, nil) } diff --git a/lxd/instance_console.go b/lxd/instance_console.go index 803a09b36969..865efea46de4 100644 --- a/lxd/instance_console.go +++ b/lxd/instance_console.go @@ -611,7 +611,7 @@ func instanceConsoleLogGet(d *Daemon, r *http.Request) response.Response { consoleBufferLogPath := c.ConsoleBufferLogPath() ent.Path = consoleBufferLogPath ent.Filename = consoleBufferLogPath - return response.FileResponse(r, []response.FileResponseEntry{ent}, nil) + return response.FileResponse([]response.FileResponseEntry{ent}, nil) } // Query the container's console ringbuffer. @@ -631,7 +631,7 @@ func instanceConsoleLogGet(d *Daemon, r *http.Request) response.Response { } if errno == unix.ENODATA { - return response.FileResponse(r, []response.FileResponseEntry{}, nil) + return response.FileResponse([]response.FileResponseEntry{}, nil) } return response.SmartError(err) @@ -641,7 +641,7 @@ func instanceConsoleLogGet(d *Daemon, r *http.Request) response.Response { ent.FileModified = time.Now() ent.FileSize = int64(len(logContents)) - return response.FileResponse(r, []response.FileResponseEntry{ent}, nil) + return response.FileResponse([]response.FileResponseEntry{ent}, nil) } // swagger:operation DELETE /1.0/instances/{name}/console instances instance_console_delete diff --git a/lxd/instance_exec.go b/lxd/instance_exec.go index 419238be53fb..9556fa02c449 100644 --- a/lxd/instance_exec.go +++ b/lxd/instance_exec.go @@ -479,6 +479,15 @@ func (s *execWs) Do(op *operations.Operation) error { }() } + if i == execWSStderr { + // Consume data (e.g. websocket pings) from stderr too to + // avoid a situation where we hit an inactivity timeout on + // stderr during long exec sessions + go func() { + _, _, _ = conn.ReadMessage() + }() + } + if i == execWSStdin { err = <-ws.MirrorWrite(conn, ttys[i]) _ = ttys[i].Close() diff --git a/lxd/instance_file.go b/lxd/instance_file.go index 0dd87b4993b0..0d3bab10e835 100644 --- a/lxd/instance_file.go +++ b/lxd/instance_file.go @@ -212,7 +212,7 @@ func instanceFileGet(s *state.State, inst instance.Instance, path string, r *htt } s.Events.SendLifecycle(inst.Project().Name, lifecycle.InstanceFileRetrieved.Event(inst, logger.Ctx{"path": path})) - return response.FileResponse(r, files, headers) + return response.FileResponse(files, headers) } else if fileType == "symlink" { // Find symlink target. target, err := client.ReadLink(path) @@ -243,7 +243,7 @@ func instanceFileGet(s *state.State, inst instance.Instance, path string, r *htt files[0].FileSize = int64(len(target)) s.Events.SendLifecycle(inst.Project().Name, lifecycle.InstanceFileRetrieved.Event(inst, logger.Ctx{"path": path})) - return response.FileResponse(r, files, headers) + return response.FileResponse(files, headers) } else if fileType == "directory" { dirEnts := []string{} diff --git a/lxd/instance_logs.go b/lxd/instance_logs.go index 052ac1abb906..f0c4f48638d7 100644 --- a/lxd/instance_logs.go +++ b/lxd/instance_logs.go @@ -25,8 +25,9 @@ import ( ) var instanceLogCmd = APIEndpoint{ - Name: "instanceLog", - Path: "instances/{name}/logs/{file}", + Name: "instanceLog", + Path: "instances/{name}/logs/{file}", + MetricsType: entity.TypeInstance, Aliases: []APIEndpointAlias{ {Name: "containerLog", Path: "containers/{name}/logs/{file}"}, {Name: "vmLog", Path: "virtual-machines/{name}/logs/{file}"}, @@ -37,8 +38,9 @@ var instanceLogCmd = APIEndpoint{ } var instanceLogsCmd = APIEndpoint{ - Name: "instanceLogs", - Path: "instances/{name}/logs", + Name: "instanceLogs", + Path: "instances/{name}/logs", + MetricsType: entity.TypeInstance, Aliases: []APIEndpointAlias{ {Name: "containerLogs", Path: "containers/{name}/logs"}, {Name: "vmLogs", Path: "virtual-machines/{name}/logs"}, @@ -48,8 +50,9 @@ var instanceLogsCmd = APIEndpoint{ } var instanceExecOutputCmd = APIEndpoint{ - Name: "instanceExecOutput", - Path: "instances/{name}/logs/exec-output/{file}", + Name: "instanceExecOutput", + Path: "instances/{name}/logs/exec-output/{file}", + MetricsType: entity.TypeInstance, Aliases: []APIEndpointAlias{ {Name: "containerExecOutput", Path: "containers/{name}/logs/exec-output/{file}"}, {Name: "vmExecOutput", Path: "virtual-machines/{name}/logs/exec-output/{file}"}, @@ -60,8 +63,9 @@ var instanceExecOutputCmd = APIEndpoint{ } var instanceExecOutputsCmd = APIEndpoint{ - Name: "instanceExecOutputs", - Path: "instances/{name}/logs/exec-output", + Name: "instanceExecOutputs", + Path: "instances/{name}/logs/exec-output", + MetricsType: entity.TypeInstance, Aliases: []APIEndpointAlias{ {Name: "containerExecOutputs", Path: "containers/{name}/logs/exec-output"}, {Name: "vmExecOutputs", Path: "virtual-machines/{name}/logs/exec-output"}, @@ -265,7 +269,7 @@ func instanceLogGet(d *Daemon, r *http.Request) response.Response { s.Events.SendLifecycle(projectName, lifecycle.InstanceLogRetrieved.Event(file, inst, request.CreateRequestor(r), nil)) - return response.FileResponse(r, []response.FileResponseEntry{ent}, nil) + return response.FileResponse([]response.FileResponseEntry{ent}, nil) } // swagger:operation DELETE /1.0/instances/{name}/logs/{filename} instances instance_log_delete @@ -582,7 +586,7 @@ func instanceExecOutputGet(d *Daemon, r *http.Request) response.Response { s.Events.SendLifecycle(projectName, lifecycle.InstanceLogRetrieved.Event(file, inst, request.CreateRequestor(r), nil)) - return response.FileResponse(r, []response.FileResponseEntry{ent}, nil) + return response.FileResponse([]response.FileResponseEntry{ent}, nil) } // swagger:operation DELETE /1.0/instances/{name}/logs/exec-output/{filename} instances instance_exec-output_delete diff --git a/lxd/instance_metadata.go b/lxd/instance_metadata.go index 5e89adf790fc..a4f3a0e44354 100644 --- a/lxd/instance_metadata.go +++ b/lxd/instance_metadata.go @@ -534,7 +534,7 @@ func instanceMetadataTemplatesGet(d *Daemon, r *http.Request) response.Response s.Events.SendLifecycle(projectName, lifecycle.InstanceMetadataTemplateRetrieved.Event(c, request.CreateRequestor(r), logger.Ctx{"path": templateName})) - return response.FileResponse(r, files, nil) + return response.FileResponse(files, nil) } // swagger:operation POST /1.0/instances/{name}/metadata/templates instances instance_metadata_templates_post diff --git a/lxd/instance_patch.go b/lxd/instance_patch.go index db645b302423..b61855661a1a 100644 --- a/lxd/instance_patch.go +++ b/lxd/instance_patch.go @@ -191,8 +191,18 @@ func instancePatch(d *Daemon, r *http.Request) response.Response { return err } + profileConfigs, err := cluster.GetConfig(ctx, tx.Tx(), "profile") + if err != nil { + return err + } + + profileDevices, err := cluster.GetDevices(ctx, tx.Tx(), "profile") + if err != nil { + return err + } + for _, profile := range profiles { - apiProfile, err := profile.ToAPI(ctx, tx.Tx()) + apiProfile, err := profile.ToAPI(ctx, tx.Tx(), profileConfigs, profileDevices) if err != nil { return err } diff --git a/lxd/instance_post.go b/lxd/instance_post.go index f36e5b46aec7..110c4e1a61e4 100644 --- a/lxd/instance_post.go +++ b/lxd/instance_post.go @@ -232,7 +232,7 @@ func instancePost(d *Daemon, r *http.Request) response.Response { } if targetMemberInfo == nil && s.GlobalConfig.InstancesPlacementScriptlet() != "" { - leaderAddress, err := d.gateway.LeaderAddress() + leaderInfo, err := s.LeaderInfo() if err != nil { return response.InternalError(err) } @@ -250,7 +250,7 @@ func instancePost(d *Daemon, r *http.Request) response.Response { Reason: apiScriptlet.InstancePlacementReasonRelocation, } - targetMemberInfo, err = scriptlet.InstancePlacementRun(r.Context(), logger.Log, s, &req, candidateMembers, leaderAddress) + targetMemberInfo, err = scriptlet.InstancePlacementRun(r.Context(), logger.Log, s, &req, candidateMembers, leaderInfo.Address) if err != nil { return response.BadRequest(fmt.Errorf("Failed instance placement scriptlet: %w", err)) } @@ -509,9 +509,19 @@ func instancePostMigration(s *state.State, inst instance.Instance, newName strin return err } + profileConfigs, err := dbCluster.GetConfig(ctx, tx.Tx(), "profile") + if err != nil { + return err + } + + profileDevices, err := dbCluster.GetDevices(ctx, tx.Tx(), "profile") + if err != nil { + return err + } + apiProfiles = make([]api.Profile, 0, len(profiles)) for _, profile := range profiles { - apiProfile, err := profile.ToAPI(ctx, tx.Tx()) + apiProfile, err := profile.ToAPI(ctx, tx.Tx(), profileConfigs, profileDevices) if err != nil { return err } @@ -758,7 +768,7 @@ func instancePostClusteringMigrate(s *state.State, r *http.Request, srcPool stor InstancePut: srcInstInfo.Writable(), Type: api.InstanceType(srcInstInfo.Type), Source: api.InstanceSource{ - Type: "migration", + Type: api.SourceTypeMigration, Mode: "pull", Operation: fmt.Sprintf("https://%s%s", srcMember.Address, srcOp.URL()), Websockets: sourceSecrets, diff --git a/lxd/instance_put.go b/lxd/instance_put.go index 47289d5461db..563a3aed28f9 100644 --- a/lxd/instance_put.go +++ b/lxd/instance_put.go @@ -139,8 +139,18 @@ func instancePut(d *Daemon, r *http.Request) response.Response { return err } + profileConfigs, err := cluster.GetConfig(ctx, tx.Tx(), "profile") + if err != nil { + return err + } + + profileDevices, err := cluster.GetDevices(ctx, tx.Tx(), "profile") + if err != nil { + return err + } + for _, profile := range profiles { - apiProfile, err := profile.ToAPI(ctx, tx.Tx()) + apiProfile, err := profile.ToAPI(ctx, tx.Tx(), profileConfigs, profileDevices) if err != nil { return err } diff --git a/lxd/instance_rebuild.go b/lxd/instance_rebuild.go index 46b3dc0bb700..146daf8d04db 100644 --- a/lxd/instance_rebuild.go +++ b/lxd/instance_rebuild.go @@ -113,7 +113,7 @@ func instanceRebuildPost(d *Daemon, r *http.Request) response.Response { return fmt.Errorf("Failed loading instance: %w", err) } - if req.Source.Type != "none" { + if req.Source.Type != api.SourceTypeNone { sourceImage, err = getSourceImageFromInstanceSource(ctx, s, tx, targetProject.Name, req.Source, &sourceImageRef, dbInst.Type.String()) if err != nil && !api.StatusErrorCheck(err, http.StatusNotFound) { return err @@ -136,7 +136,7 @@ func instanceRebuildPost(d *Daemon, r *http.Request) response.Response { } run := func(op *operations.Operation) error { - if req.Source.Type == "none" { + if req.Source.Type == api.SourceTypeNone { return instanceRebuildFromEmpty(inst, op) } diff --git a/lxd/instance_sftp.go b/lxd/instance_sftp.go index 226761671745..70f0a6648fe4 100644 --- a/lxd/instance_sftp.go +++ b/lxd/instance_sftp.go @@ -13,6 +13,7 @@ import ( "github.com/canonical/lxd/lxd/cluster" "github.com/canonical/lxd/lxd/instance" + "github.com/canonical/lxd/lxd/metrics" "github.com/canonical/lxd/lxd/request" "github.com/canonical/lxd/lxd/response" "github.com/canonical/lxd/shared" @@ -66,7 +67,6 @@ func instanceSFTPHandler(d *Daemon, r *http.Request) response.Response { } resp := &sftpServeResponse{ - req: r, projectName: projectName, instName: instName, } @@ -98,7 +98,6 @@ func instanceSFTPHandler(d *Daemon, r *http.Request) response.Response { } type sftpServeResponse struct { - req *http.Request projectName string instName string instConn net.Conn @@ -138,7 +137,7 @@ func (r *sftpServeResponse) Render(w http.ResponseWriter, req *http.Request) err return api.StatusErrorf(http.StatusInternalServerError, "Failed to upgrade SFTP connection: %w", err) } - ctx, cancel := context.WithCancel(r.req.Context()) + ctx, cancel := context.WithCancel(req.Context()) l := logger.AddContext(logger.Ctx{ "project": r.projectName, "instance": r.instName, @@ -176,5 +175,7 @@ func (r *sftpServeResponse) Render(w http.ResponseWriter, req *http.Request) err wg.Wait() // Wait for copy go routine to finish. + metrics.UseMetricsCallback(req, metrics.Success) + return nil } diff --git a/lxd/instance_snapshot.go b/lxd/instance_snapshot.go index 48ef3755e894..4b1c513cb242 100644 --- a/lxd/instance_snapshot.go +++ b/lxd/instance_snapshot.go @@ -12,6 +12,7 @@ import ( "github.com/gorilla/mux" + "github.com/canonical/lxd/lxd/auth" "github.com/canonical/lxd/lxd/db" "github.com/canonical/lxd/lxd/db/cluster" "github.com/canonical/lxd/lxd/db/operationtype" @@ -26,6 +27,7 @@ import ( "github.com/canonical/lxd/lxd/util" "github.com/canonical/lxd/shared" "github.com/canonical/lxd/shared/api" + "github.com/canonical/lxd/shared/entity" "github.com/canonical/lxd/shared/validate" "github.com/canonical/lxd/shared/version" ) @@ -150,6 +152,11 @@ func instanceSnapshotsGet(d *Daemon, r *http.Request) response.Response { return resp } + canView, err := s.Authorizer.GetPermissionChecker(r.Context(), auth.EntitlementCanView, entity.TypeInstanceSnapshot) + if err != nil { + return response.SmartError(err) + } + recursion := util.IsRecursionRequest(r) resultString := []string{} resultMap := []*api.InstanceSnapshot{} @@ -170,6 +177,11 @@ func instanceSnapshotsGet(d *Daemon, r *http.Request) response.Response { for _, snap := range snaps { _, snapName, _ := api.GetParentAndSnapshotName(snap) + + if !canView(entity.InstanceSnapshotURL(projectName, cname, snapName)) { + continue + } + if projectName == api.ProjectDefaultName { url := fmt.Sprintf("/%s/instances/%s/snapshots/%s", version.APIVersion, cname, snapName) resultString = append(resultString, url) @@ -190,6 +202,12 @@ func instanceSnapshotsGet(d *Daemon, r *http.Request) response.Response { } for _, snap := range snaps { + _, snapName, _ := api.GetParentAndSnapshotName(snap.Name()) + + if !canView(entity.InstanceSnapshotURL(projectName, cname, snapName)) { + continue + } + render, _, err := snap.Render(storagePools.RenderSnapshotUsage(s, snap)) if err != nil { continue diff --git a/lxd/instances.go b/lxd/instances.go index e36915715b23..7efe90710d49 100644 --- a/lxd/instances.go +++ b/lxd/instances.go @@ -27,8 +27,9 @@ import ( ) var instancesCmd = APIEndpoint{ - Name: "instances", - Path: "instances", + Name: "instances", + Path: "instances", + MetricsType: entity.TypeInstance, Aliases: []APIEndpointAlias{ {Name: "containers", Path: "containers"}, {Name: "vms", Path: "virtual-machines"}, @@ -40,8 +41,9 @@ var instancesCmd = APIEndpoint{ } var instanceCmd = APIEndpoint{ - Name: "instance", - Path: "instances/{name}", + Name: "instance", + Path: "instances/{name}", + MetricsType: entity.TypeInstance, Aliases: []APIEndpointAlias{ {Name: "container", Path: "containers/{name}"}, {Name: "vm", Path: "virtual-machines/{name}"}, @@ -55,8 +57,9 @@ var instanceCmd = APIEndpoint{ } var instanceUEFIVarsCmd = APIEndpoint{ - Name: "instanceUEFIVars", - Path: "instances/{name}/uefi-vars", + Name: "instanceUEFIVars", + Path: "instances/{name}/uefi-vars", + MetricsType: entity.TypeInstance, Aliases: []APIEndpointAlias{ {Name: "vmUEFIVars", Path: "virtual-machines/{name}/uefi-vars"}, }, @@ -66,8 +69,9 @@ var instanceUEFIVarsCmd = APIEndpoint{ } var instanceRebuildCmd = APIEndpoint{ - Name: "instanceRebuild", - Path: "instances/{name}/rebuild", + Name: "instanceRebuild", + Path: "instances/{name}/rebuild", + MetricsType: entity.TypeInstance, Aliases: []APIEndpointAlias{ {Name: "containerRebuild", Path: "containers/{name}/rebuild"}, {Name: "vmRebuild", Path: "virtual-machines/{name}/rebuild"}, @@ -77,8 +81,9 @@ var instanceRebuildCmd = APIEndpoint{ } var instanceStateCmd = APIEndpoint{ - Name: "instanceState", - Path: "instances/{name}/state", + Name: "instanceState", + Path: "instances/{name}/state", + MetricsType: entity.TypeInstance, Aliases: []APIEndpointAlias{ {Name: "containerState", Path: "containers/{name}/state"}, {Name: "vmState", Path: "virtual-machines/{name}/state"}, @@ -89,8 +94,9 @@ var instanceStateCmd = APIEndpoint{ } var instanceSFTPCmd = APIEndpoint{ - Name: "instanceFile", - Path: "instances/{name}/sftp", + Name: "instanceFile", + Path: "instances/{name}/sftp", + MetricsType: entity.TypeInstance, Aliases: []APIEndpointAlias{ {Name: "containerFile", Path: "containers/{name}/sftp"}, {Name: "vmFile", Path: "virtual-machines/{name}/sftp"}, @@ -100,8 +106,9 @@ var instanceSFTPCmd = APIEndpoint{ } var instanceFileCmd = APIEndpoint{ - Name: "instanceFile", - Path: "instances/{name}/files", + Name: "instanceFile", + Path: "instances/{name}/files", + MetricsType: entity.TypeInstance, Aliases: []APIEndpointAlias{ {Name: "containerFile", Path: "containers/{name}/files"}, {Name: "vmFile", Path: "virtual-machines/{name}/files"}, @@ -114,35 +121,38 @@ var instanceFileCmd = APIEndpoint{ } var instanceSnapshotsCmd = APIEndpoint{ - Name: "instanceSnapshots", - Path: "instances/{name}/snapshots", + Name: "instanceSnapshots", + Path: "instances/{name}/snapshots", + MetricsType: entity.TypeInstance, Aliases: []APIEndpointAlias{ {Name: "containerSnapshots", Path: "containers/{name}/snapshots"}, {Name: "vmSnapshots", Path: "virtual-machines/{name}/snapshots"}, }, - Get: APIEndpointAction{Handler: instanceSnapshotsGet, AccessHandler: allowPermission(entity.TypeInstance, auth.EntitlementCanView, "name")}, + Get: APIEndpointAction{Handler: instanceSnapshotsGet, AccessHandler: allowProjectResourceList}, Post: APIEndpointAction{Handler: instanceSnapshotsPost, AccessHandler: allowPermission(entity.TypeInstance, auth.EntitlementCanManageSnapshots, "name")}, } var instanceSnapshotCmd = APIEndpoint{ - Name: "instanceSnapshot", - Path: "instances/{name}/snapshots/{snapshotName}", + Name: "instanceSnapshot", + Path: "instances/{name}/snapshots/{snapshotName}", + MetricsType: entity.TypeInstance, Aliases: []APIEndpointAlias{ {Name: "containerSnapshot", Path: "containers/{name}/snapshots/{snapshotName}"}, {Name: "vmSnapshot", Path: "virtual-machines/{name}/snapshots/{snapshotName}"}, }, - Get: APIEndpointAction{Handler: instanceSnapshotHandler, AccessHandler: allowPermission(entity.TypeInstance, auth.EntitlementCanView, "name")}, - Post: APIEndpointAction{Handler: instanceSnapshotHandler, AccessHandler: allowPermission(entity.TypeInstance, auth.EntitlementCanManageSnapshots, "name")}, - Delete: APIEndpointAction{Handler: instanceSnapshotHandler, AccessHandler: allowPermission(entity.TypeInstance, auth.EntitlementCanManageSnapshots, "name")}, - Patch: APIEndpointAction{Handler: instanceSnapshotHandler, AccessHandler: allowPermission(entity.TypeInstance, auth.EntitlementCanManageSnapshots, "name")}, - Put: APIEndpointAction{Handler: instanceSnapshotHandler, AccessHandler: allowPermission(entity.TypeInstance, auth.EntitlementCanManageSnapshots, "name")}, + Get: APIEndpointAction{Handler: instanceSnapshotHandler, AccessHandler: allowPermission(entity.TypeInstanceSnapshot, auth.EntitlementCanView, "name", "snapshotName")}, + Post: APIEndpointAction{Handler: instanceSnapshotHandler, AccessHandler: allowPermission(entity.TypeInstanceSnapshot, auth.EntitlementCanEdit, "name", "snapshotName")}, + Delete: APIEndpointAction{Handler: instanceSnapshotHandler, AccessHandler: allowPermission(entity.TypeInstanceSnapshot, auth.EntitlementCanDelete, "name", "snapshotName")}, + Patch: APIEndpointAction{Handler: instanceSnapshotHandler, AccessHandler: allowPermission(entity.TypeInstanceSnapshot, auth.EntitlementCanEdit, "name", "snapshotName")}, + Put: APIEndpointAction{Handler: instanceSnapshotHandler, AccessHandler: allowPermission(entity.TypeInstanceSnapshot, auth.EntitlementCanEdit, "name", "snapshotName")}, } var instanceConsoleCmd = APIEndpoint{ - Name: "instanceConsole", - Path: "instances/{name}/console", + Name: "instanceConsole", + Path: "instances/{name}/console", + MetricsType: entity.TypeInstance, Aliases: []APIEndpointAlias{ {Name: "containerConsole", Path: "containers/{name}/console"}, {Name: "vmConsole", Path: "virtual-machines/{name}/console"}, @@ -154,8 +164,9 @@ var instanceConsoleCmd = APIEndpoint{ } var instanceExecCmd = APIEndpoint{ - Name: "instanceExec", - Path: "instances/{name}/exec", + Name: "instanceExec", + Path: "instances/{name}/exec", + MetricsType: entity.TypeInstance, Aliases: []APIEndpointAlias{ {Name: "containerExec", Path: "containers/{name}/exec"}, {Name: "vmExec", Path: "virtual-machines/{name}/exec"}, @@ -165,8 +176,9 @@ var instanceExecCmd = APIEndpoint{ } var instanceMetadataCmd = APIEndpoint{ - Name: "instanceMetadata", - Path: "instances/{name}/metadata", + Name: "instanceMetadata", + Path: "instances/{name}/metadata", + MetricsType: entity.TypeInstance, Aliases: []APIEndpointAlias{ {Name: "containerMetadata", Path: "containers/{name}/metadata"}, {Name: "vmMetadata", Path: "virtual-machines/{name}/metadata"}, @@ -178,8 +190,9 @@ var instanceMetadataCmd = APIEndpoint{ } var instanceMetadataTemplatesCmd = APIEndpoint{ - Name: "instanceMetadataTemplates", - Path: "instances/{name}/metadata/templates", + Name: "instanceMetadataTemplates", + Path: "instances/{name}/metadata/templates", + MetricsType: entity.TypeInstance, Aliases: []APIEndpointAlias{ {Name: "containerMetadataTemplates", Path: "containers/{name}/metadata/templates"}, {Name: "vmMetadataTemplates", Path: "virtual-machines/{name}/metadata/templates"}, @@ -191,39 +204,42 @@ var instanceMetadataTemplatesCmd = APIEndpoint{ } var instanceBackupsCmd = APIEndpoint{ - Name: "instanceBackups", - Path: "instances/{name}/backups", + Name: "instanceBackups", + Path: "instances/{name}/backups", + MetricsType: entity.TypeInstance, Aliases: []APIEndpointAlias{ {Name: "containerBackups", Path: "containers/{name}/backups"}, {Name: "vmBackups", Path: "virtual-machines/{name}/backups"}, }, - Get: APIEndpointAction{Handler: instanceBackupsGet, AccessHandler: allowPermission(entity.TypeInstance, auth.EntitlementCanView, "name")}, + Get: APIEndpointAction{Handler: instanceBackupsGet, AccessHandler: allowProjectResourceList}, Post: APIEndpointAction{Handler: instanceBackupsPost, AccessHandler: allowPermission(entity.TypeInstance, auth.EntitlementCanManageBackups, "name")}, } var instanceBackupCmd = APIEndpoint{ - Name: "instanceBackup", - Path: "instances/{name}/backups/{backupName}", + Name: "instanceBackup", + Path: "instances/{name}/backups/{backupName}", + MetricsType: entity.TypeInstance, Aliases: []APIEndpointAlias{ {Name: "containerBackup", Path: "containers/{name}/backups/{backupName}"}, {Name: "vmBackup", Path: "virtual-machines/{name}/backups/{backupName}"}, }, - Get: APIEndpointAction{Handler: instanceBackupGet, AccessHandler: allowPermission(entity.TypeInstance, auth.EntitlementCanView, "name")}, - Post: APIEndpointAction{Handler: instanceBackupPost, AccessHandler: allowPermission(entity.TypeInstance, auth.EntitlementCanManageBackups, "name")}, - Delete: APIEndpointAction{Handler: instanceBackupDelete, AccessHandler: allowPermission(entity.TypeInstance, auth.EntitlementCanManageBackups, "name")}, + Get: APIEndpointAction{Handler: instanceBackupGet, AccessHandler: allowPermission(entity.TypeInstanceBackup, auth.EntitlementCanView, "name", "backupName")}, + Post: APIEndpointAction{Handler: instanceBackupPost, AccessHandler: allowPermission(entity.TypeInstanceBackup, auth.EntitlementCanEdit, "name", "backupName")}, + Delete: APIEndpointAction{Handler: instanceBackupDelete, AccessHandler: allowPermission(entity.TypeInstanceBackup, auth.EntitlementCanEdit, "name", "backupName")}, } var instanceBackupExportCmd = APIEndpoint{ - Name: "instanceBackupExport", - Path: "instances/{name}/backups/{backupName}/export", + Name: "instanceBackupExport", + Path: "instances/{name}/backups/{backupName}/export", + MetricsType: entity.TypeInstance, Aliases: []APIEndpointAlias{ {Name: "containerBackupExport", Path: "containers/{name}/backups/{backupName}/export"}, {Name: "vmBackupExport", Path: "virtual-machines/{name}/backups/{backupName}/export"}, }, - Get: APIEndpointAction{Handler: instanceBackupExportGet, AccessHandler: allowPermission(entity.TypeInstance, auth.EntitlementCanManageBackups, "name")}, + Get: APIEndpointAction{Handler: instanceBackupExportGet, AccessHandler: allowPermission(entity.TypeInstanceBackup, auth.EntitlementCanView, "name", "backupName")}, } type instanceAutostartList []instance.Instance diff --git a/lxd/instances_get.go b/lxd/instances_get.go index df0a3d3b9449..c00335f57472 100644 --- a/lxd/instances_get.go +++ b/lxd/instances_get.go @@ -17,12 +17,10 @@ import ( "github.com/canonical/lxd/lxd/cluster" "github.com/canonical/lxd/lxd/db" dbCluster "github.com/canonical/lxd/lxd/db/cluster" - "github.com/canonical/lxd/lxd/db/query" "github.com/canonical/lxd/lxd/instance" "github.com/canonical/lxd/lxd/instance/instancetype" "github.com/canonical/lxd/lxd/request" "github.com/canonical/lxd/lxd/response" - "github.com/canonical/lxd/lxd/state" "github.com/canonical/lxd/shared" "github.com/canonical/lxd/shared/api" "github.com/canonical/lxd/shared/entity" @@ -222,33 +220,12 @@ func urlInstanceTypeDetect(r *http.Request) (instancetype.Type, error) { func instancesGet(d *Daemon, r *http.Request) response.Response { s := d.State() - for i := 0; i < 100; i++ { - result, err := doInstancesGet(s, r) - if err == nil { - return response.SyncResponse(true, result) - } - - if !query.IsRetriableError(err) { - logger.Debugf("DBERR: containersGet: error %q", err) - return response.SmartError(err) - } - // 100 ms may seem drastic, but we really don't want to thrash - // perhaps we should use a random amount - time.Sleep(100 * time.Millisecond) - } - - logger.Debugf("DBERR: containersGet, db is locked") - logger.Debugf(logger.GetStack()) - return response.InternalError(fmt.Errorf("DB is locked")) -} - -func doInstancesGet(s *state.State, r *http.Request) (any, error) { resultFullList := []*api.InstanceFull{} resultMu := sync.Mutex{} instanceType, err := urlInstanceTypeDetect(r) if err != nil { - return nil, err + return response.BadRequest(err) } // Parse the recursion field. @@ -261,7 +238,7 @@ func doInstancesGet(s *state.State, r *http.Request) (any, error) { filterStr := r.FormValue("filter") clauses, err := filter.Parse(filterStr, filter.QueryOperatorSet()) if err != nil { - return nil, fmt.Errorf("Invalid filter: %w", err) + return response.BadRequest(fmt.Errorf("Invalid filter: %w", err)) } mustLoadObjects := recursion > 0 || (recursion == 0 && clauses != nil && len(clauses.Clauses) > 0) @@ -271,7 +248,7 @@ func doInstancesGet(s *state.State, r *http.Request) (any, error) { allProjects := shared.IsTrue(r.FormValue("all-projects")) if allProjects && projectName != "" { - return nil, api.StatusErrorf(http.StatusBadRequest, "Cannot specify a project when requesting all projects") + return response.BadRequest(fmt.Errorf("Cannot specify a project when requesting all projects")) } else if !allProjects && projectName == "" { projectName = api.ProjectDefaultName } @@ -304,12 +281,12 @@ func doInstancesGet(s *state.State, r *http.Request) (any, error) { return nil }) if err != nil { - return nil, err + return response.SmartError(err) } userHasPermission, err := s.Authorizer.GetPermissionChecker(r.Context(), auth.EntitlementCanView, entity.TypeInstance) if err != nil { - return nil, err + return response.SmartError(err) } // Removes instances the user doesn't have access to. @@ -328,6 +305,8 @@ func doInstancesGet(s *state.State, r *http.Request) (any, error) { } resultErrListAppend := func(inst db.Instance, err error) { + logger.Error("Failed getting instance info", logger.Ctx{"err": err, "project": inst.Project, "instance": inst.Name}) + instFull := &api.InstanceFull{ Instance: api.Instance{ Name: inst.Name, @@ -436,7 +415,7 @@ func doInstancesGet(s *state.State, r *http.Request) (any, error) { for _, projectName := range filteredProjects { insts, err := instanceLoadNodeProjectAll(r.Context(), s, projectName, instanceType) if err != nil { - return nil, fmt.Errorf("Failed loading instances for project %q: %w", projectName, err) + return response.InternalError(fmt.Errorf("Failed loading instances for project %q: %w", projectName, err)) } for _, inst := range insts { @@ -506,7 +485,7 @@ func doInstancesGet(s *state.State, r *http.Request) (any, error) { if clauses != nil && len(clauses.Clauses) > 0 { resultFullList, err = instance.FilterFull(resultFullList, *clauses) if err != nil { - return nil, err + return response.SmartError(err) } } @@ -524,7 +503,7 @@ func doInstancesGet(s *state.State, r *http.Request) (any, error) { resultList = append(resultList, url.String()) } - return resultList, nil + return response.SyncResponse(true, resultList) } if recursion == 1 { @@ -533,10 +512,10 @@ func doInstancesGet(s *state.State, r *http.Request) (any, error) { resultList = append(resultList, &resultFullList[i].Instance) } - return resultList, nil + return response.SyncResponse(true, resultList) } - return resultFullList, nil + return response.SyncResponse(true, resultFullList) } // Fetch information about the containers on the given remote node, using the diff --git a/lxd/instances_post.go b/lxd/instances_post.go index 58b3fa8abef8..a7e2cd008c29 100644 --- a/lxd/instances_post.go +++ b/lxd/instances_post.go @@ -1101,7 +1101,7 @@ func instancesPost(d *Daemon, r *http.Request) response.Response { profileProject := project.ProfileProjectFromRecord(targetProject) switch req.Source.Type { - case "copy": + case api.SourceTypeCopy: if req.Source.Source == "" { return api.StatusErrorf(http.StatusBadRequest, "Must specify a source instance") } @@ -1130,7 +1130,7 @@ func instancesPost(d *Daemon, r *http.Request) response.Response { } } - case "image": + case api.SourceTypeImage: // Check if the image has an entry in the database but fail only if the error // is different than the image not being found. sourceImage, err = getSourceImageFromInstanceSource(ctx, s, tx, targetProject.Name, req.Source, &sourceImageRef, string(req.Type)) @@ -1171,6 +1171,16 @@ func instancesPost(d *Daemon, r *http.Request) response.Response { return err } + dbProfileConfigs, err := dbCluster.GetConfig(ctx, tx.Tx(), "profile") + if err != nil { + return err + } + + dbProfileDevices, err := dbCluster.GetDevices(ctx, tx.Tx(), "profile") + if err != nil { + return err + } + profilesByName := make(map[string]dbCluster.Profile, len(dbProfiles)) for _, dbProfile := range dbProfiles { profilesByName[dbProfile.Name] = dbProfile @@ -1182,7 +1192,7 @@ func instancesPost(d *Daemon, r *http.Request) response.Response { return fmt.Errorf("Requested profile %q doesn't exist", profileName) } - apiProfile, err := profile.ToAPI(ctx, tx.Tx()) + apiProfile, err := profile.ToAPI(ctx, tx.Tx(), dbProfileConfigs, dbProfileDevices) if err != nil { return err } @@ -1271,7 +1281,7 @@ func instancesPost(d *Daemon, r *http.Request) response.Response { if s.ServerClustered && !clusterNotification && targetMemberInfo == nil { // Run instance placement scriptlet if enabled and no cluster member selected yet. if s.GlobalConfig.InstancesPlacementScriptlet() != "" { - leaderAddress, err := d.gateway.LeaderAddress() + leaderInfo, err := s.LeaderInfo() if err != nil { return response.InternalError(err) } @@ -1291,7 +1301,7 @@ func instancesPost(d *Daemon, r *http.Request) response.Response { reqExpanded.Config = instancetype.ExpandInstanceConfig(globalConfigDump, reqExpanded.Config, profiles) reqExpanded.Devices = instancetype.ExpandInstanceDevices(deviceConfig.NewDevices(reqExpanded.Devices), profiles).CloneNative() - targetMemberInfo, err = scriptlet.InstancePlacementRun(r.Context(), logger.Log, s, &reqExpanded, candidateMembers, leaderAddress) + targetMemberInfo, err = scriptlet.InstancePlacementRun(r.Context(), logger.Log, s, &reqExpanded, candidateMembers, leaderInfo.Address) if err != nil { return response.SmartError(fmt.Errorf("Failed instance placement scriptlet: %w", err)) } @@ -1329,15 +1339,15 @@ func instancesPost(d *Daemon, r *http.Request) response.Response { } switch req.Source.Type { - case "image": + case api.SourceTypeImage: return createFromImage(s, r, *targetProject, profiles, sourceImage, sourceImageRef, &req) - case "none": + case api.SourceTypeNone: return createFromNone(s, r, targetProjectName, profiles, &req) - case "migration": + case api.SourceTypeMigration: return createFromMigration(s, r, targetProjectName, profiles, &req) - case "conversion": + case api.SourceTypeConversion: return createFromConversion(s, r, targetProjectName, profiles, &req) - case "copy": + case api.SourceTypeCopy: return createFromCopy(s, r, targetProjectName, profiles, &req) default: return response.BadRequest(fmt.Errorf("Unknown source type %s", req.Source.Type)) @@ -1497,7 +1507,7 @@ func clusterCopyContainerInternal(s *state.State, r *http.Request, source instan } // Reset the source for a migration - req.Source.Type = "migration" + req.Source.Type = api.SourceTypeMigration req.Source.Certificate = string(s.Endpoints.NetworkCert().PublicKey()) req.Source.Mode = "pull" req.Source.Operation = fmt.Sprintf("https://%s/%s/operations/%s", nodeAddress, version.APIVersion, opAPI.ID) diff --git a/lxd/lifecycle/identity.go b/lxd/lifecycle/identity.go index 888a7aa007f8..d6d15922b300 100644 --- a/lxd/lifecycle/identity.go +++ b/lxd/lifecycle/identity.go @@ -12,9 +12,10 @@ type IdentityAction string const ( IdentityCreated = IdentityAction(api.EventLifecycleIdentityCreated) IdentityUpdated = IdentityAction(api.EventLifecycleIdentityUpdated) + IdentityDeleted = IdentityAction(api.EventLifecycleIdentityDeleted) ) -// Event creates the lifecycle event for an action on a Certificate. +// Event creates the lifecycle event for an action on an Identity. func (a IdentityAction) Event(authenticationMethod string, identifier string, requestor *api.EventLifecycleRequestor, ctx map[string]any) api.EventLifecycle { u := api.NewURL().Path(version.APIVersion, "auth", "identities", authenticationMethod, identifier) diff --git a/lxd/main_callhook.go b/lxd/main_callhook.go index 3aa8ca4a81b3..5c40f86a474a 100644 --- a/lxd/main_callhook.go +++ b/lxd/main_callhook.go @@ -1,16 +1,24 @@ package main import ( + "bufio" + "encoding/json" + "errors" "fmt" "os" + "os/exec" + "path/filepath" + "strings" "github.com/spf13/cobra" "github.com/canonical/lxd/lxd-user/callhook" + "github.com/canonical/lxd/lxd/device/cdi" ) type cmdCallhook struct { - global *cmdGlobal + global *cmdGlobal + devicesRootFolder string } // Command returns a cobra command for `lxd callhook`. @@ -22,14 +30,174 @@ func (c *cmdCallhook) Command() *cobra.Command { Call container lifecycle hook in LXD This internal command notifies LXD about a container lifecycle event - (start, stopns, stop, restart) and blocks until LXD has processed it. + (start, startmountns, stopns, stop, restart) and blocks until LXD has processed it. ` cmd.RunE = c.Run cmd.Hidden = true + // devicesRootFolder is used to specify where to look for CDI config device files. + cmd.Flags().StringVar(&c.devicesRootFolder, "devicesRootFolder", "", "Root folder for CDI devices") + return cmd } +// resolveTargetRelativeToLink converts a target relative to a link path into an absolute path. +func resolveTargetRelativeToLink(link string, target string) (string, error) { + if !filepath.IsAbs(link) { + return "", fmt.Errorf("The link must be an absolute path: %q (target: %q)", link, target) + } + + if filepath.IsAbs(target) { + return target, nil + } + + linkDir := filepath.Dir(link) + absTarget := filepath.Join(linkDir, target) + cleanPath := filepath.Clean(absTarget) + absPath, err := filepath.Abs(cleanPath) + if err != nil { + return "", err + } + + return absPath, nil +} + +// customCDILinkerConfFile is the name of the linker conf file we will write to +// inside the container. The `00-lxdcdi` prefix is chosen to ensure that these libraries have +// a higher precedence than other libraries on the system. +var customCDILinkerConfFile = "00-lxdcdi.conf" + +// applyCDIHooksToContainer is called before the container has started but after the container namespace has been setup, +// and is used whenever CDI devices are added to a container and where symlinks and linker cache entries need to be created. +// These entries are listed in a 'CDI hooks file' located at `hooksFilePath`. +func applyCDIHooksToContainer(devicesRootFolder string, hooksFilePath string) error { + hookFile, err := os.Open(filepath.Join(devicesRootFolder, hooksFilePath)) + if err != nil { + return fmt.Errorf("Failed to open the CDI hooks file at %q: %w", hooksFilePath, err) + } + + defer hookFile.Close() + + hooks := &cdi.Hooks{} + err = json.NewDecoder(hookFile).Decode(hooks) + if err != nil { + return fmt.Errorf("Failed to decode the CDI hooks file at %q: %w\n", hooksFilePath, err) + } + + fmt.Println("CDI Hooks file loaded:") + prettyHooks, err := json.MarshalIndent(hooks, "", " ") + if err != nil { + return err + } + + containerRootFSMount := os.Getenv("LXC_ROOTFS_MOUNT") + if containerRootFSMount == "" { + return fmt.Errorf("LXC_ROOTFS_MOUNT is empty") + } + + fmt.Println(string(prettyHooks)) + + // Creating the symlinks + for _, symlink := range hooks.Symlinks { + // Resolve hook link from target + absTarget, err := resolveTargetRelativeToLink(symlink.Link, symlink.Target) + if err != nil { + return fmt.Errorf("Failed to resolve a CDI symlink: %w\n", err) + } + + // Try to create the directory if it doesn't exist + err = os.MkdirAll(filepath.Dir(filepath.Join(containerRootFSMount, symlink.Link)), 0755) + if err != nil { + return fmt.Errorf("Failed to create the directory for the CDI symlink: %w\n", err) + } + + // Create the symlink + err = os.Symlink(absTarget, filepath.Join(containerRootFSMount, symlink.Link)) + if err != nil { + if !os.IsExist(err) { + return fmt.Errorf("Failed to create the CDI symlink: %w\n", err) + } + + fmt.Printf("Symlink not created because link %q already exists for target %q\n", symlink.Link, absTarget) + } + } + + // Updating the linker cache + l := len(hooks.LDCacheUpdates) + if l > 0 { + ldConfFilePath := fmt.Sprintf("%s/etc/ld.so.conf.d/%s", containerRootFSMount, customCDILinkerConfFile) + _, err = os.Stat(ldConfFilePath) + if err == nil { + // The file already exists. Read it first, analyze its entries + // and add the ones that are not already there. + ldConfFile, err := os.OpenFile(ldConfFilePath, os.O_APPEND|os.O_RDWR, 0644) + if err != nil { + return fmt.Errorf("Failed to open the ld.so.conf file at %q: %w\n", ldConfFilePath, err) + } + + existingLinkerEntries := make(map[string]bool) + scanner := bufio.NewScanner(ldConfFile) + for scanner.Scan() { + existingLinkerEntries[strings.TrimSpace(scanner.Text())] = true + } + + fmt.Printf("Existing linker entries: %v\n", existingLinkerEntries) + for _, update := range hooks.LDCacheUpdates { + if !existingLinkerEntries[update] { + fmt.Printf("Adding linker entry: %s\n", update) + _, err = fmt.Fprintln(ldConfFile, update) + if err != nil { + ldConfFile.Close() + return fmt.Errorf("Failed to write to the linker conf file at %q: %w\n", ldConfFilePath, err) + } + + existingLinkerEntries[update] = true + } + } + + ldConfFile.Close() + } else if errors.Is(err, os.ErrNotExist) { + // The file does not exist. We simply create it with our entries. + ldConfFile, err := os.OpenFile(ldConfFilePath, os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("Failed to create the linker conf file at %q: %w\n", ldConfFilePath, err) + } + + for _, update := range hooks.LDCacheUpdates { + fmt.Printf("Adding linker entry: %s\n", update) + _, err = fmt.Fprintln(ldConfFile, update) + if err != nil { + ldConfFile.Close() + return fmt.Errorf("Failed to write to the linker conf file at %q: %w\n", ldConfFilePath, err) + } + } + + ldConfFile.Close() + } else { + return fmt.Errorf("Could not stat the linker conf file to add CDI linker entries at %q: %w\n", ldConfFilePath, err) + } + } + + // Then remove the linker cache and regenerate it + linkerCachePath := filepath.Join(containerRootFSMount, "/etc/ld.so.cache") + err = os.Remove(linkerCachePath) + if err != nil { + if !os.IsNotExist(err) { + return fmt.Errorf("Failed to remove the ld.so.cache file: %w\n", err) + } + + fmt.Printf("Linker cache not found in %q, skipping removal\n", linkerCachePath) + } + + // Run `ldconfig` on the HOST (but targeting the container rootFS) to reduce the risk of running untrusted code in the container. + err = exec.Command("/sbin/ldconfig", "-r", containerRootFSMount).Run() + if err != nil { + return fmt.Errorf("Failed to run ldconfig in the container rootfs: %w\n", err) + } + + return nil +} + // Run executes the `lxd callhook` command. func (c *cmdCallhook) Run(cmd *cobra.Command, args []string) error { // Only root should run this. @@ -38,7 +206,7 @@ func (c *cmdCallhook) Run(cmd *cobra.Command, args []string) error { } // Parse request. - lxdPath, projectName, instanceRef, hook, _, err := callhook.ParseArgs(args) + lxdPath, projectName, instanceRef, hook, cdiHooksFiles, err := callhook.ParseArgs(args) if err != nil { _ = cmd.Help() if len(args) == 0 { @@ -48,6 +216,27 @@ func (c *cmdCallhook) Run(cmd *cobra.Command, args []string) error { return err } + // Handle startmountns hook. + if hook == "startmountns" { + if len(cdiHooksFiles) == 0 { + return fmt.Errorf("Missing required CDI hooks files argument") + } + + if c.devicesRootFolder == "" { + return fmt.Errorf("Missing required --devicesRootFolder flag") + } + + var err error + for _, cdiHooksFile := range cdiHooksFiles { + err = applyCDIHooksToContainer(c.devicesRootFolder, cdiHooksFile) + if err != nil { + return err + } + } + + return nil + } + // Handle all other hook types. return callhook.HandleContainerHook(lxdPath, projectName, instanceRef, hook) } diff --git a/lxd/main_cluster.go b/lxd/main_cluster.go index 35a71d6ba3b9..1f19e10958ba 100644 --- a/lxd/main_cluster.go +++ b/lxd/main_cluster.go @@ -25,10 +25,25 @@ import ( "github.com/canonical/lxd/shared/termios" ) +func promptConfirmation(prompt string, opname string) error { + reader := bufio.NewReader(os.Stdin) + fmt.Print(prompt + "Do you want to proceed? (yes/no): ") + + input, _ := reader.ReadString('\n') + input = strings.TrimSuffix(input, "\n") + + if !shared.ValueInSlice(strings.ToLower(input), []string{"yes"}) { + return fmt.Errorf("%s operation aborted", opname) + } + + return nil +} + type cmdCluster struct { global *cmdGlobal } +// Command returns a subcommand for administrating a cluster. func (c *cmdCluster) Command() *cobra.Command { cmd := &cobra.Command{} cmd.Use = "cluster" @@ -41,8 +56,8 @@ func (c *cmdCluster) Command() *cobra.Command { cmd.AddCommand(listDatabase.Command()) // Recover - recover := cmdClusterRecoverFromQuorumLoss{global: c.global} - cmd.AddCommand(recover.Command()) + clusterRecover := cmdClusterRecoverFromQuorumLoss{global: c.global} + cmd.AddCommand(clusterRecover.Command()) // Remove a raft node. removeRaftNode := cmdClusterRemoveRaftNode{global: c.global} @@ -62,7 +77,7 @@ func (c *cmdCluster) Command() *cobra.Command { return cmd } -const SegmentComment = "# Latest dqlite segment ID: %s" +const segmentComment = "# Latest dqlite segment ID: %s" // ClusterMember is a more human-readable representation of the db.RaftNode struct. type ClusterMember struct { @@ -104,10 +119,31 @@ func (c ClusterMember) ToRaftNode() (*db.RaftNode, error) { return node, nil } +const clusterEditPrompt = `You should run this command only if: + - A quorum of cluster members is permanently lost or their addresses have changed + - You are *absolutely* sure all LXD daemons are stopped + - This instance has the most up to date database + +See https://documentation.ubuntu.com/lxd/en/latest/howto/cluster_recover/#reconfigure-the-cluster for more info.` + +const clusterEditComment = `# Member roles can be modified. Unrecoverable nodes should be given the role "spare". +# +# "voter" - Voting member of the database. A majority of voters is a quorum. +# "stand-by" - Non-voting member of the database; can be promoted to voter. +# "spare" - Not a member of the database. +# +# The edit is aborted if: +# - the number of members changes +# - the name of any member changes +# - the ID of any member changes +# - no changes are made +` + type cmdClusterEdit struct { global *cmdGlobal } +// Command returns a command for reconfiguring a cluster. func (c *cmdClusterEdit) Command() *cobra.Command { cmd := &cobra.Command{} cmd.Use = "edit" @@ -119,6 +155,7 @@ func (c *cmdClusterEdit) Command() *cobra.Command { return cmd } +// Run executes the command for reconfiguring a cluster. func (c *cmdClusterEdit) Run(cmd *cobra.Command, args []string) error { // Make sure that the daemon is not running. _, err := lxd.ConnectLXDUnix("", nil) @@ -174,8 +211,16 @@ func (c *cmdClusterEdit) Run(cmd *cobra.Command, args []string) error { return err } } else { + err = promptConfirmation(clusterEditPrompt, "Cluster edit") + if err != nil { + return err + } + if len(config.Members) > 0 { - data = []byte(fmt.Sprintf(SegmentComment, segmentID) + "\n\n" + string(data)) + data = []byte( + clusterEditComment + "\n\n" + + fmt.Sprintf(segmentComment, segmentID) + "\n\n" + + string(data)) } content, err = shared.TextEditor("", data) @@ -184,12 +229,14 @@ func (c *cmdClusterEdit) Run(cmd *cobra.Command, args []string) error { } } + var tarballPath string + var newNodes []db.RaftNode for { newConfig := ClusterConfig{} err = yaml.Unmarshal(content, &newConfig) if err == nil { // Convert ClusterConfig back to RaftNodes. - newNodes := []db.RaftNode{} + newNodes = []db.RaftNode{} var newNode *db.RaftNode for _, node := range newConfig.Members { newNode, err = node.ToRaftNode() @@ -203,9 +250,6 @@ func (c *cmdClusterEdit) Run(cmd *cobra.Command, args []string) error { // Ensure new configuration is valid. if err == nil { err = validateNewConfig(nodes, newNodes) - if err == nil { - err = cluster.Reconfigure(database, newNodes) - } } } @@ -228,6 +272,16 @@ func (c *cmdClusterEdit) Run(cmd *cobra.Command, args []string) error { break } + tarballPath, err = cluster.Reconfigure(database, newNodes) + if err != nil { + fmt.Fprintf(os.Stderr, "Cluster reconfiguration failed; restore from backup.\n") + return err + } + + fmt.Printf("Cluster changes applied; new database state saved to %s\n\n", tarballPath) + fmt.Printf("*Before* starting any cluster member, copy %s to %s on all remaining cluster members.\n\n", tarballPath, tarballPath) + fmt.Printf("LXD will load this file during startup.\n") + return nil } @@ -276,6 +330,7 @@ type cmdClusterShow struct { global *cmdGlobal } +// Command returns a command for showing the current cluster configuration. func (c *cmdClusterShow) Command() *cobra.Command { cmd := &cobra.Command{} cmd.Use = "show" @@ -287,6 +342,7 @@ func (c *cmdClusterShow) Command() *cobra.Command { return cmd } +// Run executes the command for showing the current cluster configuration. func (c *cmdClusterShow) Run(cmd *cobra.Command, args []string) error { database, err := db.OpenNode(filepath.Join(sys.DefaultOS().VarDir, "database"), nil) if err != nil { @@ -321,7 +377,7 @@ func (c *cmdClusterShow) Run(cmd *cobra.Command, args []string) error { } if len(config.Members) > 0 { - fmt.Printf(SegmentComment+"\n\n%s", segmentID, data) + fmt.Printf(segmentComment+"\n\n%s", segmentID, data) } else { fmt.Print(data) } @@ -333,6 +389,7 @@ type cmdClusterListDatabase struct { global *cmdGlobal } +// Command returns a command for showing the database roles of cluster members. func (c *cmdClusterListDatabase) Command() *cobra.Command { cmd := &cobra.Command{} cmd.Use = "list-database" @@ -344,6 +401,7 @@ func (c *cmdClusterListDatabase) Command() *cobra.Command { return cmd } +// Run executes the command for showing the database roles of cluster members. func (c *cmdClusterListDatabase) Run(cmd *cobra.Command, args []string) error { os := sys.DefaultOS() @@ -368,11 +426,28 @@ func (c *cmdClusterListDatabase) Run(cmd *cobra.Command, args []string) error { return nil } +const recoverFromQuorumLossPrompt = `You should run this command only if you are *absolutely* certain that this is +the only database member left in your cluster AND that other database members will +never come back (i.e. their LXD daemon won't ever be started again). + +This will make this LXD server the only member of the cluster, and it won't +be possible to perform operations on former cluster members anymore. + +However all information about former cluster members will be preserved in the +database, so you can possibly inspect it for further recovery. + +You'll be able to permanently delete from the database all information about +former cluster members by running "lxc cluster remove --force". + +See https://documentation.ubuntu.com/lxd/en/latest/howto/cluster_recover/#recover-from-quorum-loss for more +info.` + type cmdClusterRecoverFromQuorumLoss struct { global *cmdGlobal flagNonInteractive bool } +// Command returns a command for rebuilding a cluster based on the current member. func (c *cmdClusterRecoverFromQuorumLoss) Command() *cobra.Command { cmd := &cobra.Command{} cmd.Use = "recover-from-quorum-loss" @@ -385,6 +460,7 @@ func (c *cmdClusterRecoverFromQuorumLoss) Command() *cobra.Command { return cmd } +// Run executes the command for rebuilding a cluster based on the current member. func (c *cmdClusterRecoverFromQuorumLoss) Run(cmd *cobra.Command, args []string) error { // Make sure that the daemon is not running. _, err := lxd.ConnectLXDUnix("", nil) @@ -394,7 +470,7 @@ func (c *cmdClusterRecoverFromQuorumLoss) Run(cmd *cobra.Command, args []string) // Prompt for confirmation unless --quiet was passed. if !c.flagNonInteractive { - err := c.promptConfirmation() + err := promptConfirmation(recoverFromQuorumLossPrompt, "Recover") if err != nil { return err } @@ -410,40 +486,16 @@ func (c *cmdClusterRecoverFromQuorumLoss) Run(cmd *cobra.Command, args []string) return cluster.Recover(db) } -func (c *cmdClusterRecoverFromQuorumLoss) promptConfirmation() error { - reader := bufio.NewReader(os.Stdin) - fmt.Print(`You should run this command only if you are *absolutely* certain that this is -the only database node left in your cluster AND that other database nodes will -never come back (i.e. their LXD daemon won't ever be started again). - -This will make this LXD instance the only member of the cluster, and it won't -be possible to perform operations on former cluster members anymore. - -However all information about former cluster members will be preserved in the -database, so you can possibly inspect it for further recovery. - -You'll be able to permanently delete from the database all information about -former cluster members by running "lxc cluster remove --force". - -See https://documentation.ubuntu.com/lxd/en/latest/howto/cluster_recover/#recover-from-quorum-loss for more -info. - -Do you want to proceed? (yes/no): `) - input, _ := reader.ReadString('\n') - input = strings.TrimSuffix(input, "\n") - - if !shared.ValueInSlice(strings.ToLower(input), []string{"yes"}) { - return fmt.Errorf("Recover operation aborted") - } - - return nil -} +const removeRaftNodePrompt = `You should run this command only if you ended up in an +inconsistent state where a cluster member has been uncleanly removed (i.e. it +doesn't show up in "lxc cluster list" but it's still in the raft configuration).` type cmdClusterRemoveRaftNode struct { global *cmdGlobal flagNonInteractive bool } +// Command returns a command for removing a raft node from the currently running database. func (c *cmdClusterRemoveRaftNode) Command() *cobra.Command { cmd := &cobra.Command{} cmd.Use = "remove-raft-node
" @@ -456,6 +508,7 @@ func (c *cmdClusterRemoveRaftNode) Command() *cobra.Command { return cmd } +// Run executes the command for removing a raft node from the currently running database. func (c *cmdClusterRemoveRaftNode) Run(cmd *cobra.Command, args []string) error { if len(args) != 1 { _ = cmd.Help() @@ -466,7 +519,7 @@ func (c *cmdClusterRemoveRaftNode) Run(cmd *cobra.Command, args []string) error // Prompt for confirmation unless --quiet was passed. if !c.flagNonInteractive { - err := c.promptConfirmation() + err := promptConfirmation(removeRaftNodePrompt, "Remove raft node") if err != nil { return err } @@ -485,20 +538,3 @@ func (c *cmdClusterRemoveRaftNode) Run(cmd *cobra.Command, args []string) error return nil } - -func (c *cmdClusterRemoveRaftNode) promptConfirmation() error { - reader := bufio.NewReader(os.Stdin) - fmt.Print(`You should run this command only if you ended up in an -inconsistent state where a node has been uncleanly removed (i.e. it doesn't show -up in "lxc cluster list" but it's still in the raft configuration). - -Do you want to proceed? (yes/no): `) - input, _ := reader.ReadString('\n') - input = strings.TrimSuffix(input, "\n") - - if !shared.ValueInSlice(strings.ToLower(input), []string{"yes"}) { - return fmt.Errorf("Remove raft node operation aborted") - } - - return nil -} diff --git a/lxd/metadata/configuration.json b/lxd/metadata/configuration.json index b76baecc6c3d..2b365fad7008 100644 --- a/lxd/metadata/configuration.json +++ b/lxd/metadata/configuration.json @@ -315,8 +315,8 @@ }, { "id": { - "longdesc": "", - "shortdesc": "DRM card ID of the GPU device", + "longdesc": "The ID can either be the DRM card ID of the GPU device (container or VM) or a fully-qualified Container Device Interface (CDI) name (container only).\nHere are some examples of fully-qualified CDI names:\n\n- `nvidia.com/gpu=0`: Instructs LXD to operate a discrete GPU (dGPU) pass-through of brand NVIDIA with the first discovered GPU on your system. You can use the `nvidia-smi` tool on your host to find out which identifier to use.\n- `nvidia.com/gpu=1833c8b5-9aa0-5382-b784-68b7e77eb185`: Instructs LXD to operate a discrete GPU (dGPU) pass-through of brand NVIDIA with a given GPU unique identifier. This identifier should also appear with `nvidia-smi -L`.\n- `nvidia.com/igpu=all`: Instructs LXD to pass all the host integrated GPUs (iGPU) of brand NVIDIA. The concept of an index does not currently map to iGPUs. It is possible to list them with the `nvidia-smi -L` command. A special `nvgpu` mention should appear in the generated list to indicate a device to be an iGPU.\n- `nvidia.com/gpu=all`: Instructs LXD to pass all the host GPUs of brand NVIDIA through to the container.", + "shortdesc": "ID of the GPU device", "type": "string" } }, @@ -1631,7 +1631,7 @@ "gid": { "defaultdesc": "`0`", "longdesc": "", - "shortdesc": "GID of the device owner in the instance", + "shortdesc": "GID of the device owner in the container", "type": "integer" } }, @@ -1655,7 +1655,7 @@ "mode": { "defaultdesc": "`0660`", "longdesc": "", - "shortdesc": "Mode of the device in the instance", + "shortdesc": "Mode of the device in the container", "type": "integer" } }, @@ -1663,7 +1663,7 @@ "path": { "longdesc": "", "required": "either `source` or `path` must be set", - "shortdesc": "Path inside the instance", + "shortdesc": "Path inside the container", "type": "string" } }, @@ -1671,7 +1671,7 @@ "required": { "defaultdesc": "`true`", "longdesc": "See {ref}`devices-unix-block-hotplugging` for more information.", - "shortdesc": "Whether this device is required to start the instance", + "shortdesc": "Whether this device is required to start the container", "type": "bool" } }, @@ -1687,7 +1687,7 @@ "uid": { "defaultdesc": "`0`", "longdesc": "", - "shortdesc": "UID of the device owner in the instance", + "shortdesc": "UID of the device owner in the container", "type": "integer" } } @@ -1701,7 +1701,7 @@ "gid": { "defaultdesc": "`0`", "longdesc": "", - "shortdesc": "GID of the device owner in the instance", + "shortdesc": "GID of the device owner in the container", "type": "integer" } }, @@ -1725,7 +1725,7 @@ "mode": { "defaultdesc": "`0660`", "longdesc": "", - "shortdesc": "Mode of the device in the instance", + "shortdesc": "Mode of the device in the container", "type": "integer" } }, @@ -1733,7 +1733,7 @@ "path": { "longdesc": "", "required": "either `source` or `path` must be set", - "shortdesc": "Path inside the instance", + "shortdesc": "Path inside the container", "type": "string" } }, @@ -1741,7 +1741,7 @@ "required": { "defaultdesc": "`true`", "longdesc": "See {ref}`devices-unix-char-hotplugging` for more information.", - "shortdesc": "Whether this device is required to start the instance", + "shortdesc": "Whether this device is required to start the container", "type": "bool" } }, @@ -1757,7 +1757,7 @@ "uid": { "defaultdesc": "`0`", "longdesc": "", - "shortdesc": "UID of the device owner in the instance", + "shortdesc": "UID of the device owner in the container", "type": "integer" } } @@ -1771,7 +1771,7 @@ "gid": { "defaultdesc": "`0`", "longdesc": "", - "shortdesc": "GID of the device owner in the instance", + "shortdesc": "GID of the device owner in the container", "type": "integer" } }, @@ -1779,7 +1779,7 @@ "mode": { "defaultdesc": "`0660`", "longdesc": "", - "shortdesc": "Mode of the device in the instance", + "shortdesc": "Mode of the device in the container", "type": "integer" } }, @@ -1794,7 +1794,7 @@ "required": { "defaultdesc": "`false`", "longdesc": "The default is `false`, which means that all devices can be hotplugged.", - "shortdesc": "Whether this device is required to start the instance", + "shortdesc": "Whether this device is required to start the container", "type": "bool" } }, @@ -1802,7 +1802,7 @@ "uid": { "defaultdesc": "`0`", "longdesc": "", - "shortdesc": "UID of the device owner in the instance", + "shortdesc": "UID of the device owner in the container", "type": "integer" } }, @@ -1838,7 +1838,7 @@ "condition": "container", "defaultdesc": "`0`", "longdesc": "", - "shortdesc": "GID of the device owner in the container", + "shortdesc": "GID of the device owner in the instance", "type": "integer" } }, @@ -1847,7 +1847,7 @@ "condition": "container", "defaultdesc": "`0660`", "longdesc": "", - "shortdesc": "Mode of the device in the container", + "shortdesc": "Mode of the device in the instance", "type": "integer" } }, @@ -1878,7 +1878,7 @@ "condition": "container", "defaultdesc": "`0`", "longdesc": "", - "shortdesc": "UID of the device owner in the container", + "shortdesc": "UID of the device owner in the instance", "type": "integer" } }, @@ -1905,7 +1905,7 @@ }, { "boot.autostart.delay": { - "defaultdesc": "\"0\"", + "defaultdesc": "`0`", "liveupdate": "no", "longdesc": "The number of seconds to wait after the instance started before starting the next one.", "shortdesc": "Delay after starting the instance", @@ -1914,7 +1914,7 @@ }, { "boot.autostart.priority": { - "defaultdesc": "\"0\"", + "defaultdesc": "`0`", "liveupdate": "no", "longdesc": "The instance with the highest value is started first.", "shortdesc": "What order to start the instances in", @@ -1930,7 +1930,7 @@ }, { "boot.host_shutdown_timeout": { - "defaultdesc": "\"30\"", + "defaultdesc": "`30`", "liveupdate": "yes", "longdesc": "Number of seconds to wait for the instance to shut down before it is force-stopped.", "shortdesc": "How long to wait for the instance to shut down", @@ -1939,7 +1939,7 @@ }, { "boot.stop.priority": { - "defaultdesc": "\"0\"", + "defaultdesc": "`0`", "liveupdate": "no", "longdesc": "The instance with the highest value is shut down first.", "shortdesc": "What order to shut down the instances in", @@ -2105,14 +2105,6 @@ "type": "string" } }, - { - "ubuntu_pro.guest_attach": { - "liveupdate": "no", - "longdesc": "Indicate whether the guest should auto-attach Ubuntu Pro at start up.\nThe allowed values are `off`, `on`, and `available`.\nIf set to `off`, it will not be possible for the Ubuntu Pro client in the guest to obtain guest token via `devlxd`.\nIf set to `available`, attachment via guest token is possible but will not be performed automatically by the Ubuntu Pro client in the guest at startup.\nIf set to `on`, attachment will be performed automatically by the Ubuntu Pro client in the guest at startup.\nTo allow guest attachment, the host must be an Ubuntu machine that is Pro attached, and guest attachment must be enabled via the Pro client.\nTo do this, run `pro config set lxd_guest_attach=on`.", - "shortdesc": "Whether to auto-attach Ubuntu Pro.", - "type": "string" - } - }, { "user.*": { "liveupdate": "no", @@ -2251,6 +2243,16 @@ "type": "string" } }, + { + "limits.cpu.pin_strategy": { + "condition": "virtual machine", + "defaultdesc": "`none`", + "liveupdate": "no", + "longdesc": "Specify the strategy for VM CPU auto pinning.\nPossible values: `none` (disables CPU auto pinning) and `auto` (enables CPU auto pinning).\n\nSee {ref}`instance-options-limits-cpu-vm` for more information.", + "shortdesc": "VM CPU auto pinning strategy", + "type": "string" + } + }, { "limits.cpu.priority": { "condition": "container", @@ -2401,7 +2403,7 @@ { "security.devlxd.images": { "defaultdesc": "`false`", - "liveupdate": "no", + "liveupdate": "yes", "longdesc": "", "shortdesc": "Controls the availability of the `/1.0/images` API over `devlxd`", "type": "bool" @@ -3877,7 +3879,7 @@ "condition": "standard mode", "defaultdesc": "initial value on creation: `auto`", "longdesc": "Use CIDR notation.\n\nYou can set the option to `none` to turn off IPv4, or to `auto` to generate a new random unused subnet.", - "shortdesc": "IPv4 address for the bridge", + "shortdesc": "IPv4 address for the OVN network", "type": "string" } }, @@ -3921,7 +3923,7 @@ "condition": "standard mode", "defaultdesc": "initial value on creation: `auto`", "longdesc": "Use CIDR notation.\n\nYou can set the option to `none` to turn off IPv6, or to `auto` to generate a new random unused subnet.", - "shortdesc": "IPv6 address for the bridge", + "shortdesc": "IPv6 address for the OVN network", "type": "string" } }, @@ -4473,6 +4475,13 @@ "type": "string" } }, + { + "limits.disk.pool.POOL_NAME": { + "longdesc": "This value is the maximum value of the aggregate disk\nspace used by all instance volumes, custom volumes, and images of the\nproject on this specific storage pool.\n\nWhen set to 0, the pool is excluded from storage pool list for\nthe project.", + "shortdesc": "Maximum disk space used by the project on this pool", + "type": "string" + } + }, { "limits.instances": { "longdesc": "", @@ -5400,6 +5409,15 @@ }, "volume-conf": { "keys": [ + { + "security.shared": { + "condition": "custom block volume", + "defaultdesc": "same as `volume.security.shared` or `false`", + "longdesc": "Enabling this option allows sharing the volume across multiple instances despite the possibility of data loss.\n", + "shortdesc": "Enable volume sharing", + "type": "bool" + } + }, { "security.shifted": { "condition": "custom volume", @@ -5568,6 +5586,15 @@ "type": "string" } }, + { + "security.shared": { + "condition": "custom block volume", + "defaultdesc": "same as `volume.security.shared` or `false`", + "longdesc": "Enabling this option allows sharing the volume across multiple instances despite the possibility of data loss.\n", + "shortdesc": "Enable volume sharing", + "type": "bool" + } + }, { "security.shifted": { "condition": "custom volume", @@ -5872,6 +5899,15 @@ }, "volume-conf": { "keys": [ + { + "security.shared": { + "condition": "custom block volume", + "defaultdesc": "same as `volume.security.shared` or `false`", + "longdesc": "Enabling this option allows sharing the volume across multiple instances despite the possibility of data loss.\n", + "shortdesc": "Enable volume sharing", + "type": "bool" + } + }, { "security.shifted": { "condition": "custom volume", @@ -6070,6 +6106,15 @@ "type": "string" } }, + { + "security.shared": { + "condition": "custom block volume", + "defaultdesc": "same as `volume.security.shared` or `false`", + "longdesc": "Enabling this option allows sharing the volume across multiple instances despite the possibility of data loss.\n", + "shortdesc": "Enable volume sharing", + "type": "bool" + } + }, { "security.shifted": { "condition": "custom volume", @@ -6260,6 +6305,15 @@ "type": "string" } }, + { + "security.shared": { + "condition": "custom block volume", + "defaultdesc": "same as `volume.security.shared` or `false`", + "longdesc": "Enabling this option allows sharing the volume across multiple instances despite the possibility of data loss.\n", + "shortdesc": "Enable volume sharing", + "type": "bool" + } + }, { "security.shifted": { "condition": "custom volume", @@ -6409,6 +6463,15 @@ "type": "string" } }, + { + "security.shared": { + "condition": "custom block volume", + "defaultdesc": "same as `volume.security.shared` or `false`", + "longdesc": "Enabling this option allows sharing the volume across multiple instances despite the possibility of data loss.\n", + "shortdesc": "Enable volume sharing", + "type": "bool" + } + }, { "security.shifted": { "condition": "custom volume", @@ -6648,7 +6711,7 @@ }, { "name": "can_view", - "description": "Grants permission to view the instance." + "description": "Grants permission to view the instance and any snapshots or backups it might have." }, { "name": "can_update_state", @@ -7095,6 +7158,10 @@ { "name": "can_view_warnings", "description": "Grants permission to view warnings." + }, + { + "name": "can_view_unmanaged_networks", + "description": "Grants permission to view unmanaged networks on the LXD host machines." } ] }, @@ -7141,7 +7208,7 @@ }, { "name": "can_view", - "description": "Grants permission to view the storage volume." + "description": "Grants permission to view the storage volume and any snapshots or backups it might have." }, { "name": "can_manage_snapshots", diff --git a/lxd/metrics/api_rates.go b/lxd/metrics/api_rates.go new file mode 100644 index 000000000000..f21bdfc86318 --- /dev/null +++ b/lxd/metrics/api_rates.go @@ -0,0 +1,102 @@ +package metrics + +import ( + "net/http" + "sync" + "sync/atomic" + + "github.com/canonical/lxd/lxd/request" + "github.com/canonical/lxd/shared/entity" +) + +// RequestResult represents a completed request status category. +type RequestResult int8 + +// This defines every possible request result to be used as a metric label. +const ( + ErrorServer RequestResult = iota + ErrorClient + Success +) + +var requestResultNames = map[RequestResult]string{ + ErrorServer: "error_server", + ErrorClient: "error_client", + Success: "succeeded", +} + +// GetRequestResultsNames returns a map containing all possible request result types and their names. +// This is also used to iterate through the possible results. +func GetRequestResultsNames() map[RequestResult]string { + return requestResultNames +} + +type completedMetricsLabeling struct { + entityType entity.Type + result RequestResult +} + +var ongoingRequests map[entity.Type]*atomic.Int64 +var completedRequests map[completedMetricsLabeling]*atomic.Int64 + +// InitAPIMetrics initializes maps with initial values for the API rates metrics. +func InitAPIMetrics() { + relevantEntityTypes := entity.APIMetricsEntityTypes() + ongoingRequests = make(map[entity.Type]*atomic.Int64, len(relevantEntityTypes)) + completedRequests = make(map[completedMetricsLabeling]*atomic.Int64, len(relevantEntityTypes)*len(requestResultNames)) + + for _, entityType := range relevantEntityTypes { + ongoingRequests[entityType] = new(atomic.Int64) + for result := range requestResultNames { + completedRequests[completedMetricsLabeling{entityType: entityType, result: result}] = new(atomic.Int64) + } + } +} + +// countStartedRequest should be called before each request handler to keep track of ongoing requests. +func countStartedRequest(endpointType entity.Type) { + ongoingRequests[endpointType].Add(1) +} + +// countCompletedRequest should be called after each request is completed to keep track of completed requests. +func countCompletedRequest(endpointType entity.Type, result RequestResult) { + ongoingRequests[endpointType].Add(-1) + completedRequests[completedMetricsLabeling{entityType: endpointType, result: result}].Add(1) +} + +// GetOngoingRequests gets the value for ongoing metrics filtered by entity type. +func GetOngoingRequests(entityType entity.Type) int64 { + return ongoingRequests[entityType].Load() +} + +// GetCompletedRequests gets the value of completed requests filtered by entity type and result. +func GetCompletedRequests(entityType entity.Type, result RequestResult) int64 { + return completedRequests[completedMetricsLabeling{entityType: entityType, result: result}].Load() +} + +// TrackStartedRequest tracks the request as started for the API metrics and +// injects a callback function to track the request as completed. +func TrackStartedRequest(r *http.Request, endpointType entity.Type) { + // Set the callback function to track the request as completed. + // Use sync.Once to ensure it can be called at most once. + var once sync.Once + callbackFunc := func(result RequestResult) { + once.Do(func() { + countCompletedRequest(endpointType, result) + }) + } + + request.SetCtxValue(r, request.CtxMetricsCallbackFunc, callbackFunc) + + countStartedRequest(endpointType) +} + +// UseMetricsCallback retrieves a callback function from the request context and calls it. +// The callback function is used to mark the request as completed for the API metrics. +func UseMetricsCallback(req *http.Request, result RequestResult) { + callback, err := request.GetCtxValue[func(RequestResult)](req.Context(), request.CtxMetricsCallbackFunc) + + if err == nil && callback != nil { + callback(result) + } +} diff --git a/lxd/metrics/metrics.go b/lxd/metrics/metrics.go index 4162591f64b1..b43acc77266c 100644 --- a/lxd/metrics/metrics.go +++ b/lxd/metrics/metrics.go @@ -95,6 +95,7 @@ func (m *MetricSet) String() string { GoGoroutines, GoHeapObjects, Instances, + APIOngoingRequests, } for _, metricType := range metricTypes { diff --git a/lxd/metrics/types.go b/lxd/metrics/types.go index 2402cadcc890..1080fa711a3a 100644 --- a/lxd/metrics/types.go +++ b/lxd/metrics/types.go @@ -16,10 +16,14 @@ type MetricSet struct { type MetricType int const ( - // CPUSecondsTotal represents the total CPU seconds used. - CPUSecondsTotal MetricType = iota + // APICompletedRequests represents the total number completed requests. + APICompletedRequests MetricType = iota + // APIOngoingRequests represents the number of requests currently being handled. + APIOngoingRequests // CPUs represents the total number of effective CPUs. CPUs + // CPUSecondsTotal represents the total CPU seconds used. + CPUSecondsTotal // DiskReadBytesTotal represents the read bytes for a disk. DiskReadBytesTotal // DiskReadsCompletedTotal represents the completed for a disk. @@ -34,12 +38,60 @@ const ( FilesystemFreeBytes // FilesystemSizeBytes represents the size in bytes of a filesystem. FilesystemSizeBytes + // GoAllocBytes represents the number of bytes allocated and still in use. + GoAllocBytes + // GoAllocBytesTotal represents the total number of bytes allocated, even if freed. + GoAllocBytesTotal + // GoBuckHashSysBytes represents the number of bytes used by the profiling bucket hash table. + GoBuckHashSysBytes + // GoFreesTotal represents the total number of frees. + GoFreesTotal + // GoGCSysBytes represents the number of bytes used for garbage collection system metadata. + GoGCSysBytes + // GoGoroutines represents the number of goroutines that currently exist.. + GoGoroutines + // GoHeapAllocBytes represents the number of heap bytes allocated and still in use. + GoHeapAllocBytes + // GoHeapIdleBytes represents the number of heap bytes waiting to be used. + GoHeapIdleBytes + // GoHeapInuseBytes represents the number of heap bytes that are in use. + GoHeapInuseBytes + // GoHeapObjects represents the number of allocated objects. + GoHeapObjects + // GoHeapReleasedBytes represents the number of heap bytes released to OS. + GoHeapReleasedBytes + // GoHeapSysBytes represents the number of heap bytes obtained from system. + GoHeapSysBytes + // GoLookupsTotal represents the total number of pointer lookups. + GoLookupsTotal + // GoMallocsTotal represents the total number of mallocs. + GoMallocsTotal + // GoMCacheInuseBytes represents the number of bytes in use by mcache structures. + GoMCacheInuseBytes + // GoMCacheSysBytes represents the number of bytes used for mcache structures obtained from system. + GoMCacheSysBytes + // GoMSpanInuseBytes represents the number of bytes in use by mspan structures. + GoMSpanInuseBytes + // GoMSpanSysBytes represents the number of bytes used for mspan structures obtained from system. + GoMSpanSysBytes + // GoNextGCBytes represents the number of heap bytes when next garbage collection will take place. + GoNextGCBytes + // GoOtherSysBytes represents the number of bytes used for other system allocations. + GoOtherSysBytes + // GoStackInuseBytes represents the number of bytes in use by the stack allocator. + GoStackInuseBytes + // GoStackSysBytes represents the number of bytes obtained from system for stack allocator. + GoStackSysBytes + // GoSysBytes represents the number of bytes obtained from system. + GoSysBytes + // Instances represents the instance count. + Instances // MemoryActiveAnonBytes represents the amount of anonymous memory on active LRU list. MemoryActiveAnonBytes - // MemoryActiveFileBytes represents the amount of file-backed memory on active LRU list. - MemoryActiveFileBytes // MemoryActiveBytes represents the amount of memory on active LRU list. MemoryActiveBytes + // MemoryActiveFileBytes represents the amount of file-backed memory on active LRU list. + MemoryActiveFileBytes // MemoryCachedBytes represents the amount of cached memory. MemoryCachedBytes // MemoryDirtyBytes represents the amount of memory waiting to get written back to the disk. @@ -50,18 +102,20 @@ const ( MemoryHugePagesTotalBytes // MemoryInactiveAnonBytes represents the amount of anonymous memory on inactive LRU list. MemoryInactiveAnonBytes - // MemoryInactiveFileBytes represents the amount of file-backed memory on inactive LRU list. - MemoryInactiveFileBytes // MemoryInactiveBytes represents the amount of memory on inactive LRU list. MemoryInactiveBytes + // MemoryInactiveFileBytes represents the amount of file-backed memory on inactive LRU list. + MemoryInactiveFileBytes // MemoryMappedBytes represents the amount of mapped memory. MemoryMappedBytes - //MemoryMemAvailableBytes represents the amount of available memory. + // MemoryMemAvailableBytes represents the amount of available memory. MemoryMemAvailableBytes // MemoryMemFreeBytes represents the amount of free memory. MemoryMemFreeBytes // MemoryMemTotalBytes represents the amount of used memory. MemoryMemTotalBytes + // MemoryOOMKillsTotal represents the amount of oom kills. + MemoryOOMKillsTotal // MemoryRSSBytes represents the amount of anonymous and swap cache memory. MemoryRSSBytes // MemoryShmemBytes represents the amount of cached filesystem data that is swap-backed. @@ -72,8 +126,6 @@ const ( MemoryUnevictableBytes // MemoryWritebackBytes represents the amount of memory queued for syncing to disk. MemoryWritebackBytes - // MemoryOOMKillsTotal represents the amount of oom kills. - MemoryOOMKillsTotal // NetworkReceiveBytesTotal represents the amount of received bytes on a given interface. NetworkReceiveBytesTotal // NetworkReceiveDropTotal represents the amount of received dropped bytes on a given interface. @@ -90,66 +142,20 @@ const ( NetworkTransmitErrsTotal // NetworkTransmitPacketsTotal represents the amount of transmitted packets on a given interface. NetworkTransmitPacketsTotal - // ProcsTotal represents the number of running processes. - ProcsTotal // OperationsTotal represents the number of running operations. OperationsTotal - // WarningsTotal represents the number of active warnings. - WarningsTotal + // ProcsTotal represents the number of running processes. + ProcsTotal // UptimeSeconds represents the daemon uptime in seconds. UptimeSeconds - // GoGoroutines represents the number of goroutines that currently exist.. - GoGoroutines - // GoAllocBytes represents the number of bytes allocated and still in use. - GoAllocBytes - // GoAllocBytesTotal represents the total number of bytes allocated, even if freed. - GoAllocBytesTotal - // GoSysBytes represents the number of bytes obtained from system. - GoSysBytes - // GoLookupsTotal represents the total number of pointer lookups. - GoLookupsTotal - // GoMallocsTotal represents the total number of mallocs. - GoMallocsTotal - // GoFreesTotal represents the total number of frees. - GoFreesTotal - // GoHeapAllocBytes represents the number of heap bytes allocated and still in use. - GoHeapAllocBytes - // GoHeapSysBytes represents the number of heap bytes obtained from system. - GoHeapSysBytes - // GoHeapIdleBytes represents the number of heap bytes waiting to be used. - GoHeapIdleBytes - // GoHeapInuseBytes represents the number of heap bytes that are in use. - GoHeapInuseBytes - // GoHeapReleasedBytes represents the number of heap bytes released to OS. - GoHeapReleasedBytes - // GoHeapObjects represents the number of allocated objects. - GoHeapObjects - // GoStackInuseBytes represents the number of bytes in use by the stack allocator. - GoStackInuseBytes - // GoStackSysBytes represents the number of bytes obtained from system for stack allocator. - GoStackSysBytes - // GoMSpanInuseBytes represents the number of bytes in use by mspan structures. - GoMSpanInuseBytes - // GoMSpanSysBytes represents the number of bytes used for mspan structures obtained from system. - GoMSpanSysBytes - // GoMCacheInuseBytes represents the number of bytes in use by mcache structures. - GoMCacheInuseBytes - // GoMCacheSysBytes represents the number of bytes used for mcache structures obtained from system. - GoMCacheSysBytes - // GoBuckHashSysBytes represents the number of bytes used by the profiling bucket hash table. - GoBuckHashSysBytes - // GoGCSysBytes represents the number of bytes used for garbage collection system metadata. - GoGCSysBytes - // GoOtherSysBytes represents the number of bytes used for other system allocations. - GoOtherSysBytes - // GoNextGCBytes represents the number of heap bytes when next garbage collection will take place. - GoNextGCBytes - // Instances represents the instance count. - Instances + // WarningsTotal represents the number of active warnings. + WarningsTotal ) // MetricNames associates a metric type to its name. var MetricNames = map[MetricType]string{ + APICompletedRequests: "lxd_api_requests_completed_total", + APIOngoingRequests: "lxd_api_requests_ongoing", CPUSecondsTotal: "lxd_cpu_seconds_total", CPUs: "lxd_cpu_effective_total", DiskReadBytesTotal: "lxd_disk_read_bytes_total", @@ -219,6 +225,8 @@ var MetricNames = map[MetricType]string{ // MetricHeaders represents the metric headers which contain help messages as specified by OpenMetrics. var MetricHeaders = map[MetricType]string{ + APICompletedRequests: "# HELP lxd_api_requests_completed_total The total number of completed API requests.", + APIOngoingRequests: "# HELP lxd_api_requests_ongoing The number of API requests currently being handled.", CPUSecondsTotal: "# HELP lxd_cpu_seconds_total The total number of CPU time used in seconds.", CPUs: "# HELP lxd_cpu_effective_total The total number of effective CPUs.", DiskReadBytesTotal: "# HELP lxd_disk_read_bytes_total The total number of bytes read.", diff --git a/lxd/network/acl/acl_load.go b/lxd/network/acl/acl_load.go index e92cde583c42..96e15f62a84b 100644 --- a/lxd/network/acl/acl_load.go +++ b/lxd/network/acl/acl_load.go @@ -145,11 +145,19 @@ func UsedBy(s *state.State, aclProjectName string, usageFunc func(ctx context.Co return err } + // Get all the profile devices. + profileDevicesByID, err := cluster.GetDevices(ctx, tx.Tx(), "profile") + if err != nil { + return err + } + for _, profile := range profiles { - profileDevices[profile.Name], err = cluster.GetProfileDevices(ctx, tx.Tx(), profile.ID) - if err != nil { - return err + devices := map[string]cluster.Device{} + for _, dev := range profileDevicesByID[profile.ID] { + devices[dev.Name] = dev } + + profileDevices[profile.Name] = devices } return nil diff --git a/lxd/network/acl/driver_common.go b/lxd/network/acl/driver_common.go index 4e2e0393c712..9d863a3c29a0 100644 --- a/lxd/network/acl/driver_common.go +++ b/lxd/network/acl/driver_common.go @@ -686,7 +686,7 @@ func (d *common) Update(config *api.NetworkACLPut, clientType request.ClientType return err } - err = notifier(func(client lxd.InstanceServer) error { + err = notifier(func(member db.NodeInfo, client lxd.InstanceServer) error { return client.UseProject(d.projectName).UpdateNetworkACL(d.info.Name, d.info.Writable(), "") }) if err != nil { @@ -789,7 +789,7 @@ func (d *common) GetLog(clientType request.ClientType) (string, error) { } mu := sync.Mutex{} - err = notifier(func(client lxd.InstanceServer) error { + err = notifier(func(member db.NodeInfo, client lxd.InstanceServer) error { // Get the entries. entries, err := client.UseProject(d.projectName).GetNetworkACLLogfile(d.info.Name) if err != nil { diff --git a/lxd/network/driver_bridge.go b/lxd/network/driver_bridge.go index f5243cf4c0b7..616453bd591c 100644 --- a/lxd/network/driver_bridge.go +++ b/lxd/network/driver_bridge.go @@ -737,7 +737,7 @@ func (n *bridge) Validate(config map[string]string) error { return err } - // Peform composite key checks after per-key validation. + // Perform composite key checks after per-key validation. // Validate DNS zone names. err = n.validateZoneNames(config) @@ -3644,7 +3644,7 @@ func (n *bridge) Leases(projectName string, clientType request.ClientType) ([]ap wg.Done() }() - err = notifier(func(client lxd.InstanceServer) error { + err = notifier(func(member db.NodeInfo, client lxd.InstanceServer) error { memberLeases, err := client.GetNetworkLeases(n.name) if err != nil { return err diff --git a/lxd/network/driver_common.go b/lxd/network/driver_common.go index fe8acf9115bd..03063a0a7731 100644 --- a/lxd/network/driver_common.go +++ b/lxd/network/driver_common.go @@ -63,6 +63,7 @@ const ( subnetUsageNetworkLoadBalancer subnetUsageInstance subnetUsageProxy + subnetUsageVolatileIP ) // externalSubnetUsage represents usage of a subnet by a network or NIC. @@ -382,7 +383,7 @@ func (n *common) update(applyNetwork api.NetworkPut, targetNode string, clientTy sendNetwork.Config[k] = v } - err = notifier(func(client lxd.InstanceServer) error { + err = notifier(func(member db.NodeInfo, client lxd.InstanceServer) error { return client.UseProject(n.project).UpdateNetwork(n.name, sendNetwork, "") }) if err != nil { diff --git a/lxd/network/driver_ovn.go b/lxd/network/driver_ovn.go index d97b338f4721..f453585eacad 100644 --- a/lxd/network/driver_ovn.go +++ b/lxd/network/driver_ovn.go @@ -9,6 +9,7 @@ import ( "net" "net/http" "os" + "slices" "sort" "strconv" "strings" @@ -407,7 +408,7 @@ func (n *ovn) Validate(config map[string]string) error { // type: string // condition: standard mode // defaultdesc: initial value on creation: `auto` - // shortdesc: IPv4 address for the bridge + // shortdesc: IPv4 address for the OVN network "ipv4.address": validate.Optional(func(value string) error { if validate.IsOneOf("none", "auto")(value) == nil { return nil @@ -431,7 +432,7 @@ func (n *ovn) Validate(config map[string]string) error { // type: string // condition: standard mode // defaultdesc: initial value on creation: `auto` - // shortdesc: IPv6 address for the bridge + // shortdesc: IPv6 address for the OVN network "ipv6.address": validate.Optional(func(value string) error { if validate.IsOneOf("none", "auto")(value) == nil { return nil @@ -588,7 +589,7 @@ func (n *ovn) Validate(config map[string]string) error { return err } - // Peform composite key checks after per-key validation. + // Perform composite key checks after per-key validation. // Validate DNS zone names. err = n.validateZoneNames(config) @@ -1244,7 +1245,16 @@ func (n *ovn) allocateUplinkPortIPs(uplinkNet Network, routerMAC net.HardwareAdd return fmt.Errorf(`Missing required "ipv4.ovn.ranges" config key on uplink network`) } - ipRanges, err := shared.ParseIPRanges(uplinkNetConf["ipv4.ovn.ranges"], uplinkNet.DHCPv4Subnet()) + dhcpSubnet := uplinkNet.DHCPv4Subnet() + allowedNets := []*net.IPNet{} + + if dhcpSubnet != nil { + allowedNets = append(allowedNets, dhcpSubnet) + } else { + allowedNets = append(allowedNets, uplinkIPv4Net) + } + + ipRanges, err := shared.ParseIPRanges(uplinkNetConf["ipv4.ovn.ranges"], allowedNets...) if err != nil { return fmt.Errorf("Failed to parse uplink IPv4 OVN ranges: %w", err) } @@ -1260,7 +1270,16 @@ func (n *ovn) allocateUplinkPortIPs(uplinkNet Network, routerMAC net.HardwareAdd if uplinkIPv6Net != nil && routerExtPortIPv6 == nil { // If IPv6 OVN ranges are specified by the uplink, allocate from them. if uplinkNetConf["ipv6.ovn.ranges"] != "" { - ipRanges, err := shared.ParseIPRanges(uplinkNetConf["ipv6.ovn.ranges"], uplinkNet.DHCPv6Subnet()) + dhcpSubnet := uplinkNet.DHCPv6Subnet() + allowedNets := []*net.IPNet{} + + if dhcpSubnet != nil { + allowedNets = append(allowedNets, dhcpSubnet) + } else { + allowedNets = append(allowedNets, uplinkIPv6Net) + } + + ipRanges, err := shared.ParseIPRanges(uplinkNetConf["ipv6.ovn.ranges"], allowedNets...) if err != nil { return fmt.Errorf("Failed to parse uplink IPv6 OVN ranges: %w", err) } @@ -4460,15 +4479,32 @@ func (n *ovn) ovnNetworkExternalSubnets(ovnProjectNetworksWithOurUplink map[stri }) } + subnetSize := 128 + if keyPrefix == "ipv4" { + subnetSize = 32 + } + // Find any external subnets used for network SNAT. if netInfo.Config[fmt.Sprintf("%s.nat.address", keyPrefix)] != "" { key := fmt.Sprintf("%s.nat.address", keyPrefix) - subnetSize := 128 - if keyPrefix == "ipv4" { - subnetSize = 32 + _, ipNet, err := net.ParseCIDR(fmt.Sprintf("%s/%d", netInfo.Config[key], subnetSize)) + if err != nil { + return nil, fmt.Errorf("Failed parsing %q of %q in project %q: %w", key, netInfo.Name, netProject, err) } + externalSubnets = append(externalSubnets, externalSubnetUsage{ + subnet: *ipNet, + networkProject: netProject, + networkName: netInfo.Name, + usageType: subnetUsageNetworkSNAT, + }) + } + + // Find the volatile IP for the network. + if netInfo.Config[fmt.Sprintf("volatile.network.%s.address", keyPrefix)] != "" { + key := fmt.Sprintf("volatile.network.%s.address", keyPrefix) + _, ipNet, err := net.ParseCIDR(fmt.Sprintf("%s/%d", netInfo.Config[key], subnetSize)) if err != nil { return nil, fmt.Errorf("Failed parsing %q of %q in project %q: %w", key, netInfo.Name, netProject, err) @@ -4478,7 +4514,7 @@ func (n *ovn) ovnNetworkExternalSubnets(ovnProjectNetworksWithOurUplink map[stri subnet: *ipNet, networkProject: netProject, networkName: netInfo.Name, - usageType: subnetUsageNetworkSNAT, + usageType: subnetUsageVolatileIP, }) } } @@ -4775,31 +4811,6 @@ func (n *ovn) ForwardCreate(forward api.NetworkForwardsPost, clientType request. return nil, err } - externalSubnetsInUse, err := n.getExternalSubnetInUse(n.config["network"]) - if err != nil { - return nil, err - } - - checkAddressNotInUse := func(netip *net.IPNet) (bool, error) { - // Check the listen address subnet doesn't fall within any existing OVN network external subnets. - for _, externalSubnetUser := range externalSubnetsInUse { - // Check if usage is from our own network. - if externalSubnetUser.networkProject == n.project && externalSubnetUser.networkName == n.name { - // Skip checking conflict with our own network's subnet or SNAT address. - // But do not allow other conflict with other usage types within our own network. - if externalSubnetUser.usageType == subnetUsageNetwork || externalSubnetUser.usageType == subnetUsageNetworkSNAT { - continue - } - } - - if SubnetContains(&externalSubnetUser.subnet, netip) || SubnetContains(netip, &externalSubnetUser.subnet) { - return false, nil - } - } - - return true, nil - } - // We're auto-allocating the external IP address if the given listen address is unspecified. if listenAddressNet.IP.IsUnspecified() { ipVersion := 4 @@ -4810,7 +4821,7 @@ func (n *ovn) ForwardCreate(forward api.NetworkForwardsPost, clientType request. ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - listenAddressNet, err = n.randomExternalAddress(ctx, ipVersion, uplinkRoutes, projectRestrictedSubnets, checkAddressNotInUse) + listenAddressNet, err = n.randomExternalAddress(ctx, ipVersion, uplinkRoutes, projectRestrictedSubnets, n.checkAddressNotInUse) if err != nil { return nil, fmt.Errorf("Failed to allocate an IPv%d address: %w", ipVersion, err) } @@ -4824,7 +4835,7 @@ func (n *ovn) ForwardCreate(forward api.NetworkForwardsPost, clientType request. return nil, err } - isValid, err := checkAddressNotInUse(listenAddressNet) + isValid, err := n.checkAddressNotInUse(listenAddressNet) if err != nil { return nil, err } else if !isValid { @@ -4878,7 +4889,7 @@ func (n *ovn) ForwardCreate(forward api.NetworkForwardsPost, clientType request. return nil, err } - err = notifier(func(client lxd.InstanceServer) error { + err = notifier(func(member db.NodeInfo, client lxd.InstanceServer) error { return client.UseProject(n.project).CreateNetworkForward(n.name, forward) }) if err != nil { @@ -4983,7 +4994,7 @@ func (n *ovn) ForwardUpdate(listenAddress string, req api.NetworkForwardPut, cli return err } - err = notifier(func(client lxd.InstanceServer) error { + err = notifier(func(member db.NodeInfo, client lxd.InstanceServer) error { return client.UseProject(n.project).UpdateNetworkForward(n.name, curForward.ListenAddress, req, "") }) if err != nil { @@ -5043,7 +5054,7 @@ func (n *ovn) ForwardDelete(listenAddress string, clientType request.ClientType) return err } - err = notifier(func(client lxd.InstanceServer) error { + err = notifier(func(member db.NodeInfo, client lxd.InstanceServer) error { return client.UseProject(n.project).DeleteNetworkForward(n.name, forward.ListenAddress) }) if err != nil { @@ -5150,31 +5161,6 @@ func (n *ovn) LoadBalancerCreate(loadBalancer api.NetworkLoadBalancersPost, clie return nil, err } - externalSubnetsInUse, err := n.getExternalSubnetInUse(n.config["network"]) - if err != nil { - return nil, err - } - - checkAddressNotInUse := func(netip *net.IPNet) (bool, error) { - // Check the listen address subnet doesn't fall within any existing OVN network external subnets. - for _, externalSubnetUser := range externalSubnetsInUse { - // Check if usage is from our own network. - if externalSubnetUser.networkProject == n.project && externalSubnetUser.networkName == n.name { - // Skip checking conflict with our own network's subnet or SNAT address. - // But do not allow other conflict with other usage types within our own network. - if externalSubnetUser.usageType == subnetUsageNetwork || externalSubnetUser.usageType == subnetUsageNetworkSNAT { - continue - } - } - - if SubnetContains(&externalSubnetUser.subnet, netip) || SubnetContains(netip, &externalSubnetUser.subnet) { - return false, nil - } - } - - return true, nil - } - // We're auto-allocating the external IP address if the given listen address is unspecified. if listenAddressNet.IP.IsUnspecified() { ipVersion := 4 @@ -5185,7 +5171,7 @@ func (n *ovn) LoadBalancerCreate(loadBalancer api.NetworkLoadBalancersPost, clie ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - listenAddressNet, err = n.randomExternalAddress(ctx, ipVersion, uplinkRoutes, projectRestrictedSubnets, checkAddressNotInUse) + listenAddressNet, err = n.randomExternalAddress(ctx, ipVersion, uplinkRoutes, projectRestrictedSubnets, n.checkAddressNotInUse) if err != nil { return nil, fmt.Errorf("Failed to allocate an IPv%d address: %w", ipVersion, err) } @@ -5199,7 +5185,7 @@ func (n *ovn) LoadBalancerCreate(loadBalancer api.NetworkLoadBalancersPost, clie return nil, err } - isValid, err := checkAddressNotInUse(listenAddressNet) + isValid, err := n.checkAddressNotInUse(listenAddressNet) if err != nil { return nil, err } else if !isValid { @@ -5253,7 +5239,7 @@ func (n *ovn) LoadBalancerCreate(loadBalancer api.NetworkLoadBalancersPost, clie return nil, err } - err = notifier(func(client lxd.InstanceServer) error { + err = notifier(func(member db.NodeInfo, client lxd.InstanceServer) error { return client.UseProject(n.project).CreateNetworkLoadBalancer(n.name, loadBalancer) }) if err != nil { @@ -5359,7 +5345,7 @@ func (n *ovn) LoadBalancerUpdate(listenAddress string, req api.NetworkLoadBalanc return err } - err = notifier(func(client lxd.InstanceServer) error { + err = notifier(func(member db.NodeInfo, client lxd.InstanceServer) error { return client.UseProject(n.project).UpdateNetworkLoadBalancer(n.name, curLoadBalancer.ListenAddress, req, "") }) if err != nil { @@ -5419,7 +5405,7 @@ func (n *ovn) LoadBalancerDelete(listenAddress string, clientType request.Client return err } - err = notifier(func(client lxd.InstanceServer) error { + err = notifier(func(member db.NodeInfo, client lxd.InstanceServer) error { return client.UseProject(n.project).DeleteNetworkLoadBalancer(n.name, forward.ListenAddress) }) if err != nil { @@ -5954,3 +5940,29 @@ func (n *ovn) forPeers(f func(targetOVNNet *ovn) error) error { return nil } + +// checkAddressNotInUse checks that a given network subnet does not fall within +// any existing OVN network external subnets on the same uplink. +func (n *ovn) checkAddressNotInUse(netip *net.IPNet) (bool, error) { + externalSubnetsInUse, err := n.getExternalSubnetInUse(n.config["network"]) + if err != nil { + return false, err + } + + for _, externalSubnetUser := range externalSubnetsInUse { + // Check if usage is from our own network. + if externalSubnetUser.networkProject == n.project && externalSubnetUser.networkName == n.name { + // Skip checking conflict with our own network's subnet, SNAT address, or volatile IP. + // But do not allow other conflict with other usage types within our own network. + if slices.Contains([]subnetUsageType{subnetUsageNetwork, subnetUsageNetworkSNAT, subnetUsageVolatileIP}, externalSubnetUser.usageType) { + continue + } + } + + if SubnetContains(&externalSubnetUser.subnet, netip) || SubnetContains(netip, &externalSubnetUser.subnet) { + return false, nil + } + } + + return true, nil +} diff --git a/lxd/network/network_utils.go b/lxd/network/network_utils.go index 159a12e7e1bc..8b8e5ae61657 100644 --- a/lxd/network/network_utils.go +++ b/lxd/network/network_utils.go @@ -188,17 +188,19 @@ func UsedBy(s *state.State, networkProjectName string, networkID int64, networkN // Look for profiles. Next cheapest to do. err = s.DB.Cluster.Transaction(context.TODO(), func(ctx context.Context, tx *db.ClusterTx) error { + // Get all profiles profiles, err := cluster.GetProfiles(ctx, tx.Tx()) if err != nil { return err } - for _, profile := range profiles { - profileDevices, err := cluster.GetProfileDevices(ctx, tx.Tx(), profile.ID) - if err != nil { - return err - } + // Get all the profile devices. + profileDevices, err := cluster.GetDevices(ctx, tx.Tx(), "profile") + if err != nil { + return err + } + for _, profile := range profiles { profileProject, err := cluster.GetProject(ctx, tx.Tx(), profile.Project) if err != nil { return err @@ -209,7 +211,12 @@ func UsedBy(s *state.State, networkProjectName string, networkID int64, networkN return err } - inUse, err := usedByProfileDevices(profileDevices, apiProfileProject, networkProjectName, networkName, networkType) + devices := map[string]cluster.Device{} + for _, dev := range profileDevices[profile.ID] { + devices[dev.Name] = dev + } + + inUse, err := usedByProfileDevices(devices, apiProfileProject, networkProjectName, networkName, networkType) if err != nil { return err } diff --git a/lxd/network/zone/zone.go b/lxd/network/zone/zone.go index 924de2689395..18d61f5e0ce2 100644 --- a/lxd/network/zone/zone.go +++ b/lxd/network/zone/zone.go @@ -297,7 +297,7 @@ func (d *zone) Update(config *api.NetworkZonePut, clientType request.ClientType) return err } - err = notifier(func(client lxd.InstanceServer) error { + err = notifier(func(member db.NodeInfo, client lxd.InstanceServer) error { return client.UseProject(d.projectName).UpdateNetworkZone(d.info.Name, d.info.Writable(), "") }) if err != nil { diff --git a/lxd/network_acls.go b/lxd/network_acls.go index aea941b34be5..ea446ba2f7cf 100644 --- a/lxd/network_acls.go +++ b/lxd/network_acls.go @@ -27,14 +27,16 @@ import ( ) var networkACLsCmd = APIEndpoint{ - Path: "network-acls", + Path: "network-acls", + MetricsType: entity.TypeNetwork, Get: APIEndpointAction{Handler: networkACLsGet, AccessHandler: allowProjectResourceList}, Post: APIEndpointAction{Handler: networkACLsPost, AccessHandler: allowPermission(entity.TypeProject, auth.EntitlementCanCreateNetworkACLs)}, } var networkACLCmd = APIEndpoint{ - Path: "network-acls/{name}", + Path: "network-acls/{name}", + MetricsType: entity.TypeNetwork, Delete: APIEndpointAction{Handler: networkACLDelete, AccessHandler: allowPermission(entity.TypeNetworkACL, auth.EntitlementCanDelete, "name")}, Get: APIEndpointAction{Handler: networkACLGet, AccessHandler: allowPermission(entity.TypeNetworkACL, auth.EntitlementCanView, "name")}, @@ -44,7 +46,8 @@ var networkACLCmd = APIEndpoint{ } var networkACLLogCmd = APIEndpoint{ - Path: "network-acls/{name}/log", + Path: "network-acls/{name}/log", + MetricsType: entity.TypeNetwork, Get: APIEndpointAction{Handler: networkACLLogGet, AccessHandler: allowPermission(entity.TypeNetworkACL, auth.EntitlementCanView, "name")}, } @@ -644,5 +647,5 @@ func networkACLLogGet(d *Daemon, r *http.Request) response.Response { ent.FileModified = time.Now() ent.FileSize = int64(len(log)) - return response.FileResponse(r, []response.FileResponseEntry{ent}, nil) + return response.FileResponse([]response.FileResponseEntry{ent}, nil) } diff --git a/lxd/network_allocations.go b/lxd/network_allocations.go index 01f830226885..cd1e0427806c 100644 --- a/lxd/network_allocations.go +++ b/lxd/network_allocations.go @@ -23,7 +23,8 @@ import ( ) var networkAllocationsCmd = APIEndpoint{ - Path: "network-allocations", + Path: "network-allocations", + MetricsType: entity.TypeNetwork, Get: APIEndpointAction{Handler: networkAllocationsGet, AccessHandler: allowProjectResourceList}, } diff --git a/lxd/network_forwards.go b/lxd/network_forwards.go index 21851c4bb038..a48bdb040e6d 100644 --- a/lxd/network_forwards.go +++ b/lxd/network_forwards.go @@ -19,18 +19,21 @@ import ( "github.com/canonical/lxd/lxd/response" "github.com/canonical/lxd/lxd/util" "github.com/canonical/lxd/shared/api" + "github.com/canonical/lxd/shared/entity" "github.com/canonical/lxd/shared/version" ) var networkForwardsCmd = APIEndpoint{ - Path: "networks/{networkName}/forwards", + Path: "networks/{networkName}/forwards", + MetricsType: entity.TypeNetwork, Get: APIEndpointAction{Handler: networkForwardsGet, AccessHandler: networkAccessHandler(auth.EntitlementCanView)}, Post: APIEndpointAction{Handler: networkForwardsPost, AccessHandler: networkAccessHandler(auth.EntitlementCanEdit)}, } var networkForwardCmd = APIEndpoint{ - Path: "networks/{networkName}/forwards/{listenAddress}", + Path: "networks/{networkName}/forwards/{listenAddress}", + MetricsType: entity.TypeNetwork, Delete: APIEndpointAction{Handler: networkForwardDelete, AccessHandler: networkAccessHandler(auth.EntitlementCanEdit)}, Get: APIEndpointAction{Handler: networkForwardGet, AccessHandler: networkAccessHandler(auth.EntitlementCanView)}, diff --git a/lxd/network_load_balancers.go b/lxd/network_load_balancers.go index 77f5c275a90e..002cda621036 100644 --- a/lxd/network_load_balancers.go +++ b/lxd/network_load_balancers.go @@ -19,18 +19,21 @@ import ( "github.com/canonical/lxd/lxd/response" "github.com/canonical/lxd/lxd/util" "github.com/canonical/lxd/shared/api" + "github.com/canonical/lxd/shared/entity" "github.com/canonical/lxd/shared/version" ) var networkLoadBalancersCmd = APIEndpoint{ - Path: "networks/{networkName}/load-balancers", + Path: "networks/{networkName}/load-balancers", + MetricsType: entity.TypeNetwork, Get: APIEndpointAction{Handler: networkLoadBalancersGet, AccessHandler: networkAccessHandler(auth.EntitlementCanView)}, Post: APIEndpointAction{Handler: networkLoadBalancersPost, AccessHandler: networkAccessHandler(auth.EntitlementCanEdit)}, } var networkLoadBalancerCmd = APIEndpoint{ - Path: "networks/{networkName}/load-balancers/{listenAddress}", + Path: "networks/{networkName}/load-balancers/{listenAddress}", + MetricsType: entity.TypeNetwork, Delete: APIEndpointAction{Handler: networkLoadBalancerDelete, AccessHandler: networkAccessHandler(auth.EntitlementCanEdit)}, Get: APIEndpointAction{Handler: networkLoadBalancerGet, AccessHandler: networkAccessHandler(auth.EntitlementCanView)}, diff --git a/lxd/network_peer.go b/lxd/network_peer.go index 52c4e5633664..a9cb9fcba6af 100644 --- a/lxd/network_peer.go +++ b/lxd/network_peer.go @@ -18,18 +18,21 @@ import ( "github.com/canonical/lxd/lxd/response" "github.com/canonical/lxd/lxd/util" "github.com/canonical/lxd/shared/api" + "github.com/canonical/lxd/shared/entity" "github.com/canonical/lxd/shared/version" ) var networkPeersCmd = APIEndpoint{ - Path: "networks/{networkName}/peers", + Path: "networks/{networkName}/peers", + MetricsType: entity.TypeNetwork, Get: APIEndpointAction{Handler: networkPeersGet, AccessHandler: networkAccessHandler(auth.EntitlementCanView)}, Post: APIEndpointAction{Handler: networkPeersPost, AccessHandler: networkAccessHandler(auth.EntitlementCanEdit)}, } var networkPeerCmd = APIEndpoint{ - Path: "networks/{networkName}/peers/{peerName}", + Path: "networks/{networkName}/peers/{peerName}", + MetricsType: entity.TypeNetwork, Delete: APIEndpointAction{Handler: networkPeerDelete, AccessHandler: networkAccessHandler(auth.EntitlementCanEdit)}, Get: APIEndpointAction{Handler: networkPeerGet, AccessHandler: networkAccessHandler(auth.EntitlementCanView)}, diff --git a/lxd/network_zones.go b/lxd/network_zones.go index c8921f067a69..075028f740d3 100644 --- a/lxd/network_zones.go +++ b/lxd/network_zones.go @@ -25,14 +25,16 @@ import ( ) var networkZonesCmd = APIEndpoint{ - Path: "network-zones", + Path: "network-zones", + MetricsType: entity.TypeNetwork, Get: APIEndpointAction{Handler: networkZonesGet, AccessHandler: allowProjectResourceList}, Post: APIEndpointAction{Handler: networkZonesPost, AccessHandler: allowPermission(entity.TypeProject, auth.EntitlementCanCreateNetworkZones)}, } var networkZoneCmd = APIEndpoint{ - Path: "network-zones/{zone}", + Path: "network-zones/{zone}", + MetricsType: entity.TypeNetwork, Delete: APIEndpointAction{Handler: networkZoneDelete, AccessHandler: networkZoneAccessHandler(auth.EntitlementCanDelete)}, Get: APIEndpointAction{Handler: networkZoneGet, AccessHandler: networkZoneAccessHandler(auth.EntitlementCanView)}, diff --git a/lxd/network_zones_records.go b/lxd/network_zones_records.go index 6227f91d6d9d..91760e35c564 100644 --- a/lxd/network_zones_records.go +++ b/lxd/network_zones_records.go @@ -15,18 +15,21 @@ import ( "github.com/canonical/lxd/lxd/response" "github.com/canonical/lxd/lxd/util" "github.com/canonical/lxd/shared/api" + "github.com/canonical/lxd/shared/entity" "github.com/canonical/lxd/shared/version" ) var networkZoneRecordsCmd = APIEndpoint{ - Path: "network-zones/{zone}/records", + Path: "network-zones/{zone}/records", + MetricsType: entity.TypeNetwork, Get: APIEndpointAction{Handler: networkZoneRecordsGet, AccessHandler: networkZoneAccessHandler(auth.EntitlementCanView)}, Post: APIEndpointAction{Handler: networkZoneRecordsPost, AccessHandler: networkZoneAccessHandler(auth.EntitlementCanEdit)}, } var networkZoneRecordCmd = APIEndpoint{ - Path: "network-zones/{zone}/records/{name}", + Path: "network-zones/{zone}/records/{name}", + MetricsType: entity.TypeNetwork, Delete: APIEndpointAction{Handler: networkZoneRecordDelete, AccessHandler: networkZoneAccessHandler(auth.EntitlementCanEdit)}, Get: APIEndpointAction{Handler: networkZoneRecordGet, AccessHandler: networkZoneAccessHandler(auth.EntitlementCanView)}, diff --git a/lxd/networks.go b/lxd/networks.go index 7eac1ca082ae..223c62dd1636 100644 --- a/lxd/networks.go +++ b/lxd/networks.go @@ -45,14 +45,16 @@ import ( var networkCreateLock sync.Mutex var networksCmd = APIEndpoint{ - Path: "networks", + Path: "networks", + MetricsType: entity.TypeNetwork, Get: APIEndpointAction{Handler: networksGet, AccessHandler: allowProjectResourceList}, Post: APIEndpointAction{Handler: networksPost, AccessHandler: allowPermission(entity.TypeProject, auth.EntitlementCanCreateNetworks)}, } var networkCmd = APIEndpoint{ - Path: "networks/{networkName}", + Path: "networks/{networkName}", + MetricsType: entity.TypeNetwork, Delete: APIEndpointAction{Handler: networkDelete, AccessHandler: networkAccessHandler(auth.EntitlementCanDelete)}, Get: APIEndpointAction{Handler: networkGet, AccessHandler: networkAccessHandler(auth.EntitlementCanView)}, @@ -62,13 +64,15 @@ var networkCmd = APIEndpoint{ } var networkLeasesCmd = APIEndpoint{ - Path: "networks/{networkName}/leases", + Path: "networks/{networkName}/leases", + MetricsType: entity.TypeNetwork, Get: APIEndpointAction{Handler: networkLeasesGet, AccessHandler: networkAccessHandler(auth.EntitlementCanView)}, } var networkStateCmd = APIEndpoint{ - Path: "networks/{networkName}/state", + Path: "networks/{networkName}/state", + MetricsType: entity.TypeNetwork, Get: APIEndpointAction{Handler: networkStateGet, AccessHandler: networkAccessHandler(auth.EntitlementCanView)}, } @@ -237,11 +241,17 @@ func networksGet(d *Daemon, r *http.Request) response.Response { recursion := util.IsRecursionRequest(r) - var networkNames []string + // networks holds the network names of the managed and unmanaged networks. They are in two different slices so that + // we can perform access control checks differently. + var networks [2][]string + const ( + managed = iota + unmanaged + ) err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error { // Get list of managed networks (that may or may not have network interfaces on the host). - networkNames, err = tx.GetNetworks(ctx, effectiveProjectName) + networks[managed], err = tx.GetNetworks(ctx, effectiveProjectName) return err }) @@ -249,8 +259,18 @@ func networksGet(d *Daemon, r *http.Request) response.Response { return response.InternalError(err) } - // Get list of actual network interfaces on the host as well if the effective project is Default. + // Get list of actual network interfaces on the host if the effective project is default and the caller has permission. + var getUnmanagedNetworks bool if effectiveProjectName == api.ProjectDefaultName { + err := s.Authorizer.CheckPermission(r.Context(), entity.ServerURL(), auth.EntitlementCanViewUnmanagedNetworks) + if err == nil { + getUnmanagedNetworks = true + } else if !auth.IsDeniedError(err) { + return response.SmartError(err) + } + } + + if getUnmanagedNetworks { ifaces, err := net.Interfaces() if err != nil { return response.InternalError(err) @@ -263,12 +283,13 @@ func networksGet(d *Daemon, r *http.Request) response.Response { } // Append to the list of networks if a managed network of same name doesn't exist. - if !shared.ValueInSlice(iface.Name, networkNames) { - networkNames = append(networkNames, iface.Name) + if !shared.ValueInSlice(iface.Name, networks[managed]) { + networks[unmanaged] = append(networks[unmanaged], iface.Name) } } } + // Permission checker works for managed networks only, since they are present in the database. userHasPermission, err := s.Authorizer.GetPermissionChecker(r.Context(), auth.EntitlementCanView, entity.TypeNetwork) if err != nil { return response.InternalError(err) @@ -276,20 +297,23 @@ func networksGet(d *Daemon, r *http.Request) response.Response { resultString := []string{} resultMap := []api.Network{} - for _, networkName := range networkNames { - if !userHasPermission(entity.NetworkURL(requestProjectName, networkName)) { - continue - } - - if !recursion { - resultString = append(resultString, fmt.Sprintf("/%s/networks/%s", version.APIVersion, networkName)) - } else { - net, err := doNetworkGet(s, r, s.ServerClustered, requestProjectName, reqProject.Config, networkName) - if err != nil { + for kind, networkNames := range networks { + for _, networkName := range networkNames { + // Filter out managed networks that the caller doesn't have permission to view. + if kind == managed && !userHasPermission(entity.NetworkURL(requestProjectName, networkName)) { continue } - resultMap = append(resultMap, net) + if !recursion { + resultString = append(resultString, fmt.Sprintf("/%s/networks/%s", version.APIVersion, networkName)) + } else { + net, err := doNetworkGet(s, r, s.ServerClustered, requestProjectName, reqProject.Config, networkName) + if err != nil { + continue + } + + resultMap = append(resultMap, net) + } } } @@ -514,6 +538,14 @@ func networksPost(d *Daemon, r *http.Request) response.Response { if err != nil { return response.SmartError(err) } + + n, err := network.LoadByName(s, projectName, req.Name) + if err != nil { + return response.SmartError(fmt.Errorf("Failed loading network: %w", err)) + } + + requestor := request.CreateRequestor(r) + s.Events.SendLifecycle(projectName, lifecycle.NetworkCreated.Event(n, requestor, nil)) } err = networksPostCluster(s, projectName, netInfo, req, clientType, netType) @@ -692,12 +724,7 @@ func networksPostCluster(s *state.State, projectName string, netInfo *api.Networ } // Notify other nodes to create the network. - err = notifier(func(client lxd.InstanceServer) error { - server, _, err := client.GetServer() - if err != nil { - return err - } - + err = notifier(func(member db.NodeInfo, client lxd.InstanceServer) error { // Clone the network config for this node so we don't modify it and potentially end up sending // this node's config to another node. nodeConfig := make(map[string]string, len(netConfig)) @@ -706,7 +733,7 @@ func networksPostCluster(s *state.State, projectName string, netInfo *api.Networ } // Merge node specific config items into global config. - for key, value := range nodeConfigs[server.Environment.ServerName] { + for key, value := range nodeConfigs[member.Name] { nodeConfig[key] = value } @@ -725,7 +752,7 @@ func networksPostCluster(s *state.State, projectName string, netInfo *api.Networ return err } - logger.Debug("Created network on cluster member", logger.Ctx{"project": n.Project(), "network": n.Name(), "member": server.Environment.ServerName, "config": nodeReq.Config}) + logger.Debug("Created network on cluster member", logger.Ctx{"project": n.Project(), "network": n.Name(), "member": member.Name, "config": nodeReq.Config}) return nil }) @@ -1068,7 +1095,7 @@ func networkDelete(d *Daemon, r *http.Request) response.Response { return response.SmartError(err) } - err = notifier(func(client lxd.InstanceServer) error { + err = notifier(func(member db.NodeInfo, client lxd.InstanceServer) error { return client.UseProject(n.Project()).DeleteNetwork(n.Name()) }) if err != nil { @@ -1521,20 +1548,9 @@ func networkLeasesGet(d *Daemon, r *http.Request) response.Response { return response.SyncResponse(true, leases) } -func networkStartup(s *state.State) error { +func networkStartup(stateFunc func() *state.State) error { var err error - // Get a list of projects. - var projectNames []string - - err = s.DB.Cluster.Transaction(context.TODO(), func(ctx context.Context, tx *db.ClusterTx) error { - projectNames, err = dbCluster.GetProjectNames(ctx, tx.Tx()) - return err - }) - if err != nil { - return fmt.Errorf("Failed to load projects: %w", err) - } - // Build a list of networks to initialise, keyed by project and network name. const networkPriorityStandalone = 0 // Start networks not dependent on any other network first. const networkPriorityPhysical = 1 // Start networks dependent on physical interfaces second. @@ -1545,38 +1561,14 @@ func networkStartup(s *state.State) error { networkPriorityLogical: make(map[network.ProjectNetwork]struct{}), } - err = s.DB.Cluster.Transaction(context.TODO(), func(ctx context.Context, tx *db.ClusterTx) error { - for _, projectName := range projectNames { - networkNames, err := tx.GetCreatedNetworkNamesByProject(ctx, projectName) - if err != nil { - return fmt.Errorf("Failed to load networks for project %q: %w", projectName, err) - } - - for _, networkName := range networkNames { - pn := network.ProjectNetwork{ - ProjectName: projectName, - NetworkName: networkName, - } - - // Assume all networks are networkPriorityStandalone initially. - initNetworks[networkPriorityStandalone][pn] = struct{}{} - } - } - - return nil - }) - if err != nil { - return err - } - loadedNetworks := make(map[network.ProjectNetwork]network.Network) - initNetwork := func(n network.Network, priority int) error { + initNetwork := func(s *state.State, n network.Network, priority int) error { err = n.Start() if err != nil { err = fmt.Errorf("Failed starting: %w", err) - _ = s.DB.Cluster.Transaction(context.TODO(), func(ctx context.Context, tx *db.ClusterTx) error { + _ = s.DB.Cluster.Transaction(context.Background(), func(ctx context.Context, tx *db.ClusterTx) error { return tx.UpsertWarningLocalNode(ctx, n.Project(), entity.TypeNetwork, int(n.ID()), warningtype.NetworkUnvailable, err.Error()) }) @@ -1598,7 +1590,7 @@ func networkStartup(s *state.State) error { return nil } - loadAndInitNetwork := func(pn network.ProjectNetwork, priority int, firstPass bool) error { + loadAndInitNetwork := func(s *state.State, pn network.ProjectNetwork, priority int, firstPass bool) error { var err error var n network.Network @@ -1643,7 +1635,7 @@ func networkStartup(s *state.State) error { return nil } - err = initNetwork(n, priority) + err = initNetwork(s, n, priority) if err != nil { return err } @@ -1651,31 +1643,70 @@ func networkStartup(s *state.State) error { return nil } - // Try initializing networks in priority order. - for priority := range initNetworks { - for pn := range initNetworks[priority] { - err := loadAndInitNetwork(pn, priority, true) + remainingNetworksCount := func() int { + remainingNetworks := 0 + for _, projectNetworks := range initNetworks { + remainingNetworks += len(projectNetworks) + } + + return remainingNetworks + } + + { + // Perform first pass to start networks. + // Local scope for state variable during initial pass of setting up networks. + s := stateFunc() + err = s.DB.Cluster.Transaction(s.ShutdownCtx, func(ctx context.Context, tx *db.ClusterTx) error { + projectNames, err := dbCluster.GetProjectNames(ctx, tx.Tx()) if err != nil { - logger.Error("Failed initializing network", logger.Ctx{"project": pn.ProjectName, "network": pn.NetworkName, "err": err}) + return fmt.Errorf("Failed to load projects: %w", err) + } - continue + for _, projectName := range projectNames { + networkNames, err := tx.GetCreatedNetworkNamesByProject(ctx, projectName) + if err != nil { + return fmt.Errorf("Failed to load networks for project %q: %w", projectName, err) + } + + for _, networkName := range networkNames { + pn := network.ProjectNetwork{ + ProjectName: projectName, + NetworkName: networkName, + } + + // Assume all networks are networkPriorityStandalone initially. + initNetworks[networkPriorityStandalone][pn] = struct{}{} + } } + + return nil + }) + if err != nil { + return err } - } - loadedNetworks = nil // Don't store loaded networks after first pass. + // Try initializing networks in priority order. + for priority := range initNetworks { + for pn := range initNetworks[priority] { + err := loadAndInitNetwork(s, pn, priority, true) + if err != nil { + logger.Error("Failed initializing network", logger.Ctx{"project": pn.ProjectName, "network": pn.NetworkName, "err": err}) - remainingNetworks := 0 - for _, networks := range initNetworks { - remainingNetworks += len(networks) + continue + } + } + } + + loadedNetworks = nil // Don't store loaded networks after first pass. } // For any remaining networks that were not successfully initialised, we now start a go routine to // periodically try to initialize them again in the background. - if remainingNetworks > 0 { + if remainingNetworksCount() > 0 { go func() { for { t := time.NewTimer(time.Duration(time.Minute)) + s := stateFunc() // Get fresh state in case global config has been updated. select { case <-s.ShutdownCtx.Done(): @@ -1689,7 +1720,7 @@ func networkStartup(s *state.State) error { // Try initializing networks in priority order. for priority := range initNetworks { for pn := range initNetworks[priority] { - err := loadAndInitNetwork(pn, priority, false) + err := loadAndInitNetwork(s, pn, priority, false) if err != nil { logger.Error("Failed initializing network", logger.Ctx{"project": pn.ProjectName, "network": pn.NetworkName, "err": err}) @@ -1700,11 +1731,7 @@ func networkStartup(s *state.State) error { } } - remainingNetworks := 0 - for _, networks := range initNetworks { - remainingNetworks += len(networks) - } - + remainingNetworks := remainingNetworksCount() if remainingNetworks <= 0 { logger.Info("All networks initialized") } diff --git a/lxd/operations.go b/lxd/operations.go index f8858f70c93c..693dae5e1a42 100644 --- a/lxd/operations.go +++ b/lxd/operations.go @@ -2,7 +2,6 @@ package main import ( "context" - "errors" "fmt" "net/http" "net/url" @@ -30,26 +29,30 @@ import ( ) var operationCmd = APIEndpoint{ - Path: "operations/{id}", + Path: "operations/{id}", + MetricsType: entity.TypeOperation, Delete: APIEndpointAction{Handler: operationDelete, AccessHandler: allowAuthenticated}, Get: APIEndpointAction{Handler: operationGet, AccessHandler: allowAuthenticated}, } var operationsCmd = APIEndpoint{ - Path: "operations", + Path: "operations", + MetricsType: entity.TypeOperation, Get: APIEndpointAction{Handler: operationsGet, AccessHandler: allowProjectResourceList}, } var operationWait = APIEndpoint{ - Path: "operations/{id}/wait", + Path: "operations/{id}/wait", + MetricsType: entity.TypeOperation, Get: APIEndpointAction{Handler: operationWaitGet, AllowUntrusted: true}, } var operationWebsocket = APIEndpoint{ - Path: "operations/{id}/websocket", + Path: "operations/{id}/websocket", + MetricsType: entity.TypeOperation, Get: APIEndpointAction{Handler: operationWebsocketGet, AllowUntrusted: true}, } @@ -1099,7 +1102,7 @@ func operationWebsocketGet(d *Daemon, r *http.Request) response.Response { // First check if the query is for a local operation from this node op, err := operations.OperationGetInternal(id) if err == nil { - return operations.OperationWebSocket(r, op) + return operations.OperationWebSocket(op) } // Then check if the query is from an operation on another node, and, if so, forward it @@ -1143,26 +1146,24 @@ func operationWebsocketGet(d *Daemon, r *http.Request) response.Response { return response.SmartError(err) } - return operations.ForwardedOperationWebSocket(r, id, source) + return operations.ForwardedOperationWebSocket(id, source) } func autoRemoveOrphanedOperationsTask(d *Daemon) (task.Func, task.Schedule) { f := func(ctx context.Context) { s := d.State() - localClusterAddress := s.LocalConfig.ClusterAddress() - - leader, err := d.gateway.LeaderAddress() + leaderInfo, err := s.LeaderInfo() if err != nil { - if errors.Is(err, cluster.ErrNodeIsNotClustered) { - return // No error if not clustered. - } - logger.Error("Failed to get leader cluster member address", logger.Ctx{"err": err}) return } - if localClusterAddress != leader { + if !leaderInfo.Clustered { + return + } + + if !leaderInfo.Leader { logger.Debug("Skipping remove orphaned operations task since we're not leader") return } diff --git a/lxd/operations/operations.go b/lxd/operations/operations.go index f775f7886910..f7138a4d02f3 100644 --- a/lxd/operations/operations.go +++ b/lxd/operations/operations.go @@ -115,6 +115,7 @@ type Operation struct { onRun func(*Operation) error onCancel func(*Operation) error onConnect func(*Operation, *http.Request, http.ResponseWriter) error + onDone func(*Operation) // Indicates if operation has finished. finished *cancel.Canceller @@ -218,12 +219,22 @@ func (op *Operation) SetRequestor(r *http.Request) { op.requestor = request.CreateRequestor(r) } +// SetOnDone sets the operation onDone function that is called after the operation completes. +func (op *Operation) SetOnDone(f func(*Operation)) { + op.onDone = f +} + // Requestor returns the initial requestor for this operation. func (op *Operation) Requestor() *api.EventLifecycleRequestor { return op.requestor } func (op *Operation) done() { + if op.onDone != nil { + // This can mark the request that spawned this operation as completed for the API metrics. + op.onDone(op) + } + if op.readonly { return } diff --git a/lxd/operations/response.go b/lxd/operations/response.go index 16229563d364..fa62f0faf4bd 100644 --- a/lxd/operations/response.go +++ b/lxd/operations/response.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" + "github.com/canonical/lxd/lxd/metrics" "github.com/canonical/lxd/lxd/response" "github.com/canonical/lxd/lxd/util" "github.com/canonical/lxd/shared/api" @@ -23,6 +24,18 @@ func OperationResponse(op *Operation) response.Response { // Render builds operationResponse and writes it to http.ResponseWriter. func (r *operationResponse) Render(w http.ResponseWriter, req *http.Request) error { + // Inject callback function on operation. + // If the operation was completed as expected or cancelled by an user, it is considered a success. + // Otherwise it is considered a failure. + r.op.SetOnDone(func(op *Operation) { + sc := op.Status() + if sc == api.Success || sc == api.Cancelled { + metrics.UseMetricsCallback(req, metrics.Success) + } else { + metrics.UseMetricsCallback(req, metrics.ErrorServer) + } + }) + err := r.op.Start() if err != nil { return err @@ -105,6 +118,8 @@ func (r *forwardedOperationResponse) Render(w http.ResponseWriter, req *http.Req debugLogger = logger.AddContext(logger.Ctx{"http_code": code}) } + metrics.UseMetricsCallback(req, metrics.Success) + return util.WriteJSON(w, body, debugLogger) } diff --git a/lxd/operations/websocket.go b/lxd/operations/websocket.go index af5ade4d5183..01c6d4a1f632 100644 --- a/lxd/operations/websocket.go +++ b/lxd/operations/websocket.go @@ -6,28 +6,34 @@ import ( "github.com/gorilla/websocket" + "github.com/canonical/lxd/lxd/metrics" "github.com/canonical/lxd/lxd/response" "github.com/canonical/lxd/shared/ws" ) type operationWebSocket struct { - req *http.Request - op *Operation + op *Operation } // OperationWebSocket returns a new websocket operation. -func OperationWebSocket(req *http.Request, op *Operation) response.Response { - return &operationWebSocket{req, op} +func OperationWebSocket(op *Operation) response.Response { + return &operationWebSocket{op} } // Render renders a websocket operation response. func (r *operationWebSocket) Render(w http.ResponseWriter, req *http.Request) error { - chanErr, err := r.op.Connect(r.req, w) + chanErr, err := r.op.Connect(req, w) if err != nil { return err } err = <-chanErr + + if err == nil { + // If there was an error on Render, the callback function will be called during the error handling. + metrics.UseMetricsCallback(req, metrics.Success) + } + return err } @@ -41,20 +47,19 @@ func (r *operationWebSocket) String() string { } type forwardedOperationWebSocket struct { - req *http.Request id string source *websocket.Conn // Connection to the node were the operation is running } -// ForwardedOperationWebSocket returns a new forwarted websocket operation. -func ForwardedOperationWebSocket(req *http.Request, id string, source *websocket.Conn) response.Response { - return &forwardedOperationWebSocket{req, id, source} +// ForwardedOperationWebSocket returns a new forwarded websocket operation. +func ForwardedOperationWebSocket(id string, source *websocket.Conn) response.Response { + return &forwardedOperationWebSocket{id, source} } // Render renders a forwarded websocket operation response. func (r *forwardedOperationWebSocket) Render(w http.ResponseWriter, req *http.Request) error { // Upgrade target connection to websocket. - target, err := ws.Upgrader.Upgrade(w, r.req, nil) + target, err := ws.Upgrader.Upgrade(w, req, nil) if err != nil { return err } @@ -66,6 +71,9 @@ func (r *forwardedOperationWebSocket) Render(w http.ResponseWriter, req *http.Re _ = r.source.Close() _ = target.Close() + // If there was an error on Render, the callback function will be called during the error handling. + metrics.UseMetricsCallback(req, metrics.Success) + return nil } diff --git a/lxd/patches.go b/lxd/patches.go index e3b6b1454f1f..4a7447e9ad09 100644 --- a/lxd/patches.go +++ b/lxd/patches.go @@ -2,7 +2,6 @@ package main import ( "context" - "errors" "fmt" "net/http" "os" @@ -21,7 +20,6 @@ import ( "github.com/canonical/lxd/lxd/db/query" "github.com/canonical/lxd/lxd/instance/instancetype" "github.com/canonical/lxd/lxd/network" - "github.com/canonical/lxd/lxd/node" "github.com/canonical/lxd/lxd/project" "github.com/canonical/lxd/lxd/state" storagePools "github.com/canonical/lxd/lxd/storage" @@ -399,9 +397,12 @@ func patchNetworkACLRemoveDefaults(name string, d *Daemon) error { // Its done as a patch rather than a schema update so we can use PRAGMA foreign_keys = OFF without a transaction. func patchDBNodesAutoInc(name string, d *Daemon) error { for { + // Get state on every iteration in case of change, since this loop can run indefinitely. + s := d.State() + // Only apply patch if schema needs it. var schemaSQL string - row := d.State().DB.Cluster.DB().QueryRow("SELECT sql FROM sqlite_master WHERE name = 'nodes'") + row := s.DB.Cluster.DB().QueryRow("SELECT sql FROM sqlite_master WHERE name = 'nodes'") err := row.Scan(&schemaSQL) if err != nil { return err @@ -412,27 +413,13 @@ func patchDBNodesAutoInc(name string, d *Daemon) error { return nil // Nothing to do. } - // Only apply patch on leader, otherwise wait for it to be applied. - var localConfig *node.Config - err = d.db.Node.Transaction(context.TODO(), func(ctx context.Context, tx *db.NodeTx) error { - localConfig, err = node.ConfigLoad(ctx, tx) - return err - }) + leaderInfo, err := s.LeaderInfo() if err != nil { return err } - leaderAddress, err := d.gateway.LeaderAddress() - if err != nil { - if errors.Is(err, cluster.ErrNodeIsNotClustered) { - break // Apply change on standalone node. - } - - return err - } - - if localConfig.ClusterAddress() == leaderAddress { - break // Apply change on leader node. + if leaderInfo.Leader { + break // Apply change on leader node (or standalone node). } logger.Warnf("Waiting for %q patch to be applied on leader cluster member", name) diff --git a/lxd/permissions.go b/lxd/permissions.go index 793c680749ed..758d5f6e543d 100644 --- a/lxd/permissions.go +++ b/lxd/permissions.go @@ -1,9 +1,11 @@ package main import ( + "cmp" "context" "fmt" "net/http" + "slices" "github.com/canonical/lxd/lxd/auth" "github.com/canonical/lxd/lxd/db" @@ -14,8 +16,9 @@ import ( ) var permissionsCmd = APIEndpoint{ - Name: "permissions", - Path: "auth/permissions", + Name: "permissions", + Path: "auth/permissions", + MetricsType: entity.TypeIdentity, Get: APIEndpointAction{ Handler: getPermissions, AccessHandler: allowPermission(entity.TypeServer, auth.EntitlementCanViewPermissions), @@ -221,8 +224,38 @@ func getPermissions(d *Daemon, r *http.Request) response.Response { } if recursion == "1" { + slices.SortFunc(apiPermissionInfos, comparePermissionInfo) return response.SyncResponse(true, apiPermissionInfos) } + slices.SortFunc(apiPermissions, comparePermission) return response.SyncResponse(true, apiPermissions) } + +func comparePermission(a, b api.Permission) int { + result := cmp.Compare(a.EntityType, b.EntityType) + if result != 0 { + return result + } + + result = cmp.Compare(a.EntityReference, b.EntityReference) + if result != 0 { + return result + } + + return cmp.Compare(a.Entitlement, b.Entitlement) +} + +func comparePermissionInfo(a, b api.PermissionInfo) int { + result := cmp.Compare(a.EntityType, b.EntityType) + if result != 0 { + return result + } + + result = cmp.Compare(a.EntityReference, b.EntityReference) + if result != 0 { + return result + } + + return cmp.Compare(a.Entitlement, b.Entitlement) +} diff --git a/lxd/profiles.go b/lxd/profiles.go index d2aaa587996e..9a5db1e4a614 100644 --- a/lxd/profiles.go +++ b/lxd/profiles.go @@ -35,14 +35,16 @@ import ( ) var profilesCmd = APIEndpoint{ - Path: "profiles", + Path: "profiles", + MetricsType: entity.TypeProfile, Get: APIEndpointAction{Handler: profilesGet, AccessHandler: allowProjectResourceList}, Post: APIEndpointAction{Handler: profilesPost, AccessHandler: allowPermission(entity.TypeProject, auth.EntitlementCanCreateProfiles)}, } var profileCmd = APIEndpoint{ - Path: "profiles/{name}", + Path: "profiles/{name}", + MetricsType: entity.TypeProfile, Delete: APIEndpointAction{Handler: profileDelete, AccessHandler: profileAccessHandler(auth.EntitlementCanDelete)}, Get: APIEndpointAction{Handler: profileGet, AccessHandler: profileAccessHandler(auth.EntitlementCanView)}, @@ -230,13 +232,23 @@ func profilesGet(d *Daemon, r *http.Request) response.Response { } if recursion { + profileConfigs, err := dbCluster.GetConfig(ctx, tx.Tx(), "profile") + if err != nil { + return err + } + + profileDevices, err := dbCluster.GetDevices(ctx, tx.Tx(), "profile") + if err != nil { + return err + } + apiProfiles = make([]*api.Profile, 0, len(profiles)) for _, profile := range profiles { if !userHasPermission(entity.ProfileURL(requestProjectName, profile.Name)) { continue } - apiProfile, err := profile.ToAPI(ctx, tx.Tx()) + apiProfile, err := profile.ToAPI(ctx, tx.Tx(), profileConfigs, profileDevices) if err != nil { return err } @@ -463,7 +475,17 @@ func profileGet(d *Daemon, r *http.Request) response.Response { return fmt.Errorf("Fetch profile: %w", err) } - resp, err = profile.ToAPI(ctx, tx.Tx()) + profileConfigs, err := dbCluster.GetConfig(ctx, tx.Tx(), "profile") + if err != nil { + return err + } + + profileDevices, err := dbCluster.GetDevices(ctx, tx.Tx(), "profile") + if err != nil { + return err + } + + resp, err = profile.ToAPI(ctx, tx.Tx(), profileConfigs, profileDevices) if err != nil { return err } @@ -549,7 +571,7 @@ func profilePut(d *Daemon, r *http.Request) response.Response { return fmt.Errorf("Failed to retrieve profile %q: %w", details.profileName, err) } - profile, err = current.ToAPI(ctx, tx.Tx()) + profile, err = current.ToAPI(ctx, tx.Tx(), nil, nil) if err != nil { return err } @@ -584,7 +606,7 @@ func profilePut(d *Daemon, r *http.Request) response.Response { return response.SmartError(err) } - err = notifier(func(client lxd.InstanceServer) error { + err = notifier(func(member db.NodeInfo, client lxd.InstanceServer) error { return client.UseProject(details.effectiveProject.Name).UpdateProfile(details.profileName, profile.Writable(), "") }) if err != nil { @@ -649,7 +671,7 @@ func profilePatch(d *Daemon, r *http.Request) response.Response { return fmt.Errorf("Failed to retrieve profile=%q: %w", details.profileName, err) } - profile, err = current.ToAPI(ctx, tx.Tx()) + profile, err = current.ToAPI(ctx, tx.Tx(), nil, nil) if err != nil { return err } diff --git a/lxd/project/limits/permissions.go b/lxd/project/limits/permissions.go index 95f85b5bb649..552cfccc9c31 100644 --- a/lxd/project/limits/permissions.go +++ b/lxd/project/limits/permissions.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "slices" "strconv" "strings" @@ -23,6 +24,36 @@ import ( "github.com/canonical/lxd/shared/validate" ) +// projectLimitDiskPool is the prefix used for pool-specific disk limits. +var projectLimitDiskPool = "limits.disk.pool." + +// HiddenStoragePools returns a list of storage pools that should be hidden from users of the project. +func HiddenStoragePools(ctx context.Context, tx *db.ClusterTx, projectName string) ([]string, error) { + dbProject, err := cluster.GetProject(ctx, tx.Tx(), projectName) + if err != nil { + return nil, fmt.Errorf("Failed getting project: %w", err) + } + + project, err := dbProject.ToAPI(ctx, tx.Tx()) + if err != nil { + return nil, err + } + + hiddenPools := []string{} + for k, v := range project.Config { + if !strings.HasPrefix(k, projectLimitDiskPool) || v != "0" { + continue + } + + fields := strings.SplitN(k, projectLimitDiskPool, 2) + if len(fields) == 2 { + hiddenPools = append(hiddenPools, fields[1]) + } + } + + return hiddenPools, nil +} + // AllowInstanceCreation returns an error if any project-specific limit or // restriction is violated when creating a new instance. func AllowInstanceCreation(globalConfig *clusterConfig.Config, tx *db.ClusterTx, projectName string, req api.InstancesPost) error { @@ -76,7 +107,7 @@ func AllowInstanceCreation(globalConfig *clusterConfig.Config, tx *db.ClusterTx, // Special case restriction checks on volatile.* keys. strip := false - if shared.ValueInSlice(req.Source.Type, []string{"copy", "migration"}) { + if shared.ValueInSlice(req.Source.Type, []string{api.SourceTypeCopy, api.SourceTypeMigration}) { // Allow stripping volatile keys if dealing with a copy or migration. strip = true } @@ -234,7 +265,7 @@ func checkRestrictionsOnVolatileConfig(project api.Project, instanceType instanc // AllowVolumeCreation returns an error if any project-specific limit or // restriction is violated when creating a new custom volume in a project. -func AllowVolumeCreation(globalConfig *clusterConfig.Config, tx *db.ClusterTx, projectName string, req api.StorageVolumesPost) error { +func AllowVolumeCreation(globalConfig *clusterConfig.Config, tx *db.ClusterTx, projectName string, poolName string, req api.StorageVolumesPost) error { var globalConfigDump map[string]any if globalConfig != nil { globalConfigDump = globalConfig.Dump() @@ -256,8 +287,9 @@ func AllowVolumeCreation(globalConfig *clusterConfig.Config, tx *db.ClusterTx, p // Add the volume being created. info.Volumes = append(info.Volumes, db.StorageVolumeArgs{ - Name: req.Name, - Config: req.Config, + Name: req.Name, + Config: req.Config, + PoolName: poolName, }) err = checkRestrictionsAndAggregateLimits(globalConfig, tx, info) @@ -329,8 +361,9 @@ func checkRestrictionsAndAggregateLimits(globalConfig *clusterConfig.Config, tx // across all project instances. aggregateKeys := []string{} isRestricted := false + for key, value := range info.Project.Config { - if shared.ValueInSlice(key, allAggregateLimits) { + if slices.Contains(allAggregateLimits, key) || strings.HasPrefix(key, projectLimitDiskPool) { aggregateKeys = append(aggregateKeys, key) continue } @@ -385,11 +418,18 @@ func getAggregateLimits(info *projectInfo, aggregateKeys []string) (map[string]a } for _, key := range aggregateKeys { - max := int64(-1) + maxValue := int64(-1) limit := info.Project.Config[key] if limit != "" { - parser := aggregateLimitConfigValueParsers[key] - max, err = parser(info.Project.Config[key]) + keyName := key + + // Handle pool-specific limits. + if strings.HasPrefix(key, projectLimitDiskPool) { + keyName = "limits.disk" + } + + parser := aggregateLimitConfigValueParsers[keyName] + maxValue, err = parser(info.Project.Config[key]) if err != nil { return nil, err } @@ -397,7 +437,7 @@ func getAggregateLimits(info *projectInfo, aggregateKeys []string) (map[string]a resource := api.ProjectStateResource{ Usage: totals[key], - Limit: max, + Limit: maxValue, } result[key] = resource @@ -417,16 +457,24 @@ func checkAggregateLimits(info *projectInfo, aggregateKeys []string) error { } for _, key := range aggregateKeys { - parser := aggregateLimitConfigValueParsers[key] - max, err := parser(info.Project.Config[key]) + keyName := key + + // Handle pool-specific limits. + if strings.HasPrefix(key, projectLimitDiskPool) { + keyName = "limits.disk" + } + + parser := aggregateLimitConfigValueParsers[keyName] + maxValue, err := parser(info.Project.Config[key]) if err != nil { return err } - if totals[key] > max { + if totals[key] > maxValue { return fmt.Errorf("Reached maximum aggregate value %q for %q in project %q", info.Project.Config[key], key, info.Project.Name) } } + return nil } @@ -1125,7 +1173,14 @@ func validateAggregateLimit(totals map[string]int64, key, value string) error { return nil } - parser := aggregateLimitConfigValueParsers[key] + keyName := key + + // Handle pool-specific limits. + if strings.HasPrefix(key, projectLimitDiskPool) { + keyName = "limits.disk" + } + + parser := aggregateLimitConfigValueParsers[keyName] limit, err := parser(value) if err != nil { return fmt.Errorf("Invalid value %q for limit %q: %w", value, key, err) @@ -1133,7 +1188,14 @@ func validateAggregateLimit(totals map[string]int64, key, value string) error { total := totals[key] if limit < total { - printer := aggregateLimitConfigValuePrinters[key] + keyName := key + + // Handle pool-specific limits. + if strings.HasPrefix(key, projectLimitDiskPool) { + keyName = "limits.disk" + } + + printer := aggregateLimitConfigValuePrinters[keyName] return fmt.Errorf("%q is too low: current total is %q", key, printer(total)) } @@ -1206,9 +1268,19 @@ func fetchProject(globalConfig map[string]any, tx *db.ClusterTx, projectName str return nil, fmt.Errorf("Fetch profiles from database: %w", err) } + dbProfileConfigs, err := cluster.GetConfig(ctx, tx.Tx(), "profile") + if err != nil { + return nil, fmt.Errorf("Fetch profile configs from database: %w", err) + } + + dbProfileDevices, err := cluster.GetDevices(ctx, tx.Tx(), "profile") + if err != nil { + return nil, fmt.Errorf("Fetch profile devices from database: %w", err) + } + profiles := make([]api.Profile, 0, len(dbProfiles)) for _, profile := range dbProfiles { - apiProfile, err := profile.ToAPI(ctx, tx.Tx()) + apiProfile, err := profile.ToAPI(ctx, tx.Tx(), dbProfileConfigs, dbProfileDevices) if err != nil { return nil, err } @@ -1216,38 +1288,39 @@ func fetchProject(globalConfig map[string]any, tx *db.ClusterTx, projectName str profiles = append(profiles, *apiProfile) } - drivers, err := tx.GetStoragePoolDrivers(ctx) - if err != nil { - return nil, fmt.Errorf("Fetch storage pools from database: %w", err) + info := &projectInfo{ + Project: *project, + Profiles: profiles, } - dbInstances, err := cluster.GetInstances(ctx, tx.Tx(), cluster.InstanceFilter{Project: &projectName}) - if err != nil { - return nil, fmt.Errorf("Fetch project instances from database: %w", err) + instanceFilter := cluster.InstanceFilter{ + Project: &projectName, } - instances := make([]api.Instance, 0, len(dbInstances)) - for _, instance := range dbInstances { - apiInstance, err := instance.ToAPI(ctx, tx.Tx(), globalConfig) + instanceFunc := func(inst db.InstanceArgs, project api.Project) error { + apiInstance, err := inst.ToAPI() if err != nil { - return nil, fmt.Errorf("Failed to get API data for instance %q in project %q: %w", instance.Name, instance.Project, err) + return err } - instances = append(instances, *apiInstance) + info.Instances = append(info.Instances, *apiInstance) + + return nil } - volumes, err := tx.GetCustomVolumesInProject(ctx, projectName) + err = tx.InstanceList(ctx, instanceFunc, instanceFilter) if err != nil { - return nil, fmt.Errorf("Fetch project custom volumes from database: %w", err) + return nil, fmt.Errorf("Fetch instances from database: %w", err) } - info := &projectInfo{ - Project: *project, - Profiles: profiles, - Instances: instances, - Volumes: volumes, + info.StoragePoolDrivers, err = tx.GetStoragePoolDrivers(ctx) + if err != nil { + return nil, fmt.Errorf("Fetch storage pools from database: %w", err) + } - StoragePoolDrivers: drivers, + info.Volumes, err = tx.GetCustomVolumesInProject(ctx, projectName) + if err != nil { + return nil, fmt.Errorf("Fetch project custom volumes from database: %w", err) } return info, nil @@ -1287,8 +1360,18 @@ func getTotalsAcrossProjectEntities(info *projectInfo, keys []string, skipUnset for _, key := range keys { totals[key] = 0 - if key == "limits.disk" { + if key == "limits.disk" || strings.HasPrefix(key, projectLimitDiskPool) { + poolName := "" + fields := strings.SplitN(key, projectLimitDiskPool, 2) + if len(fields) == 2 { + poolName = fields[1] + } + for _, volume := range info.Volumes { + if poolName != "" && volume.PoolName != poolName { + continue + } + value, ok := volume.Config["size"] if !ok { if skipUnset { @@ -1329,14 +1412,31 @@ func getInstanceLimits(instance api.Instance, keys []string, skipUnset bool, sto for _, key := range keys { var limit int64 - parser := aggregateLimitConfigValueParsers[key] + keyName := key + + // Handle pool-specific limits. + if strings.HasPrefix(key, projectLimitDiskPool) { + keyName = "limits.disk" + } + + parser := aggregateLimitConfigValueParsers[keyName] + + if key == "limits.disk" || strings.HasPrefix(key, projectLimitDiskPool) { + poolName := "" + fields := strings.SplitN(key, projectLimitDiskPool, 2) + if len(fields) == 2 { + poolName = fields[1] + } - if key == "limits.disk" { _, device, err := instancetype.GetRootDiskDevice(instance.Devices) if err != nil { return nil, fmt.Errorf("Failed getting root disk device for instance %q in project %q: %w", instance.Name, instance.Project, err) } + if poolName != "" && device["pool"] != poolName { + continue + } + value, ok := device["size"] if !ok || value == "" { if skipUnset { diff --git a/lxd/project/limits/state.go b/lxd/project/limits/state.go index 3962a95f298c..019e4f4505b1 100644 --- a/lxd/project/limits/state.go +++ b/lxd/project/limits/state.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strconv" + "strings" "github.com/canonical/lxd/lxd/db" "github.com/canonical/lxd/lxd/instance/instancetype" @@ -29,6 +30,16 @@ func GetCurrentAllocations(globalConfig map[string]any, ctx context.Context, tx return nil, err } + // Get per-pool limits. + poolLimits := []string{} + for k := range info.Project.Config { + if strings.HasPrefix(k, projectLimitDiskPool) { + poolLimits = append(poolLimits, k) + } + } + + allAggregateLimits := append(allAggregateLimits, poolLimits...) + // Get the instance aggregated values. raw, err := getAggregateLimits(info, allAggregateLimits) if err != nil { @@ -41,6 +52,13 @@ func GetCurrentAllocations(globalConfig map[string]any, ctx context.Context, tx result["networks"] = raw["limits.networks"] result["processes"] = raw["limits.processes"] + // Add the pool-specific disk limits. + for k, v := range raw { + if strings.HasPrefix(k, projectLimitDiskPool) && v.Limit > 0 { + result[fmt.Sprintf("disk.%s", strings.SplitN(k, ".", 4)[3])] = v + } + } + // Get the instance count values. count, limit, err := getTotalInstanceCountLimit(info) if err != nil { diff --git a/lxd/request/const.go b/lxd/request/const.go index 0ab3eabc9e6a..1a9f464acc6c 100644 --- a/lxd/request/const.go +++ b/lxd/request/const.go @@ -45,6 +45,9 @@ const ( // CtxTrusted is a boolean value that indicates whether the request was authenticated or not. CtxTrusted CtxKey = "trusted" + + // CtxMetricsCallbackFunc is a callback function that can be called to mark the request as completed for the API metrics. + CtxMetricsCallbackFunc CtxKey = "metrics_callback_function" ) // Headers. diff --git a/lxd/resources.go b/lxd/resources.go index eeccb317852a..f0013a444879 100644 --- a/lxd/resources.go +++ b/lxd/resources.go @@ -15,13 +15,15 @@ import ( ) var api10ResourcesCmd = APIEndpoint{ - Path: "resources", + Path: "resources", + MetricsType: entity.TypeServer, Get: APIEndpointAction{Handler: api10ResourcesGet, AccessHandler: allowPermission(entity.TypeServer, auth.EntitlementCanViewResources)}, } var storagePoolResourcesCmd = APIEndpoint{ - Path: "storage-pools/{name}/resources", + Path: "storage-pools/{name}/resources", + MetricsType: entity.TypeStoragePool, Get: APIEndpointAction{Handler: storagePoolResourcesGet, AccessHandler: allowPermission(entity.TypeServer, auth.EntitlementCanViewResources)}, } diff --git a/lxd/response/response.go b/lxd/response/response.go index 69ea4ec02236..d55303fb6664 100644 --- a/lxd/response/response.go +++ b/lxd/response/response.go @@ -6,12 +6,14 @@ import ( "encoding/json" "fmt" "io" + "io/fs" "mime/multipart" "net/http" "os" "time" "github.com/canonical/lxd/client" + "github.com/canonical/lxd/lxd/metrics" "github.com/canonical/lxd/lxd/ucred" "github.com/canonical/lxd/lxd/util" "github.com/canonical/lxd/shared/api" @@ -212,6 +214,15 @@ func (r *syncResponse) Render(w http.ResponseWriter, req *http.Request) error { } } + // defer calling the callback function after possibly considering the response a SmartError. + defer func() { + if r.success { + metrics.UseMetricsCallback(req, metrics.Success) + } else { + metrics.UseMetricsCallback(req, metrics.ErrorServer) + } + }() + // Handle plain text responses. if r.plaintext { if r.metadata != nil { @@ -357,6 +368,17 @@ func (r *errorResponse) Render(w http.ResponseWriter, req *http.Request) error { Code: r.code, // Set the error code in the Code field of the response body. } + defer func() { + // Use the callback function to count the request for the API metrics. + if r.code >= 400 && r.code < 500 { + // 4* codes are considered client errors on HTTP. + metrics.UseMetricsCallback(req, metrics.ErrorClient) + } else { + // Any other status code here shoud be higher than or equal to 500 and is considered a server error. + metrics.UseMetricsCallback(req, metrics.ErrorServer) + } + }() + err := json.NewEncoder(output).Encode(resp) if err != nil { @@ -399,14 +421,13 @@ type FileResponseEntry struct { } type fileResponse struct { - req *http.Request files []FileResponseEntry headers map[string]string } // FileResponse returns a new file response. -func FileResponse(r *http.Request, files []FileResponseEntry, headers map[string]string) Response { - return &fileResponse{r, files, headers} +func FileResponse(files []FileResponseEntry, headers map[string]string) Response { + return &fileResponse{files, headers} } // Render renders a file response. @@ -417,6 +438,14 @@ func (r *fileResponse) Render(w http.ResponseWriter, req *http.Request) error { } } + var err error + defer func() { + if err == nil { + // If there was an error on Render, the callback function will be called during the error handling. + metrics.UseMetricsCallback(req, metrics.Success) + } + }() + // No file, well, it's easy then if len(r.files) == 0 { return nil @@ -424,11 +453,11 @@ func (r *fileResponse) Render(w http.ResponseWriter, req *http.Request) error { // For a single file, return it inline if len(r.files) == 1 { - remoteConn := ucred.GetConnFromContext(r.req.Context()) + remoteConn := ucred.GetConnFromContext(req.Context()) remoteTCP, _ := tcp.ExtractConn(remoteConn) if remoteTCP != nil { // Apply TCP timeouts if remote connection is TCP (rather than Unix). - err := tcp.SetTimeouts(remoteTCP, 10*time.Second) + err = tcp.SetTimeouts(remoteTCP, 10*time.Second) if err != nil { return api.StatusErrorf(http.StatusInternalServerError, "Failed setting TCP timeouts on remote connection: %w", err) } @@ -447,14 +476,16 @@ func (r *fileResponse) Render(w http.ResponseWriter, req *http.Request) error { mt = r.files[0].FileModified sz = r.files[0].FileSize } else { - f, err := os.Open(r.files[0].Path) + var f *os.File + f, err = os.Open(r.files[0].Path) if err != nil { return err } defer func() { _ = f.Close() }() - fi, err := f.Stat() + var fi fs.FileInfo + fi, err = f.Stat() if err != nil { return err } @@ -468,7 +499,7 @@ func (r *fileResponse) Render(w http.ResponseWriter, req *http.Request) error { w.Header().Set("Content-Length", fmt.Sprintf("%d", sz)) w.Header().Set("Content-Disposition", fmt.Sprintf("inline;filename=%s", r.files[0].Filename)) - http.ServeContent(w, r.req, r.files[0].Filename, mt, rs) + http.ServeContent(w, req, r.files[0].Filename, mt, rs) return nil } @@ -485,7 +516,8 @@ func (r *fileResponse) Render(w http.ResponseWriter, req *http.Request) error { if entry.File != nil { rd = entry.File } else { - fd, err := os.Open(entry.Path) + var fd *os.File + fd, err = os.Open(entry.Path) if err != nil { return err } @@ -495,12 +527,13 @@ func (r *fileResponse) Render(w http.ResponseWriter, req *http.Request) error { rd = fd } - fw, err := mw.CreateFormFile(entry.Identifier, entry.Filename) + var fw io.Writer + fw, err = mw.CreateFormFile(entry.Identifier, entry.Filename) if err != nil { return err } - _, err = io.Copy(fw, rd) + _, err := io.Copy(fw, rd) if err != nil { return err } @@ -510,7 +543,9 @@ func (r *fileResponse) Render(w http.ResponseWriter, req *http.Request) error { } } - return mw.Close() + err = mw.Close() + + return err } func (r *fileResponse) String() string { @@ -518,16 +553,14 @@ func (r *fileResponse) String() string { } type forwardedResponse struct { - client lxd.InstanceServer - request *http.Request + client lxd.InstanceServer } // ForwardedResponse takes a request directed to a node and forwards it to // another node, writing back the response it gegs. func ForwardedResponse(client lxd.InstanceServer, request *http.Request) Response { return &forwardedResponse{ - client: client, - request: request, + client: client, } } @@ -538,14 +571,14 @@ func (r *forwardedResponse) Render(w http.ResponseWriter, req *http.Request) err return err } - url := fmt.Sprintf("%s%s", info.Addresses[0], r.request.URL.RequestURI()) - forwarded, err := http.NewRequest(r.request.Method, url, r.request.Body) + url := fmt.Sprintf("%s%s", info.Addresses[0], req.URL.RequestURI()) + forwarded, err := http.NewRequest(req.Method, url, req.Body) if err != nil { return err } - for key := range r.request.Header { - forwarded.Header.Set(key, r.request.Header.Get(key)) + for key := range req.Header { + forwarded.Header.Set(key, req.Header.Get(key)) } httpClient, err := r.client.GetHTTPClient() @@ -571,7 +604,7 @@ func (r *forwardedResponse) Render(w http.ResponseWriter, req *http.Request) err } func (r *forwardedResponse) String() string { - return fmt.Sprintf("request to %s", r.request.URL) + return "forwarded response" } type manualResponse struct { @@ -585,7 +618,14 @@ func ManualResponse(hook func(w http.ResponseWriter) error) Response { // Render renders a manual response. func (r *manualResponse) Render(w http.ResponseWriter, req *http.Request) error { - return r.hook(w) + err := r.hook(w) + + if err == nil { + // If there was an error on Render, the callback function will be called during the error handling. + metrics.UseMetricsCallback(req, metrics.Success) + } + + return err } func (r *manualResponse) String() string { diff --git a/lxd/response/swagger.go b/lxd/response/swagger.go index 4f5e61c33e30..1c8e6941dd58 100644 --- a/lxd/response/swagger.go +++ b/lxd/response/swagger.go @@ -137,3 +137,21 @@ type swaggerNotFound struct { ErrorCode int `json:"error_code"` } } + +// Not implemented +// +// swagger:response NotImplemented +type swaggerNotImplemented struct { + // Not implemented + // in: body + Body struct { + // Example: error + Type string `json:"type"` + + // Example: not implemented + Error string `json:"error"` + + // Example: 501 + ErrorCode int `json:"error_code"` + } +} diff --git a/lxd/scriptlet/instance_placement.go b/lxd/scriptlet/instance_placement.go index 6c0b759eca28..8444f55ee0da 100644 --- a/lxd/scriptlet/instance_placement.go +++ b/lxd/scriptlet/instance_placement.go @@ -138,7 +138,7 @@ func InstancePlacementRun(ctx context.Context, l logger.Logger, s *state.State, // Get the local resource usage. if memberName == s.ServerName { - memberState, err = cluster.MemberState(ctx, s, memberName) + memberState, err = cluster.MemberState(ctx, s) if err != nil { return nil, err } diff --git a/lxd/state/state.go b/lxd/state/state.go index 27c3a52b459e..6d0df265ce7a 100644 --- a/lxd/state/state.go +++ b/lxd/state/state.go @@ -21,7 +21,6 @@ import ( "github.com/canonical/lxd/lxd/maas" "github.com/canonical/lxd/lxd/node" "github.com/canonical/lxd/lxd/sys" - "github.com/canonical/lxd/lxd/ubuntupro" "github.com/canonical/lxd/shared" ) @@ -84,6 +83,9 @@ type State struct { // Whether the server is clustered. ServerClustered bool + // Whether we are the leader and the leader address if not. + LeaderInfo func() (*LeaderInfo, error) + // Local server UUID. ServerUUID string @@ -92,7 +94,16 @@ type State struct { // Authorizer. Authorizer auth.Authorizer +} + +// LeaderInfo represents information regarding cluster member leadership. +type LeaderInfo struct { + // Clustered is true if the server is clustered and false otherwise. + Clustered bool + + // Leader is true if the server is the raft leader or if the server is not clustered, and false otherwise. + Leader bool - // Ubuntu pro settings. - UbuntuPro *ubuntupro.Client + // Address is the address of the leader. It is not set if the server is not clustered. + Address string } diff --git a/lxd/state/testing.go b/lxd/state/testing.go index 5eada3910b8c..aaaa1e457206 100644 --- a/lxd/state/testing.go +++ b/lxd/state/testing.go @@ -6,6 +6,8 @@ import ( "context" "testing" + "github.com/stretchr/testify/require" + clusterConfig "github.com/canonical/lxd/lxd/cluster/config" "github.com/canonical/lxd/lxd/db" "github.com/canonical/lxd/lxd/firewall" @@ -34,8 +36,14 @@ func NewTestState(t *testing.T) (*State, func()) { OS: os, Firewall: firewall.New(), UpdateIdentityCache: func() {}, - GlobalConfig: &clusterConfig.Config{}, } + var err error + err = state.DB.Cluster.Transaction(context.TODO(), func(ctx context.Context, tx *db.ClusterTx) error { + state.GlobalConfig, err = clusterConfig.Load(ctx, tx) + return err + }) + require.NoError(t, err) + return state, cleanup } diff --git a/lxd/storage/backend_lxd.go b/lxd/storage/backend_lxd.go index e7f42bb54124..0dee387d1c54 100644 --- a/lxd/storage/backend_lxd.go +++ b/lxd/storage/backend_lxd.go @@ -6016,6 +6016,43 @@ func (b *lxdBackend) UpdateCustomVolume(projectName string, volName string, newD } } + sharedVolume, ok := changedConfig["security.shared"] + if ok && shared.IsFalseOrEmpty(sharedVolume) && curVol.ContentType == cluster.StoragePoolVolumeContentTypeNameBlock { + usedByProfile := false + + err = VolumeUsedByProfileDevices(b.state, b.name, projectName, &curVol.StorageVolume, func(profileID int64, profile api.Profile, project api.Project, usedByDevices []string) error { + usedByProfile = true + + return db.ErrListStop + }) + if err != nil && err != db.ErrListStop { + return err + } + + if usedByProfile { + return fmt.Errorf("Cannot disable security.shared on custom storage block volume as it is attached to profile(s)") + } + + var usedByInstanceDevices []string + + err = VolumeUsedByInstanceDevices(b.state, b.name, projectName, &curVol.StorageVolume, true, func(inst db.InstanceArgs, project api.Project, usedByDevices []string) error { + usedByInstanceDevices = append(usedByInstanceDevices, inst.Name) + + if len(usedByInstanceDevices) > 1 { + return db.ErrListStop + } + + return nil + }) + if err != nil && err != db.ErrListStop { + return err + } + + if len(usedByInstanceDevices) > 1 { + return fmt.Errorf("Cannot disable security.shared on custom storage block volume as it is attached to more than one instance") + } + } + curVol := b.GetVolume(drivers.VolumeTypeCustom, contentType, volStorageName, curVol.Config) if !userOnly { err = b.driver.UpdateVolume(curVol, changedConfig) @@ -7549,7 +7586,7 @@ func (b *lxdBackend) CreateCustomVolumeFromISO(projectName string, volName strin } err := b.state.DB.Cluster.Transaction(b.state.ShutdownCtx, func(ctx context.Context, tx *db.ClusterTx) error { - return limits.AllowVolumeCreation(b.state.GlobalConfig, tx, projectName, req) + return limits.AllowVolumeCreation(b.state.GlobalConfig, tx, projectName, b.name, req) }) if err != nil { return fmt.Errorf("Failed checking volume creation allowed: %w", err) @@ -7654,7 +7691,7 @@ func (b *lxdBackend) CreateCustomVolumeFromBackup(srcBackup backup.Info, srcData } err = b.state.DB.Cluster.Transaction(b.state.ShutdownCtx, func(ctx context.Context, tx *db.ClusterTx) error { - return limits.AllowVolumeCreation(b.state.GlobalConfig, tx, srcBackup.Project, req) + return limits.AllowVolumeCreation(b.state.GlobalConfig, tx, srcBackup.Project, b.name, req) }) if err != nil { return fmt.Errorf("Failed checking volume creation allowed: %w", err) diff --git a/lxd/storage/drivers/driver_common.go b/lxd/storage/drivers/driver_common.go index 41800cf2c919..ad57d8e6db5c 100644 --- a/lxd/storage/drivers/driver_common.go +++ b/lxd/storage/drivers/driver_common.go @@ -130,6 +130,11 @@ func (d *common) fillVolumeConfig(vol *Volume, excludedKeys ...string) error { continue } + // security.shared is only relevant for custom block volumes. + if (vol.Type() != VolumeTypeCustom || vol.ContentType() != ContentTypeBlock) && (volKey == "security.shared") { + continue + } + if vol.config[volKey] == "" { vol.config[volKey] = d.config[k] } diff --git a/lxd/storage/storage.go b/lxd/storage/storage.go index d692d75a4391..37db73246580 100644 --- a/lxd/storage/storage.go +++ b/lxd/storage/storage.go @@ -209,13 +209,14 @@ func UsedBy(ctx context.Context, s *state.State, pool Pool, firstOnly bool, memb return fmt.Errorf("Failed loading profiles: %w", err) } - for _, profile := range profiles { - profileDevices, err := cluster.GetProfileDevices(ctx, tx.Tx(), profile.ID) - if err != nil { - return fmt.Errorf("Failed loading profile devices: %w", err) - } + // Get all the profile devices. + profileDevices, err := cluster.GetDevices(ctx, tx.Tx(), "profile") + if err != nil { + return fmt.Errorf("Failed loading profile devices: %w", err) + } - for _, device := range profileDevices { + for _, profile := range profiles { + for _, device := range profileDevices[profile.ID] { if device.Type != cluster.TypeDisk { continue } diff --git a/lxd/storage/utils.go b/lxd/storage/utils.go index 38b4db585331..bffac379be51 100644 --- a/lxd/storage/utils.go +++ b/lxd/storage/utils.go @@ -537,6 +537,19 @@ func poolAndVolumeCommonRules(vol *drivers.Volume) map[string]func(string) error rules["security.unmapped"] = validate.Optional(validate.IsBool) } + // security.shared is only relevant for custom block volumes. + if vol == nil || (vol.Type() == drivers.VolumeTypeCustom && vol.ContentType() == drivers.ContentTypeBlock) { + // lxdmeta:generate(entities=storage-btrfs,storage-ceph,storage-dir,storage-lvm,storage-zfs,storage-powerflex; group=volume-conf; key=security.shared) + // Enabling this option allows sharing the volume across multiple instances despite the possibility of data loss. + // + // --- + // type: bool + // condition: custom block volume + // defaultdesc: same as `volume.security.shared` or `false` + // shortdesc: Enable volume sharing + rules["security.shared"] = validate.Optional(validate.IsBool) + } + // Those keys are only valid for volumes. if vol != nil { // lxdmeta:generate(entities=storage-btrfs,storage-cephfs,storage-ceph,storage-dir,storage-lvm,storage-zfs,storage-powerflex; group=volume-conf; key=volatile.uuid) @@ -927,8 +940,20 @@ func VolumeUsedByProfileDevices(s *state.State, poolName string, projectName str return fmt.Errorf("Failed loading profiles: %w", err) } + // Get all the profile configs. + profileConfigs, err := cluster.GetConfig(ctx, tx.Tx(), "profile") + if err != nil { + return fmt.Errorf("Failed loading profile configs: %w", err) + } + + // Get all the profile devices. + profileDevices, err := cluster.GetDevices(ctx, tx.Tx(), "profile") + if err != nil { + return fmt.Errorf("Failed loading profile devices: %w", err) + } + for _, profile := range dbProfiles { - apiProfile, err := profile.ToAPI(ctx, tx.Tx()) + apiProfile, err := profile.ToAPI(ctx, tx.Tx(), profileConfigs, profileDevices) if err != nil { return fmt.Errorf("Failed getting API Profile %q: %w", profile.Name, err) } diff --git a/lxd/storage_buckets.go b/lxd/storage_buckets.go index 0bf27bd282b7..9edf020da12b 100644 --- a/lxd/storage_buckets.go +++ b/lxd/storage_buckets.go @@ -25,14 +25,16 @@ import ( ) var storagePoolBucketsCmd = APIEndpoint{ - Path: "storage-pools/{poolName}/buckets", + Path: "storage-pools/{poolName}/buckets", + MetricsType: entity.TypeStoragePool, Get: APIEndpointAction{Handler: storagePoolBucketsGet, AccessHandler: allowProjectResourceList}, Post: APIEndpointAction{Handler: storagePoolBucketsPost, AccessHandler: allowPermission(entity.TypeProject, auth.EntitlementCanCreateStorageBuckets)}, } var storagePoolBucketCmd = APIEndpoint{ - Path: "storage-pools/{poolName}/buckets/{bucketName}", + Path: "storage-pools/{poolName}/buckets/{bucketName}", + MetricsType: entity.TypeStoragePool, Delete: APIEndpointAction{Handler: storagePoolBucketDelete, AccessHandler: storageBucketAccessHandler(auth.EntitlementCanDelete)}, Get: APIEndpointAction{Handler: storagePoolBucketGet, AccessHandler: storageBucketAccessHandler(auth.EntitlementCanView)}, @@ -41,14 +43,16 @@ var storagePoolBucketCmd = APIEndpoint{ } var storagePoolBucketKeysCmd = APIEndpoint{ - Path: "storage-pools/{poolName}/buckets/{bucketName}/keys", + Path: "storage-pools/{poolName}/buckets/{bucketName}/keys", + MetricsType: entity.TypeStoragePool, Get: APIEndpointAction{Handler: storagePoolBucketKeysGet, AccessHandler: storageBucketAccessHandler(auth.EntitlementCanView)}, Post: APIEndpointAction{Handler: storagePoolBucketKeysPost, AccessHandler: storageBucketAccessHandler(auth.EntitlementCanEdit)}, } var storagePoolBucketKeyCmd = APIEndpoint{ - Path: "storage-pools/{poolName}/buckets/{bucketName}/keys/{keyName}", + Path: "storage-pools/{poolName}/buckets/{bucketName}/keys/{keyName}", + MetricsType: entity.TypeStoragePool, Delete: APIEndpointAction{Handler: storagePoolBucketKeyDelete, AccessHandler: storageBucketAccessHandler(auth.EntitlementCanEdit)}, Get: APIEndpointAction{Handler: storagePoolBucketKeyGet, AccessHandler: storageBucketAccessHandler(auth.EntitlementCanView)}, diff --git a/lxd/storage_pools.go b/lxd/storage_pools.go index 406623cc2de4..b01cf3dda180 100644 --- a/lxd/storage_pools.go +++ b/lxd/storage_pools.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/url" + "slices" "strings" "sync" @@ -19,6 +20,7 @@ import ( dbCluster "github.com/canonical/lxd/lxd/db/cluster" "github.com/canonical/lxd/lxd/lifecycle" "github.com/canonical/lxd/lxd/project" + "github.com/canonical/lxd/lxd/project/limits" "github.com/canonical/lxd/lxd/request" "github.com/canonical/lxd/lxd/response" "github.com/canonical/lxd/lxd/state" @@ -35,14 +37,16 @@ import ( var storagePoolCreateLock sync.Mutex var storagePoolsCmd = APIEndpoint{ - Path: "storage-pools", + Path: "storage-pools", + MetricsType: entity.TypeStoragePool, Get: APIEndpointAction{Handler: storagePoolsGet, AccessHandler: allowAuthenticated}, Post: APIEndpointAction{Handler: storagePoolsPost, AccessHandler: allowPermission(entity.TypeServer, auth.EntitlementCanCreateStoragePools)}, } var storagePoolCmd = APIEndpoint{ - Path: "storage-pools/{poolName}", + Path: "storage-pools/{poolName}", + MetricsType: entity.TypeStoragePool, Delete: APIEndpointAction{Handler: storagePoolDelete, AccessHandler: allowPermission(entity.TypeStoragePool, auth.EntitlementCanDelete, "poolName")}, Get: APIEndpointAction{Handler: storagePoolGet, AccessHandler: allowAuthenticated}, @@ -148,13 +152,24 @@ func storagePoolsGet(d *Daemon, r *http.Request) response.Response { recursion := util.IsRecursionRequest(r) var poolNames []string + var hiddenPoolNames []string err := s.DB.Cluster.Transaction(context.TODO(), func(ctx context.Context, tx *db.ClusterTx) error { var err error + // Load the pool names. poolNames, err = tx.GetStoragePoolNames(ctx) + if err != nil { + return err + } - return err + // Load the project limits. + hiddenPoolNames, err = limits.HiddenStoragePools(ctx, tx, request.ProjectParam(r)) + if err != nil { + return err + } + + return nil }) if err != nil && !response.IsNotFoundError(err) { return response.SmartError(err) @@ -168,6 +183,11 @@ func storagePoolsGet(d *Daemon, r *http.Request) response.Response { resultString := []string{} resultMap := []api.StoragePool{} for _, poolName := range poolNames { + // Hide storage pools with a 0 project limit. + if slices.Contains(hiddenPoolNames, poolName) { + continue + } + if !recursion { resultString = append(resultString, fmt.Sprintf("/%s/storage-pools/%s", version.APIVersion, poolName)) } else { @@ -506,12 +526,7 @@ func storagePoolsPostCluster(s *state.State, pool *api.StoragePool, req api.Stor } // Notify all other nodes to create the pool. - err = notifier(func(client lxd.InstanceServer) error { - server, _, err := client.GetServer() - if err != nil { - return err - } - + err = notifier(func(member db.NodeInfo, client lxd.InstanceServer) error { nodeReq := req // Clone fresh node config so we don't modify req.Config with this node's specific config which @@ -522,7 +537,7 @@ func storagePoolsPostCluster(s *state.State, pool *api.StoragePool, req api.Stor } // Merge node specific config items into global config. - for key, value := range configs[server.Environment.ServerName] { + for key, value := range configs[member.Name] { nodeReq.Config[key] = value } @@ -531,7 +546,7 @@ func storagePoolsPostCluster(s *state.State, pool *api.StoragePool, req api.Stor return err } - logger.Debug("Created storage pool on cluster member", logger.Ctx{"pool": req.Name, "member": server.Environment.ServerName}) + logger.Debug("Created storage pool on cluster member", logger.Ctx{"pool": req.Name, "member": member.Name}) return nil }) @@ -616,6 +631,27 @@ func storagePoolGet(d *Daemon, r *http.Request) response.Response { memberSpecific = true } + var hiddenPoolNames []string + err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error { + var err error + + // Load the project limits. + hiddenPoolNames, err = limits.HiddenStoragePools(ctx, tx, request.ProjectParam(r)) + if err != nil { + return err + } + + return nil + }) + if err != nil { + return response.SmartError(err) + } + + // Hide storage pools with a 0 project limit. + if slices.Contains(hiddenPoolNames, poolName) { + return response.NotFound(nil) + } + // Get the existing storage pool. pool, err := storagePools.LoadByName(s, poolName) if err != nil { @@ -880,7 +916,7 @@ func doStoragePoolUpdate(s *state.State, pool storagePools.Pool, req api.Storage sendPool.Config[k] = v } - err = notifier(func(client lxd.InstanceServer) error { + err = notifier(func(member db.NodeInfo, client lxd.InstanceServer) error { return client.UpdateStoragePool(pool.Name(), sendPool, "") }) if err != nil { @@ -1004,12 +1040,7 @@ func storagePoolDelete(d *Daemon, r *http.Request) response.Response { } // If we are clustered, also notify all other nodes. - err = notifier(func(client lxd.InstanceServer) error { - _, _, err := client.GetServer() - if err != nil { - return err - } - + err = notifier(func(member db.NodeInfo, client lxd.InstanceServer) error { return client.DeleteStoragePool(pool.Name()) }) if err != nil { diff --git a/lxd/storage_volumes.go b/lxd/storage_volumes.go index 53b844351f4f..3621805e4fff 100644 --- a/lxd/storage_volumes.go +++ b/lxd/storage_volumes.go @@ -47,43 +47,48 @@ import ( ) var storageVolumesCmd = APIEndpoint{ - Path: "storage-volumes", + Path: "storage-volumes", + MetricsType: entity.TypeStoragePool, Get: APIEndpointAction{Handler: storagePoolVolumesGet, AccessHandler: allowProjectResourceList}, } var storageVolumesTypeCmd = APIEndpoint{ - Path: "storage-volumes/{type}", + Path: "storage-volumes/{type}", + MetricsType: entity.TypeStoragePool, Get: APIEndpointAction{Handler: storagePoolVolumesGet, AccessHandler: allowProjectResourceList}, } var storagePoolVolumesCmd = APIEndpoint{ - Path: "storage-pools/{poolName}/volumes", + Path: "storage-pools/{poolName}/volumes", + MetricsType: entity.TypeStoragePool, Get: APIEndpointAction{Handler: storagePoolVolumesGet, AccessHandler: allowProjectResourceList}, Post: APIEndpointAction{Handler: storagePoolVolumesPost, AccessHandler: allowPermission(entity.TypeProject, auth.EntitlementCanCreateStorageVolumes)}, } var storagePoolVolumesTypeCmd = APIEndpoint{ - Path: "storage-pools/{poolName}/volumes/{type}", + Path: "storage-pools/{poolName}/volumes/{type}", + MetricsType: entity.TypeStoragePool, Get: APIEndpointAction{Handler: storagePoolVolumesGet, AccessHandler: allowProjectResourceList}, Post: APIEndpointAction{Handler: storagePoolVolumesPost, AccessHandler: allowPermission(entity.TypeProject, auth.EntitlementCanCreateStorageVolumes)}, } var storagePoolVolumeTypeCmd = APIEndpoint{ - Path: "storage-pools/{poolName}/volumes/{type}/{volumeName}", - - Delete: APIEndpointAction{Handler: storagePoolVolumeDelete, AccessHandler: storagePoolVolumeTypeAccessHandler(auth.EntitlementCanDelete)}, - Get: APIEndpointAction{Handler: storagePoolVolumeGet, AccessHandler: storagePoolVolumeTypeAccessHandler(auth.EntitlementCanView)}, - Patch: APIEndpointAction{Handler: storagePoolVolumePatch, AccessHandler: storagePoolVolumeTypeAccessHandler(auth.EntitlementCanEdit)}, - Post: APIEndpointAction{Handler: storagePoolVolumePost, AccessHandler: storagePoolVolumeTypeAccessHandler(auth.EntitlementCanEdit)}, - Put: APIEndpointAction{Handler: storagePoolVolumePut, AccessHandler: storagePoolVolumeTypeAccessHandler(auth.EntitlementCanEdit)}, + Path: "storage-pools/{poolName}/volumes/{type}/{volumeName}", + MetricsType: entity.TypeStoragePool, + + Delete: APIEndpointAction{Handler: storagePoolVolumeDelete, AccessHandler: storagePoolVolumeTypeAccessHandler(entity.TypeStorageVolume, auth.EntitlementCanDelete)}, + Get: APIEndpointAction{Handler: storagePoolVolumeGet, AccessHandler: storagePoolVolumeTypeAccessHandler(entity.TypeStorageVolume, auth.EntitlementCanView)}, + Patch: APIEndpointAction{Handler: storagePoolVolumePatch, AccessHandler: storagePoolVolumeTypeAccessHandler(entity.TypeStorageVolume, auth.EntitlementCanEdit)}, + Post: APIEndpointAction{Handler: storagePoolVolumePost, AccessHandler: storagePoolVolumeTypeAccessHandler(entity.TypeStorageVolume, auth.EntitlementCanEdit)}, + Put: APIEndpointAction{Handler: storagePoolVolumePut, AccessHandler: storagePoolVolumeTypeAccessHandler(entity.TypeStorageVolume, auth.EntitlementCanEdit)}, } // storagePoolVolumeTypeAccessHandler returns an access handler which checks the given entitlement on a storage volume. -func storagePoolVolumeTypeAccessHandler(entitlement auth.Entitlement) func(d *Daemon, r *http.Request) response.Response { +func storagePoolVolumeTypeAccessHandler(entityType entity.Type, entitlement auth.Entitlement) func(d *Daemon, r *http.Request) response.Response { return func(d *Daemon, r *http.Request) response.Response { s := d.State() err := addStoragePoolVolumeDetailsToRequestContext(s, r) @@ -96,31 +101,29 @@ func storagePoolVolumeTypeAccessHandler(entitlement auth.Entitlement) func(d *Da return response.SmartError(err) } - var target string - - // Regardless of whether the caller specified a target parameter, we do not add it to the authorization check if - // the storage pool is remote. This is because the volume in the database has a NULL `node_id`, so the URL uniquely - // identifies the volume without the target parameter. - if !details.pool.Driver().Info().Remote { - // If the storage pool is local, we need to add a target parameter to the authorization check URL for the - // auth subsystem to consider it unique. - - // If the target parameter was specified, use that. - target = request.QueryParam(r, "target") + var u *api.URL + switch entityType { + case entity.TypeStorageVolume: + u = entity.StorageVolumeURL(request.ProjectParam(r), details.location, details.pool.Name(), details.volumeTypeName, details.volumeName) + case entity.TypeStorageVolumeBackup: + backupName, err := url.PathUnescape(mux.Vars(r)["backupName"]) + if err != nil { + return response.SmartError(err) + } - if target == "" { - // Otherwise, check if the volume is located on another member. - if details.forwardingNodeInfo != nil { - // Use the name of the forwarding member as the location of the volume. - target = details.forwardingNodeInfo.Name - } else { - // If we're not forwarding the request, use the name of this member as the location of the volume. - target = s.ServerName - } + u = entity.StorageVolumeBackupURL(request.ProjectParam(r), details.location, details.pool.Name(), details.volumeTypeName, details.volumeName, backupName) + case entity.TypeStorageVolumeSnapshot: + snapshotName, err := url.PathUnescape(mux.Vars(r)["snapshotName"]) + if err != nil { + return response.SmartError(err) } + + u = entity.StorageVolumeSnapshotURL(request.ProjectParam(r), details.location, details.pool.Name(), details.volumeTypeName, details.volumeName, snapshotName) + default: + return response.InternalError(fmt.Errorf("Cannot use storage volume access handler with entities of type %q", entityType)) } - err = s.Authorizer.CheckPermission(r.Context(), entity.StorageVolumeURL(request.ProjectParam(r), target, details.pool.Name(), details.volumeTypeName, details.volumeName), entitlement) + err = s.Authorizer.CheckPermission(r.Context(), u, entitlement) if err != nil { return response.SmartError(err) } @@ -1048,7 +1051,7 @@ func storagePoolVolumesPost(d *Daemon, r *http.Request) response.Response { return err } - err = limits.AllowVolumeCreation(s.GlobalConfig, tx, projectName, req) + err = limits.AllowVolumeCreation(s.GlobalConfig, tx, projectName, poolName, req) if err != nil { return err } @@ -1092,13 +1095,13 @@ func storagePoolVolumesPost(d *Daemon, r *http.Request) response.Response { switch req.Source.Type { case "": return doVolumeCreateOrCopy(s, r, requestProjectName, projectName, poolName, &req) - case "copy": + case api.SourceTypeCopy: if dbVolume != nil { return doCustomVolumeRefresh(s, r, requestProjectName, projectName, poolName, &req) } return doVolumeCreateOrCopy(s, r, requestProjectName, projectName, poolName, &req) - case "migration": + case api.SourceTypeMigration: return doVolumeMigration(s, r, requestProjectName, projectName, poolName, &req) default: return response.BadRequest(fmt.Errorf("Unknown source type %q", req.Source.Type)) @@ -1143,7 +1146,7 @@ func clusterCopyCustomVolumeInternal(s *state.State, r *http.Request, sourceAddr } // Reset the source for a migration - req.Source.Type = "migration" + req.Source.Type = api.SourceTypeMigration req.Source.Certificate = string(s.Endpoints.NetworkCert().PublicKey()) req.Source.Mode = "pull" req.Source.Operation = fmt.Sprintf("https://%s/%s/operations/%s", sourceAddress, version.APIVersion, opAPI.ID) @@ -1798,7 +1801,7 @@ func storageVolumePostClusteringMigrate(s *state.State, r *http.Request, srcPool Name: newVolumeName, Type: "custom", Source: api.StorageVolumeSource{ - Type: "migration", + Type: api.SourceTypeMigration, Mode: "pull", Operation: fmt.Sprintf("https://%s%s", srcMember.Address, srcOp.URL()), Websockets: sourceSecrets, @@ -2668,6 +2671,7 @@ type storageVolumeDetails struct { volumeName string volumeTypeName string volumeType int + location string pool storagePools.Pool forwardingNodeInfo *db.NodeInfo } @@ -2677,7 +2681,26 @@ type storageVolumeDetails struct { // bucket is added to the request context under request.CtxEffectiveProjectName. func addStoragePoolVolumeDetailsToRequestContext(s *state.State, r *http.Request) error { var details storageVolumeDetails + var location string + + // Defer function to set the details in the request context. This is because we can return early in certain + // optimisations and ensures the details are always set. defer func() { + // Check if the pool is remote or not. + // Check for nil in case there was an error. + var remote bool + if details.pool != nil { + driver := details.pool.Driver() + if driver != nil { + remote = driver.Info().Remote + } + } + + // Only set the location if the pool is not remote. + if !remote { + details.location = location + } + request.SetCtxValue(r, ctxStorageVolumeDetails, details) }() @@ -2731,15 +2754,18 @@ func addStoragePoolVolumeDetailsToRequestContext(s *state.State, r *http.Request request.SetCtxValue(r, request.CtxEffectiveProjectName, effectiveProject) - // If the target is set, we have all the information we need to perform the access check. - if request.QueryParam(r, "target") != "" { + // If the target is set, the location of the volume is user specified, so we don't need to perform further logic. + target := request.QueryParam(r, "target") + if target != "" { + location = target return nil } - // If the request has already been forwarded, no reason to perform further logic to determine the location of the - // volume. + // If the request has already been forwarded, the other member already performed the logic to determine the volume + // location, so we can set the location in the volume details as ourselves. _, err = request.GetCtxValue[string](r.Context(), request.CtxForwardedProtocol) if err == nil { + location = s.ServerName return nil } @@ -2750,6 +2776,11 @@ func addStoragePoolVolumeDetailsToRequestContext(s *state.State, r *http.Request } details.forwardingNodeInfo = remoteNodeInfo + if remoteNodeInfo != nil { + location = remoteNodeInfo.Name + } else { + location = s.ServerName + } return nil } diff --git a/lxd/storage_volumes_backup.go b/lxd/storage_volumes_backup.go index 7c01dcbb6169..0e99450e290e 100644 --- a/lxd/storage_volumes_backup.go +++ b/lxd/storage_volumes_backup.go @@ -25,29 +25,33 @@ import ( "github.com/canonical/lxd/lxd/util" "github.com/canonical/lxd/shared" "github.com/canonical/lxd/shared/api" + "github.com/canonical/lxd/shared/entity" "github.com/canonical/lxd/shared/logger" "github.com/canonical/lxd/shared/version" ) var storagePoolVolumeTypeCustomBackupsCmd = APIEndpoint{ - Path: "storage-pools/{poolName}/volumes/{type}/{volumeName}/backups", + Path: "storage-pools/{poolName}/volumes/{type}/{volumeName}/backups", + MetricsType: entity.TypeStoragePool, - Get: APIEndpointAction{Handler: storagePoolVolumeTypeCustomBackupsGet, AccessHandler: storagePoolVolumeTypeAccessHandler(auth.EntitlementCanView)}, - Post: APIEndpointAction{Handler: storagePoolVolumeTypeCustomBackupsPost, AccessHandler: storagePoolVolumeTypeAccessHandler(auth.EntitlementCanManageBackups)}, + Get: APIEndpointAction{Handler: storagePoolVolumeTypeCustomBackupsGet, AccessHandler: allowProjectResourceList}, + Post: APIEndpointAction{Handler: storagePoolVolumeTypeCustomBackupsPost, AccessHandler: storagePoolVolumeTypeAccessHandler(entity.TypeStorageVolume, auth.EntitlementCanManageBackups)}, } var storagePoolVolumeTypeCustomBackupCmd = APIEndpoint{ - Path: "storage-pools/{poolName}/volumes/{type}/{volumeName}/backups/{backupName}", + Path: "storage-pools/{poolName}/volumes/{type}/{volumeName}/backups/{backupName}", + MetricsType: entity.TypeStoragePool, - Get: APIEndpointAction{Handler: storagePoolVolumeTypeCustomBackupGet, AccessHandler: storagePoolVolumeTypeAccessHandler(auth.EntitlementCanView)}, - Post: APIEndpointAction{Handler: storagePoolVolumeTypeCustomBackupPost, AccessHandler: storagePoolVolumeTypeAccessHandler(auth.EntitlementCanManageBackups)}, - Delete: APIEndpointAction{Handler: storagePoolVolumeTypeCustomBackupDelete, AccessHandler: storagePoolVolumeTypeAccessHandler(auth.EntitlementCanManageBackups)}, + Get: APIEndpointAction{Handler: storagePoolVolumeTypeCustomBackupGet, AccessHandler: storagePoolVolumeTypeAccessHandler(entity.TypeStorageVolumeBackup, auth.EntitlementCanView)}, + Post: APIEndpointAction{Handler: storagePoolVolumeTypeCustomBackupPost, AccessHandler: storagePoolVolumeTypeAccessHandler(entity.TypeStorageVolumeBackup, auth.EntitlementCanEdit)}, + Delete: APIEndpointAction{Handler: storagePoolVolumeTypeCustomBackupDelete, AccessHandler: storagePoolVolumeTypeAccessHandler(entity.TypeStorageVolumeBackup, auth.EntitlementCanDelete)}, } var storagePoolVolumeTypeCustomBackupExportCmd = APIEndpoint{ - Path: "storage-pools/{poolName}/volumes/{type}/{volumeName}/backups/{backupName}/export", + Path: "storage-pools/{poolName}/volumes/{type}/{volumeName}/backups/{backupName}/export", + MetricsType: entity.TypeStoragePool, - Get: APIEndpointAction{Handler: storagePoolVolumeTypeCustomBackupExportGet, AccessHandler: storagePoolVolumeTypeAccessHandler(auth.EntitlementCanView)}, + Get: APIEndpointAction{Handler: storagePoolVolumeTypeCustomBackupExportGet, AccessHandler: storagePoolVolumeTypeAccessHandler(entity.TypeStorageVolumeBackup, auth.EntitlementCanView)}, } // swagger:operation GET /1.0/storage-pools/{poolName}/volumes/{type}/{volumeName}/backups storage storage_pool_volumes_type_backups_get @@ -155,6 +159,11 @@ var storagePoolVolumeTypeCustomBackupExportCmd = APIEndpoint{ func storagePoolVolumeTypeCustomBackupsGet(d *Daemon, r *http.Request) response.Response { s := d.State() + err := addStoragePoolVolumeDetailsToRequestContext(s, r) + if err != nil { + return response.SmartError(err) + } + effectiveProjectName, err := request.GetCtxValue[string](r.Context(), request.CtxEffectiveProjectName) if err != nil { return response.SmartError(err) @@ -197,9 +206,24 @@ func storagePoolVolumeTypeCustomBackupsGet(d *Daemon, r *http.Request) response. resultString := []string{} resultMap := []*api.StoragePoolVolumeBackup{} + canView, err := s.Authorizer.GetPermissionChecker(r.Context(), auth.EntitlementCanView, entity.TypeStorageVolumeBackup) + if err != nil { + return response.SmartError(err) + } + for _, backup := range backups { + _, backupName, ok := strings.Cut(backup.Name(), "/") + if !ok { + // Not adding the name to the error response here because we were unable to check if the caller is allowed to view it. + return response.InternalError(fmt.Errorf("Storage volume backup has invalid name")) + } + + if !canView(entity.StorageVolumeBackupURL(request.ProjectParam(r), details.location, details.pool.Name(), details.volumeTypeName, details.volumeName, backupName)) { + continue + } + if !recursion { - url := api.NewURL().Path(version.APIVersion, "storage-pools", details.pool.Name(), "volumes", "custom", details.volumeName, "backups", strings.Split(backup.Name(), "/")[1]).String() + url := api.NewURL().Path(version.APIVersion, "storage-pools", details.pool.Name(), "volumes", "custom", details.volumeName, "backups", backupName).String() resultString = append(resultString, url) } else { render := backup.Render() @@ -336,8 +360,9 @@ func storagePoolVolumeTypeCustomBackupsPost(d *Daemon, r *http.Request) response base := details.volumeName + shared.SnapshotDelimiter + "backup" length := len(base) - max := 0 + backupNo := 0 + // Iterate over previous backups to autoincrement the backup number. for _, backup := range backups { // Ignore backups not containing base. if !strings.HasPrefix(backup, base) { @@ -351,12 +376,12 @@ func storagePoolVolumeTypeCustomBackupsPost(d *Daemon, r *http.Request) response continue } - if num >= max { - max = num + 1 + if num >= backupNo { + backupNo = num + 1 } } - req.Name = fmt.Sprintf("backup%d", max) + req.Name = fmt.Sprintf("backup%d", backupNo) } // Validate the name. @@ -775,5 +800,5 @@ func storagePoolVolumeTypeCustomBackupExportGet(d *Daemon, r *http.Request) resp s.Events.SendLifecycle(effectiveProjectName, lifecycle.StorageVolumeBackupRetrieved.Event(details.pool.Name(), details.volumeTypeName, fullName, effectiveProjectName, request.CreateRequestor(r), nil)) - return response.FileResponse(r, []response.FileResponseEntry{ent}, nil) + return response.FileResponse([]response.FileResponseEntry{ent}, nil) } diff --git a/lxd/storage_volumes_snapshot.go b/lxd/storage_volumes_snapshot.go index f464a708c592..afc53f9320bd 100644 --- a/lxd/storage_volumes_snapshot.go +++ b/lxd/storage_volumes_snapshot.go @@ -30,25 +30,28 @@ import ( "github.com/canonical/lxd/lxd/util" "github.com/canonical/lxd/shared" "github.com/canonical/lxd/shared/api" + "github.com/canonical/lxd/shared/entity" "github.com/canonical/lxd/shared/logger" "github.com/canonical/lxd/shared/version" ) var storagePoolVolumeSnapshotsTypeCmd = APIEndpoint{ - Path: "storage-pools/{poolName}/volumes/{type}/{volumeName}/snapshots", + Path: "storage-pools/{poolName}/volumes/{type}/{volumeName}/snapshots", + MetricsType: entity.TypeStoragePool, - Get: APIEndpointAction{Handler: storagePoolVolumeSnapshotsTypeGet, AccessHandler: storagePoolVolumeTypeAccessHandler(auth.EntitlementCanView)}, - Post: APIEndpointAction{Handler: storagePoolVolumeSnapshotsTypePost, AccessHandler: storagePoolVolumeTypeAccessHandler(auth.EntitlementCanManageSnapshots)}, + Get: APIEndpointAction{Handler: storagePoolVolumeSnapshotsTypeGet, AccessHandler: allowProjectResourceList}, + Post: APIEndpointAction{Handler: storagePoolVolumeSnapshotsTypePost, AccessHandler: storagePoolVolumeTypeAccessHandler(entity.TypeStorageVolume, auth.EntitlementCanManageSnapshots)}, } var storagePoolVolumeSnapshotTypeCmd = APIEndpoint{ - Path: "storage-pools/{poolName}/volumes/{type}/{volumeName}/snapshots/{snapshotName}", - - Delete: APIEndpointAction{Handler: storagePoolVolumeSnapshotTypeDelete, AccessHandler: storagePoolVolumeTypeAccessHandler(auth.EntitlementCanManageSnapshots)}, - Get: APIEndpointAction{Handler: storagePoolVolumeSnapshotTypeGet, AccessHandler: storagePoolVolumeTypeAccessHandler(auth.EntitlementCanView)}, - Post: APIEndpointAction{Handler: storagePoolVolumeSnapshotTypePost, AccessHandler: storagePoolVolumeTypeAccessHandler(auth.EntitlementCanManageSnapshots)}, - Patch: APIEndpointAction{Handler: storagePoolVolumeSnapshotTypePatch, AccessHandler: storagePoolVolumeTypeAccessHandler(auth.EntitlementCanManageSnapshots)}, - Put: APIEndpointAction{Handler: storagePoolVolumeSnapshotTypePut, AccessHandler: storagePoolVolumeTypeAccessHandler(auth.EntitlementCanManageSnapshots)}, + Path: "storage-pools/{poolName}/volumes/{type}/{volumeName}/snapshots/{snapshotName}", + MetricsType: entity.TypeStoragePool, + + Delete: APIEndpointAction{Handler: storagePoolVolumeSnapshotTypeDelete, AccessHandler: storagePoolVolumeTypeAccessHandler(entity.TypeStorageVolumeSnapshot, auth.EntitlementCanDelete)}, + Get: APIEndpointAction{Handler: storagePoolVolumeSnapshotTypeGet, AccessHandler: storagePoolVolumeTypeAccessHandler(entity.TypeStorageVolumeSnapshot, auth.EntitlementCanView)}, + Post: APIEndpointAction{Handler: storagePoolVolumeSnapshotTypePost, AccessHandler: storagePoolVolumeTypeAccessHandler(entity.TypeStorageVolumeSnapshot, auth.EntitlementCanEdit)}, + Patch: APIEndpointAction{Handler: storagePoolVolumeSnapshotTypePatch, AccessHandler: storagePoolVolumeTypeAccessHandler(entity.TypeStorageVolumeSnapshot, auth.EntitlementCanEdit)}, + Put: APIEndpointAction{Handler: storagePoolVolumeSnapshotTypePut, AccessHandler: storagePoolVolumeTypeAccessHandler(entity.TypeStorageVolumeSnapshot, auth.EntitlementCanEdit)}, } // swagger:operation POST /1.0/storage-pools/{poolName}/volumes/{type}/{volumeName}/snapshots storage storage_pool_volumes_type_snapshots_post @@ -343,6 +346,11 @@ func storagePoolVolumeSnapshotsTypePost(d *Daemon, r *http.Request) response.Res func storagePoolVolumeSnapshotsTypeGet(d *Daemon, r *http.Request) response.Response { s := d.State() + err := addStoragePoolVolumeDetailsToRequestContext(s, r) + if err != nil { + return response.SmartError(err) + } + details, err := request.GetCtxValue[storageVolumeDetails](r.Context(), ctxStorageVolumeDetails) if err != nil { return response.SmartError(err) @@ -377,12 +385,21 @@ func storagePoolVolumeSnapshotsTypeGet(d *Daemon, r *http.Request) response.Resp return response.SmartError(err) } + canView, err := s.Authorizer.GetPermissionChecker(r.Context(), auth.EntitlementCanView, entity.TypeStorageVolumeSnapshot) + if err != nil { + return response.SmartError(err) + } + // Prepare the response. resultString := []string{} resultMap := []*api.StorageVolumeSnapshot{} for _, volume := range volumes { _, snapshotName, _ := api.GetParentAndSnapshotName(volume.Name) + if !canView(entity.StorageVolumeSnapshotURL(request.ProjectParam(r), details.location, details.pool.Name(), details.volumeTypeName, details.volumeName, snapshotName)) { + continue + } + if !recursion { resultString = append(resultString, fmt.Sprintf("/%s/storage-pools/%s/volumes/%s/%s/snapshots/%s", version.APIVersion, details.pool.Name(), details.volumeTypeName, details.volumeName, snapshotName)) } else { diff --git a/lxd/storage_volumes_state.go b/lxd/storage_volumes_state.go index fed51b2a55a6..2027f4e254ea 100644 --- a/lxd/storage_volumes_state.go +++ b/lxd/storage_volumes_state.go @@ -22,7 +22,8 @@ import ( ) var storagePoolVolumeTypeStateCmd = APIEndpoint{ - Path: "storage-pools/{poolName}/volumes/{type}/{volumeName}/state", + Path: "storage-pools/{poolName}/volumes/{type}/{volumeName}/state", + MetricsType: entity.TypeStoragePool, Get: APIEndpointAction{Handler: storagePoolVolumeTypeStateGet, AccessHandler: allowPermission(entity.TypeStorageVolume, auth.EntitlementCanView, "poolName", "type", "volumeName")}, } diff --git a/lxd/sys/os.go b/lxd/sys/os.go index 166aed77a168..9a6299d9fbc4 100644 --- a/lxd/sys/os.go +++ b/lxd/sys/os.go @@ -234,3 +234,16 @@ func (s *OS) Init() ([]cluster.Warning, error) { func (s *OS) InitStorage() error { return s.initStorageDirs() } + +// InUbuntuCore returns true if we're running on Ubuntu Core. +func (s *OS) InUbuntuCore() bool { + if !shared.InSnap() { + return false + } + + if s.ReleaseInfo["NAME"] == "Ubuntu Core" { + return true + } + + return false +} diff --git a/lxd/tokens.go b/lxd/tokens.go index bf0abde64480..ac00a6308966 100644 --- a/lxd/tokens.go +++ b/lxd/tokens.go @@ -2,15 +2,25 @@ package main import ( "context" + "net/http" "time" + "github.com/canonical/lxd/lxd/db" + "github.com/canonical/lxd/lxd/db/cluster" "github.com/canonical/lxd/lxd/db/operationtype" "github.com/canonical/lxd/lxd/operations" + "github.com/canonical/lxd/lxd/response" "github.com/canonical/lxd/lxd/state" "github.com/canonical/lxd/lxd/task" + "github.com/canonical/lxd/shared/api" "github.com/canonical/lxd/shared/logger" ) +func removeTokenHandler(d *Daemon, r *http.Request) response.Response { + autoRemoveExpiredTokens(r.Context(), d.State()) + return response.EmptySyncResponse +} + func autoRemoveExpiredTokens(ctx context.Context, s *state.State) { expiredTokenOps := make([]*operations.Operation, 0) @@ -28,7 +38,23 @@ func autoRemoveExpiredTokens(ctx context.Context, s *state.State) { } } - if len(expiredTokenOps) == 0 { + leaderInfo, err := s.LeaderInfo() + if err != nil { + // Log warning but don't return here so that any local token operations are pruned. + logger.Warn("Failed to get database leader details", logger.Ctx{"err": err}) + } + + var expiredPendingTLSIdentities []cluster.Identity + if leaderInfo != nil && leaderInfo.Leader { + expiredPendingTLSIdentities, err = getExpiredPendingIdentities(ctx, s) + if err != nil { + // Log warning but don't return here so that any local token operations are pruned. + logger.Warn("Failed to retrieve expired pending TLS identities during removal of expired tokens task", logger.Ctx{"err": err}) + } + } + + if len(expiredTokenOps) == 0 && len(expiredPendingTLSIdentities) == 0 { + // Nothing to do return } @@ -36,8 +62,22 @@ func autoRemoveExpiredTokens(ctx context.Context, s *state.State) { for _, op := range expiredTokenOps { _, err := op.Cancel() if err != nil { - logger.Debug("Failed removing expired token", logger.Ctx{"err": err, "id": op.ID()}) + logger.Warn("Failed removing expired token", logger.Ctx{"err": err, "operation": op.ID()}) + } + } + + err := s.DB.Cluster.Transaction(s.ShutdownCtx, func(ctx context.Context, tx *db.ClusterTx) error { + for _, expiredPendingTLSIdentity := range expiredPendingTLSIdentities { + err := cluster.DeleteIdentity(ctx, tx.Tx(), api.AuthenticationMethodTLS, expiredPendingTLSIdentity.Identifier) + if err != nil { + logger.Warn("Failed removing pending TLS identity", logger.Ctx{"err": err, "operation": op.ID(), "identity": expiredPendingTLSIdentity.Identifier}) + } } + + return nil + }) + if err != nil { + logger.Warn("Failed removing pending TLS identities", logger.Ctx{"err": err, "operation": op.ID()}) } return nil @@ -45,7 +85,7 @@ func autoRemoveExpiredTokens(ctx context.Context, s *state.State) { op, err := operations.OperationCreate(s, "", operations.OperationClassTask, operationtype.RemoveExpiredTokens, nil, nil, opRun, nil, nil, nil) if err != nil { - logger.Error("Failed creating remove expired tokens operation", logger.Ctx{"err": err}) + logger.Warn("Failed creating remove expired tokens operation", logger.Ctx{"err": err}) return } @@ -53,19 +93,57 @@ func autoRemoveExpiredTokens(ctx context.Context, s *state.State) { err = op.Start() if err != nil { - logger.Error("Failed starting remove expired tokens operation", logger.Ctx{"err": err}) + logger.Warn("Failed starting remove expired tokens operation", logger.Ctx{"err": err}) return } err = op.Wait(ctx) if err != nil { - logger.Error("Failed removing expired tokens", logger.Ctx{"err": err}) + logger.Warn("Failed removing expired tokens", logger.Ctx{"err": err}) return } logger.Debug("Done removing expired tokens") } +func getExpiredPendingIdentities(ctx context.Context, s *state.State) ([]cluster.Identity, error) { + var pendingTLSIdentities []cluster.Identity + err := s.DB.Cluster.Transaction(ctx, func(ctx context.Context, tx *db.ClusterTx) error { + var err error + dbPendingIdentityType := cluster.IdentityType(api.IdentityTypeCertificateClientPending) + pendingTLSIdentities, err = cluster.GetIdentitys(ctx, tx.Tx(), cluster.IdentityFilter{Type: &dbPendingIdentityType}) + if err != nil { + return err + } + + return nil + }) + if err != nil { + return nil, err + } + + expiredPendingTLSIdentities := make([]cluster.Identity, 0, len(pendingTLSIdentities)) + for _, pendingTLSIdentity := range pendingTLSIdentities { + metadata, err := pendingTLSIdentity.PendingTLSMetadata() + if err == nil && (metadata.Expiry.IsZero() || metadata.Expiry.After(time.Now())) { + continue // Token has not expired. + } + + if err != nil { + // In this case, regardless of the error returned by PendingTLSMetadata, we want to remove the pending identity. + // This is because a) we know that it is a pending identity because our query filtered for only that identity type, + // and b) if unmarshalling the metadata failed then the pending identity is invalid (it cannot be activated because + // we cannot check it's expiry). Therefore we log the error and continue to append it to our list. + logger.Warn("Failed to unmarshal pending TLS identity metadata", logger.Ctx{"err": err}) + } + + // If it's expired it should be removed. + expiredPendingTLSIdentities = append(expiredPendingTLSIdentities, pendingTLSIdentity) + } + + return expiredPendingTLSIdentities, nil +} + func autoRemoveExpiredTokensTask(d *Daemon) (task.Func, task.Schedule) { f := func(ctx context.Context) { autoRemoveExpiredTokens(ctx, d.State()) diff --git a/lxd/ubuntupro/client.go b/lxd/ubuntupro/client.go deleted file mode 100644 index 8ff5d1c7d057..000000000000 --- a/lxd/ubuntupro/client.go +++ /dev/null @@ -1,247 +0,0 @@ -package ubuntupro - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io/fs" - "net/http" - "os" - "path" - - "github.com/canonical/lxd/lxd/fsmonitor" - "github.com/canonical/lxd/lxd/fsmonitor/drivers" - "github.com/canonical/lxd/shared" - "github.com/canonical/lxd/shared/api" - "github.com/canonical/lxd/shared/logger" -) - -const ( - // guestAttachSettingOff indicates that guest attachment is turned off. - // - When the host has this setting turned off, devlxd requests to `GET /1.0/ubuntu-pro` should return "off" and - // `POST /1.0/ubuntu-pro/token` should return a 403 Forbidden (regardless of the guest setting). - // - When the guest has this setting turned off (`ubuntu_pro.guest_attach`), devlxd requests to `GET /1.0/ubuntu-pro` - // should return "off" and `POST /1.0/ubuntu-pro/token` should return a 403 Forbidden (regardless of the host setting). - guestAttachSettingOff = "off" - - // guestAttachSettingAvailable indicates that guest attachment is available. - // - When the host has this setting, devlxd requests to `GET /1.0/ubuntu-pro` should return the setting from the guest - // (`ubuntu_pro.guest_attach) and `POST /1.0/ubuntu-pro/token` should retrieve a guest token via the Ubuntu Pro client. - // - When the guest has this setting, the pro client inside the guest will not try to retrieve a guest token at startup - // (though attachment with a guest token can still be performed with `pro auto-attach`. - guestAttachSettingAvailable = "available" - - // guestAttachSettingOn indicates that guest attachment is on. - // - When the host has this setting, devlxd requests to `GET /1.0/ubuntu-pro` should return the setting from the guest - // (`ubuntu_pro.guest_attach) and `POST /1.0/ubuntu-pro/token` should retrieve a guest token via the Ubuntu Pro client. - // - When the guest has this setting, the pro client inside the guest will attempt to retrieve a guest token at startup. - guestAttachSettingOn = "on" -) - -// isValid returns an error if the GuestAttachSetting is not one of the pre-defined values. -func validateGuestAttachSetting(guestAttachSetting string) error { - if !shared.ValueInSlice(guestAttachSetting, []string{guestAttachSettingOff, guestAttachSettingAvailable, guestAttachSettingOn}) { - return fmt.Errorf("Invalid guest auto-attach setting %q", guestAttachSetting) - } - - return nil -} - -// ubuntuAdvantageDirectory is the base directory for Ubuntu Pro related configuration. -const ubuntuAdvantageDirectory = "/var/lib/ubuntu-advantage" - -// Client is our wrapper for Ubuntu Pro configuration and the Ubuntu Pro CLI. -type Client struct { - guestAttachSetting string - monitor fsmonitor.FSMonitor - pro pro -} - -// pro is an internal interface that is used for mocking calls to the pro CLI. -type pro interface { - getGuestToken(ctx context.Context) (*api.UbuntuProGuestTokenResponse, error) -} - -// proCLI calls the actual Ubuntu Pro CLI to implement the interface. -type proCLI struct{} - -// proAPIGetGuestTokenV1 represents the expected format of calls to `pro api u.pro.attach.guest.get_guest_token.v1`. -// Not all fields are modelled as they are not required for guest attachment functionality. -type proAPIGetGuestTokenV1 struct { - Result string `json:"result"` - Data struct { - Attributes api.UbuntuProGuestTokenResponse `json:"attributes"` - } `json:"data"` - Errors []struct { - Title string - } `json:"errors"` -} - -// getTokenJSON runs `pro api u.pro.attach.guest.get_guest_token.v1` and returns the result. -func (proCLI) getGuestToken(ctx context.Context) (*api.UbuntuProGuestTokenResponse, error) { - // Run pro guest attach command. - response, err := shared.RunCommandContext(ctx, "pro", "api", "u.pro.attach.guest.get_guest_token.v1") - if err != nil { - return nil, api.StatusErrorf(http.StatusServiceUnavailable, "Ubuntu Pro client command unsuccessful: %w", err) - } - - var getGuestTokenResponse proAPIGetGuestTokenV1 - err = json.Unmarshal([]byte(response), &getGuestTokenResponse) - if err != nil { - return nil, api.StatusErrorf(http.StatusInternalServerError, "Received unexpected response from Ubuntu Pro contracts server: %w", err) - } - - if getGuestTokenResponse.Result != "success" { - if len(getGuestTokenResponse.Errors) > 0 && getGuestTokenResponse.Errors[0].Title != "" { - return nil, api.StatusErrorf(http.StatusServiceUnavailable, "Ubuntu Pro contracts server returned %q when getting a guest token with error %q", getGuestTokenResponse.Result, getGuestTokenResponse.Errors[0].Title) - } - - return nil, api.StatusErrorf(http.StatusServiceUnavailable, "Ubuntu Pro contracts server returned %q when getting a guest token", getGuestTokenResponse.Result) - } - - return &getGuestTokenResponse.Data.Attributes, nil -} - -// New returns a new Client that watches /var/lib/ubuntu-advantage for changes to LXD configuration and contains a shim -// for the actual Ubuntu Pro CLI. If the host is not Ubuntu, it returns a static Client that always returns -// guestAttachSettingOff. -func New(osName string, ctx context.Context) *Client { - if osName != "Ubuntu" { - // If we're not on Ubuntu, return a static Client. - return &Client{guestAttachSetting: guestAttachSettingOff} - } - - s := &Client{} - s.init(ctx, shared.HostPath(ubuntuAdvantageDirectory), proCLI{}) - return s -} - -// getGuestAttachSetting returns the correct attachment setting for an instance based the on the instance configuration -// and the current GuestAttachSetting of the host. -func (s *Client) getGuestAttachSetting(instanceSetting string) string { - // If the setting is "off" on the host then no guest attachment should take place. - if s.guestAttachSetting == guestAttachSettingOff { - return guestAttachSettingOff - } - - // The `ubuntu_pro.guest_attach` setting is optional. If it is not set, return the host's guest attach setting. - if instanceSetting == "" { - return s.guestAttachSetting - } - - // If the setting is not empty, check it is valid. This should have been validated already when setting the value so - // log a warning if it is invalid. - err := validateGuestAttachSetting(instanceSetting) - if err != nil { - logger.Warn("Received invalid Ubuntu Pro guest attachment setting", logger.Ctx{"setting": instanceSetting}) - return guestAttachSettingOff - } - - return instanceSetting -} - -// GuestAttachSettings returns UbuntuProSettings based on the instance configuration and the GuestAttachSetting of the host. -func (s *Client) GuestAttachSettings(instanceSetting string) api.UbuntuProSettings { - return api.UbuntuProSettings{GuestAttach: s.getGuestAttachSetting(instanceSetting)} -} - -// GetGuestToken returns a 403 Forbidden error if the host or the instance has guestAttachSettingOff, otherwise -// it calls the pro shim to get a token. -func (s *Client) GetGuestToken(ctx context.Context, instanceSetting string) (*api.UbuntuProGuestTokenResponse, error) { - if s.getGuestAttachSetting(instanceSetting) == guestAttachSettingOff { - return nil, api.NewStatusError(http.StatusForbidden, "Guest attachment not allowed") - } - - return s.pro.getGuestToken(ctx) -} - -// init configures the Client to watch the ubuntu advantage directory for file changes. -func (s *Client) init(ctx context.Context, ubuntuAdvantageDir string, proShim pro) { - // Initial setting should be "off". - s.guestAttachSetting = guestAttachSettingOff - s.pro = proShim - - // Set up a watcher on the ubuntu advantage directory. - err := s.watch(ctx, ubuntuAdvantageDir) - if err != nil { - logger.Warn("Failed to configure Ubuntu configuration watcher", logger.Ctx{"err": err}) - } -} - -func (s *Client) watch(ctx context.Context, ubuntuAdvantageDir string) error { - // On first call, attempt to read the contents of the config file. - configFilePath := path.Join(ubuntuAdvantageDir, "interfaces", "lxd-config.json") - err := s.parseConfigFile(configFilePath) - if err != nil && !errors.Is(err, fs.ErrNotExist) { - logger.Warn("Failed to read Ubunto Pro LXD configuration file", logger.Ctx{"err": err}) - } - - // Watch /var/lib/ubuntu-advantage for write, remove, and rename events. - monitor, err := drivers.Load(ctx, ubuntuAdvantageDir, fsmonitor.EventWrite, fsmonitor.EventRemove, fsmonitor.EventRename) - if err != nil { - return fmt.Errorf("Failed to create a file monitor: %w", err) - } - - go func() { - // Wait for the context to be cancelled. - <-ctx.Done() - - // On cancel, set the guestAttachSetting back to "off" and unwatch the file. - s.guestAttachSetting = guestAttachSettingOff - err := monitor.Unwatch(path.Join(ubuntuAdvantageDir, "interfaces", "lxd-config.json"), "") - if err != nil { - logger.Warn("Failed to remove Ubuntu Pro configuration file watcher", logger.Ctx{"err": err}) - } - }() - - // Add a hook for the config file. - err = monitor.Watch(configFilePath, "", func(path string, event fsmonitor.Event) bool { - if event == fsmonitor.EventRemove { - // On remove, set guest attach to "off". - s.guestAttachSetting = guestAttachSettingOff - return true - } - - // Otherwise, parse the config file and update the client accordingly. - err := s.parseConfigFile(path) - if err != nil { - logger.Warn("Failed to read Ubunto Pro LXD configuration file", logger.Ctx{"err": err}) - } - - return true - }) - if err != nil { - return fmt.Errorf("Failed to configure file monitor: %w", err) - } - - s.monitor = monitor - return nil -} - -// parseConfigFile reads the Ubuntu Pro `lxd-config.json` file, validates it, and sets appropriate values in the Client. -func (s *Client) parseConfigFile(lxdConfigFile string) error { - // Default to "off" if any error occurs. - s.guestAttachSetting = guestAttachSettingOff - - f, err := os.Open(lxdConfigFile) - if err != nil { - return fmt.Errorf("Failed to open Ubuntu Pro configuration file: %w", err) - } - - defer f.Close() - - var settings api.UbuntuProSettings - err = json.NewDecoder(f).Decode(&settings) - if err != nil { - return fmt.Errorf("Failed to read Ubuntu Pro configuration file: %w", err) - } - - err = validateGuestAttachSetting(settings.GuestAttach) - if err != nil { - return fmt.Errorf("Failed to read Ubuntu Pro configuration file: %w", err) - } - - s.guestAttachSetting = settings.GuestAttach - return nil -} diff --git a/lxd/ubuntupro/client_test.go b/lxd/ubuntupro/client_test.go deleted file mode 100644 index 45e866df95dc..000000000000 --- a/lxd/ubuntupro/client_test.go +++ /dev/null @@ -1,256 +0,0 @@ -package ubuntupro - -import ( - "context" - "encoding/json" - "net/http" - "os" - "path/filepath" - "testing" - "time" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/canonical/lxd/shared/api" -) - -type proCLIMock struct { - mockResponse *api.UbuntuProGuestTokenResponse - mockErr error -} - -func (p proCLIMock) getGuestToken(_ context.Context) (*api.UbuntuProGuestTokenResponse, error) { - return p.mockResponse, p.mockErr -} - -func TestClient(t *testing.T) { - sleep := func() { - time.Sleep(100 * time.Millisecond) - } - - writeSettingsFile := func(filepath string, raw string, setting string) { - var d []byte - var err error - if raw != "" { - d = []byte(raw) - } else { - d, err = json.Marshal(api.UbuntuProSettings{GuestAttach: setting}) - require.NoError(t, err) - } - - err = os.WriteFile(filepath, d, 0666) - require.NoError(t, err) - sleep() - } - - mockTokenResponse := api.UbuntuProGuestTokenResponse{ - Expires: time.Now().String(), - GuestToken: "token", - ID: uuid.New().String(), - } - - mockProCLI := proCLIMock{ - mockResponse: &mockTokenResponse, - mockErr: nil, - } - - type assertion struct { - instanceSetting string - expectedSetting string - expectErr bool - expectedToken *api.UbuntuProGuestTokenResponse - expectedErrorCode int - } - - assertionsWhenHostHasGuestAttachmentOff := []assertion{ - { - instanceSetting: "", - expectedSetting: guestAttachSettingOff, - expectErr: true, - expectedErrorCode: http.StatusForbidden, - }, - { - instanceSetting: guestAttachSettingOff, - expectedSetting: guestAttachSettingOff, - expectErr: true, - expectedErrorCode: http.StatusForbidden, - }, - { - instanceSetting: guestAttachSettingAvailable, - expectedSetting: guestAttachSettingOff, - expectErr: true, - expectedErrorCode: http.StatusForbidden, - }, - { - instanceSetting: guestAttachSettingOn, - expectedSetting: guestAttachSettingOff, - expectErr: true, - expectedErrorCode: http.StatusForbidden, - }, - } - - assertionsWhenHostHasGuestAttachmentAvailable := []assertion{ - { - instanceSetting: "", - expectedSetting: guestAttachSettingAvailable, - expectedToken: &mockTokenResponse, - }, - { - instanceSetting: guestAttachSettingOff, - expectedSetting: guestAttachSettingOff, - expectErr: true, - expectedErrorCode: http.StatusForbidden, - }, - { - instanceSetting: guestAttachSettingAvailable, - expectedSetting: guestAttachSettingAvailable, - expectedToken: &mockTokenResponse, - }, - { - instanceSetting: guestAttachSettingOn, - expectedSetting: guestAttachSettingOn, - expectedToken: &mockTokenResponse, - }, - } - - assertionsWhenHostHasGuestAttachmentOn := []assertion{ - { - instanceSetting: "", - expectedSetting: guestAttachSettingOn, - expectedToken: &mockTokenResponse, - }, - { - instanceSetting: guestAttachSettingOff, - expectedSetting: guestAttachSettingOff, - expectErr: true, - expectedErrorCode: http.StatusForbidden, - }, - { - instanceSetting: guestAttachSettingAvailable, - expectedSetting: guestAttachSettingAvailable, - expectedToken: &mockTokenResponse, - }, - { - instanceSetting: guestAttachSettingOn, - expectedSetting: guestAttachSettingOn, - expectedToken: &mockTokenResponse, - }, - } - - ctx, cancel := context.WithCancel(context.Background()) - - // Make a temporary directory to test file watcher behaviour. - tmpDir, err := os.MkdirTemp("", "") - require.NoError(t, err) - - // Create and initialise the Client. Don't call New(), as this will create a real client watching the actual - // /var/lib/ubuntu-advantage directory. - s := &Client{} - s.init(ctx, tmpDir, mockProCLI) - - runAssertions := func(assertions []assertion) { - for _, a := range assertions { - assert.Equal(t, api.UbuntuProSettings{GuestAttach: a.expectedSetting}, s.GuestAttachSettings(a.instanceSetting)) - token, err := s.GetGuestToken(ctx, a.instanceSetting) - assert.Equal(t, a.expectedToken, token) - if a.expectErr { - assert.True(t, api.StatusErrorCheck(err, a.expectedErrorCode)) - } else { - assert.NoError(t, err) - } - } - } - - // There is no "interfaces" directory, so the guest attach setting should be off. - assert.Equal(t, guestAttachSettingOff, s.guestAttachSetting) - runAssertions(assertionsWhenHostHasGuestAttachmentOff) - - // Create the interfaces directory and sleep to wait for the filewatcher to catch up. - interfacesDir := filepath.Join(tmpDir, "interfaces") - err = os.Mkdir(interfacesDir, 0755) - require.NoError(t, err) - sleep() - - // There is no "lxd-config.json" file, so the guest attach setting should be off. - assert.Equal(t, guestAttachSettingOff, s.guestAttachSetting) - runAssertions(assertionsWhenHostHasGuestAttachmentOff) - - // Create the lxd-config.json file and sleep to wait for the filewatcher. - lxdConfigFilepath := filepath.Join(interfacesDir, "lxd-config.json") - f, err := os.Create(lxdConfigFilepath) - require.NoError(t, err) - err = f.Close() - require.NoError(t, err) - sleep() - - // The guest attach setting should still be false as we've not written anything to the file. - assert.Equal(t, guestAttachSettingOff, s.guestAttachSetting) - runAssertions(assertionsWhenHostHasGuestAttachmentOff) - - // Write '{"guest_attach":"available"}' to the settings file. - writeSettingsFile(lxdConfigFilepath, "", guestAttachSettingAvailable) - assert.Equal(t, guestAttachSettingAvailable, s.guestAttachSetting) - runAssertions(assertionsWhenHostHasGuestAttachmentAvailable) - - // Write '{"guest_attach":"off"}' to the settings file. - writeSettingsFile(lxdConfigFilepath, "", guestAttachSettingOff) - assert.Equal(t, guestAttachSettingOff, s.guestAttachSetting) - runAssertions(assertionsWhenHostHasGuestAttachmentOff) - - // Write '{"guest_attach":"on"}' to the settings file. - writeSettingsFile(lxdConfigFilepath, "", guestAttachSettingOn) - assert.Equal(t, guestAttachSettingOn, s.guestAttachSetting) - runAssertions(assertionsWhenHostHasGuestAttachmentOn) - - // Write invalid JSON to the settings file. - writeSettingsFile(lxdConfigFilepath, "{{}\\foo", "") - assert.Equal(t, guestAttachSettingOff, s.guestAttachSetting) - runAssertions(assertionsWhenHostHasGuestAttachmentOff) - - // Write '{"guest_attach":"on"}' to the settings file. - writeSettingsFile(lxdConfigFilepath, "", guestAttachSettingOn) - assert.Equal(t, guestAttachSettingOn, s.guestAttachSetting) - runAssertions(assertionsWhenHostHasGuestAttachmentOn) - - // Write an invalid setting to the settings file. - writeSettingsFile(lxdConfigFilepath, "", "foo") - assert.Equal(t, guestAttachSettingOff, s.guestAttachSetting) - runAssertions(assertionsWhenHostHasGuestAttachmentOff) - - // Write '{"guest_attach":"on"}' to the settings file. - writeSettingsFile(lxdConfigFilepath, "", guestAttachSettingOn) - assert.Equal(t, guestAttachSettingOn, s.guestAttachSetting) - runAssertions(assertionsWhenHostHasGuestAttachmentOn) - - // Remove the config file. - err = os.Remove(lxdConfigFilepath) - require.NoError(t, err) - sleep() - assert.Equal(t, guestAttachSettingOff, s.guestAttachSetting) - runAssertions(assertionsWhenHostHasGuestAttachmentOff) - - // Create a temporary config file and move it to the right location. - tmpSettingsFilePath := filepath.Join(interfacesDir, "lxd-config.json.tmp") - _, err = os.Create(tmpSettingsFilePath) - require.NoError(t, err) - writeSettingsFile(tmpSettingsFilePath, "", guestAttachSettingOn) - assert.Equal(t, guestAttachSettingOff, s.guestAttachSetting) - runAssertions(assertionsWhenHostHasGuestAttachmentOff) - - err = os.Rename(tmpSettingsFilePath, lxdConfigFilepath) - require.NoError(t, err) - sleep() - assert.Equal(t, guestAttachSettingOn, s.guestAttachSetting) - runAssertions(assertionsWhenHostHasGuestAttachmentOn) - - // Cancel the context. - cancel() - sleep() - assert.Equal(t, guestAttachSettingOff, s.guestAttachSetting) - runAssertions(assertionsWhenHostHasGuestAttachmentOff) - - err = os.RemoveAll(tmpDir) - require.NoError(t, err) -} diff --git a/lxd/util/sys.go b/lxd/util/sys.go index 606ece092ae9..0be5f9934891 100644 --- a/lxd/util/sys.go +++ b/lxd/util/sys.go @@ -3,9 +3,7 @@ package util import ( - "fmt" "os" - "path/filepath" "strings" "golang.org/x/sys/unix" @@ -65,40 +63,3 @@ func ReplaceDaemon() error { return nil } - -// GetQemuFwPaths returns a list of directory paths to search for QEMU firmware files. -func GetQemuFwPaths() ([]string, error) { - var qemuFwPaths []string - - for _, v := range []string{"LXD_QEMU_FW_PATH", "LXD_OVMF_PATH"} { - searchPaths := os.Getenv(v) - if searchPaths == "" { - continue - } - - qemuFwPaths = append(qemuFwPaths, strings.Split(searchPaths, ":")...) - } - - // Append default paths after ones extracted from env vars so they take precedence. - qemuFwPaths = append(qemuFwPaths, "/usr/share/OVMF", "/usr/share/seabios") - - count := 0 - for i, path := range qemuFwPaths { - var err error - resolvedPath, err := filepath.EvalSymlinks(path) - if err != nil { - // don't fail, just skip as some search paths can be optional - continue - } - - count++ - qemuFwPaths[i] = resolvedPath - } - - // We want to have at least one valid path to search for firmware. - if count == 0 { - return nil, fmt.Errorf("Failed to find a valid search path for firmware") - } - - return qemuFwPaths, nil -} diff --git a/lxd/warnings.go b/lxd/warnings.go index ad1eb8ad99de..090fb41e7be9 100644 --- a/lxd/warnings.go +++ b/lxd/warnings.go @@ -31,13 +31,15 @@ import ( ) var warningsCmd = APIEndpoint{ - Path: "warnings", + Path: "warnings", + MetricsType: entity.TypeWarning, Get: APIEndpointAction{Handler: warningsGet, AccessHandler: allowPermission(entity.TypeServer, auth.EntitlementCanViewWarnings)}, } var warningCmd = APIEndpoint{ - Path: "warnings/{id}", + Path: "warnings/{id}", + MetricsType: entity.TypeWarning, Get: APIEndpointAction{Handler: warningGet, AccessHandler: allowPermission(entity.TypeServer, auth.EntitlementCanViewWarnings)}, Patch: APIEndpointAction{Handler: warningPatch, AccessHandler: allowPermission(entity.TypeServer, auth.EntitlementCanEdit)}, diff --git a/po/ar.po b/po/ar.po index 50ad4f056d0d..1a2849efd518 100644 --- a/po/ar.po +++ b/po/ar.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: lxd\n" "Report-Msgid-Bugs-To: lxd@lists.canonical.com\n" -"POT-Creation-Date: 2024-10-21 13:43+0100\n" +"POT-Creation-Date: 2024-12-09 13:05+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Automatically generated\n" "Language-Team: none\n" @@ -29,7 +29,7 @@ msgid "" "### size: \"61203283968\"" msgstr "" -#: lxc/storage.go:257 +#: lxc/storage.go:281 msgid "" "### This is a YAML representation of a storage pool.\n" "### Any line starting with a '#' will be ignored.\n" @@ -46,7 +46,7 @@ msgid "" "### zfs.pool_name: default" msgstr "" -#: lxc/storage_volume.go:913 +#: lxc/storage_volume.go:1030 msgid "" "### This is a YAML representation of a storage volume.\n" "### Any line starting with a '# will be ignored.\n" @@ -60,7 +60,7 @@ msgid "" "### size: \"61203283968\"" msgstr "" -#: lxc/config.go:1178 +#: lxc/config.go:1243 msgid "" "### This is a YAML representation of the UEFI variables configuration.\n" "### Any line starting with a '# will be ignored.\n" @@ -103,13 +103,13 @@ msgid "" "### Note that the fingerprint is shown but cannot be changed" msgstr "" -#: lxc/cluster_group.go:385 +#: lxc/cluster_group.go:421 msgid "" "### This is a YAML representation of the cluster group.\n" "### Any line starting with a '# will be ignored." msgstr "" -#: lxc/config.go:114 +#: lxc/config.go:122 msgid "" "### This is a YAML representation of the configuration.\n" "### Any line starting with a '# will be ignored.\n" @@ -130,7 +130,7 @@ msgid "" "### Note that the name is shown but cannot be changed" msgstr "" -#: lxc/auth.go:214 +#: lxc/auth.go:219 msgid "" "### This is a YAML representation of the group.\n" "### Any line starting with a '# will be ignored.\n" @@ -157,7 +157,7 @@ msgid "" "permissions can be modified" msgstr "" -#: lxc/auth.go:969 +#: lxc/auth.go:1121 msgid "" "### This is a YAML representation of the group.\n" "### Any line starting with a '# will be ignored.\n" @@ -178,7 +178,7 @@ msgid "" "groups can be modified" msgstr "" -#: lxc/auth.go:1586 +#: lxc/auth.go:1793 msgid "" "### This is a YAML representation of the identity provider group.\n" "### Any line starting with a '# will be ignored.\n" @@ -192,7 +192,7 @@ msgid "" "### Note that the name is shown but cannot be modified" msgstr "" -#: lxc/image.go:395 +#: lxc/image.go:419 msgid "" "### This is a YAML representation of the image properties.\n" "### Any line starting with a '# will be ignored.\n" @@ -202,7 +202,7 @@ msgid "" "### description: My custom image" msgstr "" -#: lxc/config_metadata.go:65 +#: lxc/config_metadata.go:73 msgid "" "### This is a YAML representation of the instance metadata.\n" "### Any line starting with a '# will be ignored.\n" @@ -226,7 +226,7 @@ msgid "" "### properties: {}" msgstr "" -#: lxc/network_acl.go:535 +#: lxc/network_acl.go:608 msgid "" "### This is a YAML representation of the network ACL.\n" "### Any line starting with a '# will be ignored.\n" @@ -254,7 +254,7 @@ msgid "" "configuration keys can be changed." msgstr "" -#: lxc/network_forward.go:590 +#: lxc/network_forward.go:667 msgid "" "### This is a YAML representation of the network forward.\n" "### Any line starting with a '# will be ignored.\n" @@ -278,7 +278,7 @@ msgid "" "### Note that the listen_address and location cannot be changed." msgstr "" -#: lxc/network_load_balancer.go:593 +#: lxc/network_load_balancer.go:637 msgid "" "### This is a YAML representation of the network load balancer.\n" "### Any line starting with a '# will be ignored.\n" @@ -302,7 +302,7 @@ msgid "" "### Note that the listen_address and location cannot be changed." msgstr "" -#: lxc/network_peer.go:526 +#: lxc/network_peer.go:613 msgid "" "### This is a YAML representation of the network peer.\n" "### Any line starting with a '# will be ignored.\n" @@ -319,7 +319,7 @@ msgid "" "cannot be changed." msgstr "" -#: lxc/network_zone.go:1075 +#: lxc/network_zone.go:1239 msgid "" "### This is a YAML representation of the network zone record.\n" "### Any line starting with a '# will be ignored.\n" @@ -333,7 +333,7 @@ msgid "" "### user.foo: bah\n" msgstr "" -#: lxc/network_zone.go:478 +#: lxc/network_zone.go:543 msgid "" "### This is a YAML representation of the network zone.\n" "### Any line starting with a '# will be ignored.\n" @@ -347,7 +347,7 @@ msgid "" "### user.foo: bah\n" msgstr "" -#: lxc/network.go:602 +#: lxc/network.go:674 msgid "" "### This is a YAML representation of the network.\n" "### Any line starting with a '# will be ignored.\n" @@ -367,7 +367,7 @@ msgid "" "### Note that only the configuration can be changed." msgstr "" -#: lxc/profile.go:457 +#: lxc/profile.go:513 msgid "" "### This is a YAML representation of the profile.\n" "### Any line starting with a '# will be ignored.\n" @@ -388,7 +388,7 @@ msgid "" "### Note that the name is shown but cannot be changed" msgstr "" -#: lxc/project.go:257 +#: lxc/project.go:282 msgid "" "### This is a YAML representation of the project.\n" "### Any line starting with a '# will be ignored.\n" @@ -409,81 +409,81 @@ msgid "" "### Note that the name is shown but cannot be changed" msgstr "" -#: lxc/cluster.go:702 +#: lxc/cluster.go:790 msgid "" "### This is a yaml representation of the cluster member.\n" "### Any line starting with a '# will be ignored." msgstr "" -#: lxc/info.go:321 +#: lxc/info.go:329 #, c-format msgid "%d (id: %d, online: %v, NUMA node: %v)" msgstr "" -#: lxc/image.go:1108 +#: lxc/image.go:1180 #, c-format msgid "%s (%d more)" msgstr "" -#: lxc/info.go:161 +#: lxc/info.go:169 #, c-format msgid "%s (%s) (%d available)" msgstr "" -#: lxc/file.go:1125 +#: lxc/file.go:1157 #, c-format msgid "%s is not a directory" msgstr "" -#: lxc/file.go:1015 +#: lxc/file.go:1047 #, c-format msgid "'%s' isn't a supported file type" msgstr "" -#: lxc/cluster_group.go:137 lxc/profile.go:227 +#: lxc/cluster_group.go:149 lxc/profile.go:247 msgid "(none)" msgstr "" -#: lxc/info.go:311 +#: lxc/info.go:319 #, c-format msgid "- Level %d (type: %s): %s" msgstr "" -#: lxc/info.go:289 +#: lxc/info.go:297 #, c-format msgid "- Partition %d" msgstr "" -#: lxc/info.go:197 +#: lxc/info.go:205 #, c-format msgid "- Port %d (%s)" msgstr "" -#: lxc/action.go:220 +#: lxc/action.go:236 msgid "--console can't be used while forcing instance shutdown" msgstr "" -#: lxc/action.go:369 +#: lxc/action.go:385 msgid "--console can't be used with --all" msgstr "" -#: lxc/action.go:373 +#: lxc/action.go:389 msgid "--console only works with a single instance" msgstr "" -#: lxc/init.go:134 lxc/rebuild.go:65 +#: lxc/init.go:142 lxc/rebuild.go:65 msgid "--empty cannot be combined with an image name" msgstr "" -#: lxc/config.go:473 lxc/config.go:772 +#: lxc/config.go:497 lxc/config.go:820 msgid "--expanded cannot be used with a server" msgstr "" -#: lxc/copy.go:157 +#: lxc/copy.go:169 msgid "--instance-only can't be passed when the source is a snapshot" msgstr "" -#: lxc/copy.go:91 +#: lxc/copy.go:103 msgid "--no-profiles cannot be used with --refresh" msgstr "" @@ -491,12 +491,12 @@ msgstr "" msgid "--project cannot be used with the query command" msgstr "" -#: lxc/copy.go:168 +#: lxc/copy.go:180 msgid "--refresh can only be used with instances" msgstr "" -#: lxc/config.go:162 lxc/config.go:423 lxc/config.go:599 lxc/config.go:798 -#: lxc/info.go:453 +#: lxc/config.go:170 lxc/config.go:447 lxc/config.go:639 lxc/config.go:846 +#: lxc/info.go:461 msgid "--target cannot be used with instances" msgstr "" @@ -512,23 +512,23 @@ msgstr "" msgid " " msgstr "" -#: lxc/remote.go:895 lxc/remote.go:952 +#: lxc/remote.go:952 lxc/remote.go:1017 msgid "" msgstr "" -#: lxc/remote.go:992 +#: lxc/remote.go:1065 msgid " " msgstr "" -#: lxc/remote.go:822 +#: lxc/remote.go:871 msgid " " msgstr "" -#: lxc/file.go:655 +#: lxc/file.go:687 msgid "... [:]/" msgstr "" -#: lxc/image.go:663 +#: lxc/image.go:695 msgid "" "|| [] [:] [key=value...]" msgstr "" @@ -541,23 +541,23 @@ msgstr "" msgid "ADDRESS" msgstr "" -#: lxc/alias.go:139 lxc/image.go:1072 lxc/image_alias.go:234 +#: lxc/alias.go:139 lxc/image.go:1143 lxc/image_alias.go:234 msgid "ALIAS" msgstr "" -#: lxc/image.go:1073 +#: lxc/image.go:1144 msgid "ALIASES" msgstr "" -#: lxc/cluster.go:187 lxc/image.go:1078 lxc/list.go:554 +#: lxc/cluster.go:195 lxc/image.go:1138 lxc/list.go:562 msgid "ARCHITECTURE" msgstr "" -#: lxc/remote.go:804 +#: lxc/remote.go:853 msgid "AUTH TYPE" msgstr "" -#: lxc/auth.go:814 +#: lxc/auth.go:966 msgid "AUTHENTICATION METHOD" msgstr "" @@ -574,7 +574,7 @@ msgstr "" msgid "Access key: %s" msgstr "" -#: lxc/config.go:388 +#: lxc/config.go:396 msgid "Access the expanded configuration" msgstr "" @@ -591,31 +591,31 @@ msgstr "" msgid "Action (defaults to GET)" msgstr "" -#: lxc/cluster_group.go:653 +#: lxc/cluster_group.go:725 msgid "Add a cluster member to a cluster group" msgstr "" -#: lxc/auth.go:1107 lxc/auth.go:1108 +#: lxc/auth.go:1314 lxc/auth.go:1315 msgid "Add a group to an identity" msgstr "" -#: lxc/auth.go:1875 lxc/auth.go:1876 +#: lxc/auth.go:2082 lxc/auth.go:2083 msgid "Add a group to an identity provider group" msgstr "" -#: lxc/network_zone.go:1248 +#: lxc/network_zone.go:1424 msgid "Add a network zone record entry" msgstr "" -#: lxc/network_load_balancer.go:803 +#: lxc/network_load_balancer.go:859 msgid "Add backend to a load balancer" msgstr "" -#: lxc/network_load_balancer.go:802 +#: lxc/network_load_balancer.go:858 msgid "Add backends to a load balancer" msgstr "" -#: lxc/network_zone.go:1249 +#: lxc/network_zone.go:1425 msgid "Add entries to a network zone record" msgstr "" @@ -623,7 +623,7 @@ msgstr "" msgid "Add instance devices" msgstr "" -#: lxc/cluster_group.go:652 +#: lxc/cluster_group.go:724 msgid "Add member to group" msgstr "" @@ -668,15 +668,15 @@ msgid "" "restricted to one or more projects.\n" msgstr "" -#: lxc/auth.go:516 lxc/auth.go:517 +#: lxc/auth.go:521 lxc/auth.go:522 msgid "Add permissions to groups" msgstr "" -#: lxc/network_forward.go:799 lxc/network_forward.go:800 +#: lxc/network_forward.go:888 lxc/network_forward.go:889 msgid "Add ports to a forward" msgstr "" -#: lxc/network_load_balancer.go:967 lxc/network_load_balancer.go:968 +#: lxc/network_load_balancer.go:1047 lxc/network_load_balancer.go:1048 msgid "Add ports to a load balancer" msgstr "" @@ -688,11 +688,11 @@ msgstr "" msgid "Add roles to a cluster member" msgstr "" -#: lxc/network_acl.go:770 lxc/network_acl.go:771 +#: lxc/network_acl.go:859 lxc/network_acl.go:860 msgid "Add rules to an ACL" msgstr "" -#: lxc/info.go:201 +#: lxc/info.go:209 #, c-format msgid "Address: %s" msgstr "" @@ -702,7 +702,7 @@ msgstr "" msgid "Admin access key: %s" msgstr "" -#: lxc/remote.go:636 +#: lxc/remote.go:681 #, c-format msgid "Admin password (or token) for %s:" msgstr "" @@ -726,16 +726,16 @@ msgstr "" msgid "Alias name missing" msgstr "" -#: lxc/publish.go:244 +#: lxc/publish.go:256 #, c-format msgid "Aliases already exists: %s" msgstr "" -#: lxc/image.go:993 +#: lxc/image.go:1045 msgid "Aliases:" msgstr "" -#: lxc/storage_volume.go:1451 +#: lxc/storage_volume.go:1596 msgid "All projects" msgstr "" @@ -747,26 +747,26 @@ msgstr "" msgid "Alternative certificate name" msgstr "" -#: lxc/image.go:964 lxc/info.go:478 +#: lxc/image.go:1016 lxc/info.go:486 #, c-format msgid "Architecture: %s" msgstr "" -#: lxc/info.go:127 +#: lxc/info.go:135 #, c-format msgid "Architecture: %v" msgstr "" -#: lxc/cluster.go:1225 +#: lxc/cluster.go:1371 #, c-format msgid "Are you sure you want to %s cluster member %q? (yes/no) [default=no]: " msgstr "" -#: lxc/console.go:388 +#: lxc/console.go:402 msgid "As neither could be found, the raw SPICE socket can be found at:" msgstr "" -#: lxc/init.go:334 lxc/rebuild.go:131 +#: lxc/init.go:343 lxc/rebuild.go:131 msgid "Asked for a VM but image is of type container" msgstr "" @@ -774,7 +774,7 @@ msgstr "" msgid "Assign sets of groups to cluster members" msgstr "" -#: lxc/profile.go:167 lxc/profile.go:168 +#: lxc/profile.go:179 lxc/profile.go:180 msgid "Assign sets of profiles to instances" msgstr "" @@ -782,7 +782,7 @@ msgstr "" msgid "Attach network interfaces to instances" msgstr "" -#: lxc/network.go:220 lxc/network.go:221 +#: lxc/network.go:232 lxc/network.go:233 msgid "Attach network interfaces to profiles" msgstr "" @@ -790,19 +790,19 @@ msgstr "" msgid "Attach new network interfaces to instances" msgstr "" -#: lxc/storage_volume.go:165 lxc/storage_volume.go:166 +#: lxc/storage_volume.go:168 lxc/storage_volume.go:169 msgid "Attach new storage volumes to instances" msgstr "" -#: lxc/storage_volume.go:263 lxc/storage_volume.go:264 +#: lxc/storage_volume.go:282 lxc/storage_volume.go:283 msgid "Attach new storage volumes to profiles" msgstr "" -#: lxc/console.go:36 +#: lxc/console.go:39 msgid "Attach to instance consoles" msgstr "" -#: lxc/console.go:37 +#: lxc/console.go:40 msgid "" "Attach to instance consoles\n" "\n" @@ -810,26 +810,26 @@ msgid "" "as well as retrieve past log entries from it." msgstr "" -#: lxc/remote.go:618 +#: lxc/remote.go:636 #, c-format msgid "Authentication type '%s' not supported by server" msgstr "" -#: lxc/info.go:220 +#: lxc/info.go:228 #, c-format msgid "Auto negotiation: %v" msgstr "" -#: lxc/image.go:188 +#: lxc/image.go:200 msgid "Auto update is only available in pull mode" msgstr "" -#: lxc/image.go:1003 +#: lxc/image.go:1055 #, c-format msgid "Auto update: %s" msgstr "" -#: lxc/network_forward.go:245 lxc/network_load_balancer.go:247 +#: lxc/network_forward.go:265 lxc/network_load_balancer.go:267 msgid "Auto-allocate an IPv4 or IPv6 listen address. One of 'ipv4', 'ipv6'." msgstr "" @@ -837,7 +837,7 @@ msgstr "" msgid "Available projects:" msgstr "" -#: lxc/list.go:560 lxc/list.go:561 +#: lxc/list.go:568 lxc/list.go:569 msgid "BASE IMAGE" msgstr "" @@ -846,16 +846,16 @@ msgstr "" msgid "Backing up instance: %s" msgstr "" -#: lxc/storage_volume.go:2442 +#: lxc/storage_volume.go:2708 #, c-format msgid "Backing up storage volume: %s" msgstr "" -#: lxc/export.go:192 lxc/storage_volume.go:2519 +#: lxc/export.go:192 lxc/storage_volume.go:2785 msgid "Backup exported successfully!" msgstr "" -#: lxc/info.go:646 lxc/storage_volume.go:1382 +#: lxc/info.go:654 lxc/storage_volume.go:1527 msgid "Backups:" msgstr "" @@ -864,50 +864,50 @@ msgstr "" msgid "Bad device override syntax, expecting ,=: %s" msgstr "" -#: lxc/network.go:334 lxc/network_acl.go:387 lxc/network_forward.go:298 -#: lxc/network_load_balancer.go:301 lxc/network_peer.go:281 -#: lxc/network_zone.go:329 lxc/network_zone.go:931 lxc/storage_bucket.go:142 +#: lxc/network.go:366 lxc/network_acl.go:431 lxc/network_forward.go:318 +#: lxc/network_load_balancer.go:321 lxc/network_peer.go:309 +#: lxc/network_zone.go:366 lxc/network_zone.go:1053 lxc/storage_bucket.go:142 #, c-format msgid "Bad key/value pair: %s" msgstr "" -#: lxc/copy.go:140 lxc/init.go:233 lxc/move.go:381 lxc/project.go:152 +#: lxc/copy.go:152 lxc/init.go:237 lxc/move.go:393 lxc/project.go:161 #, c-format msgid "Bad key=value pair: %q" msgstr "" -#: lxc/publish.go:181 lxc/storage.go:154 lxc/storage_volume.go:624 +#: lxc/publish.go:193 lxc/storage.go:162 lxc/storage_volume.go:685 #, c-format msgid "Bad key=value pair: %s" msgstr "" -#: lxc/image.go:772 +#: lxc/image.go:816 #, c-format msgid "Bad property: %s" msgstr "" -#: lxc/network.go:860 +#: lxc/network.go:952 msgid "Bond:" msgstr "" -#: lxc/action.go:148 lxc/action.go:325 +#: lxc/action.go:164 lxc/action.go:341 msgid "Both --all and instance name given" msgstr "" -#: lxc/info.go:128 +#: lxc/info.go:136 #, c-format msgid "Brand: %v" msgstr "" -#: lxc/network.go:873 +#: lxc/network.go:965 msgid "Bridge:" msgstr "" -#: lxc/info.go:569 lxc/network.go:852 +#: lxc/info.go:577 lxc/network.go:944 msgid "Bytes received" msgstr "" -#: lxc/info.go:570 lxc/network.go:853 +#: lxc/info.go:578 lxc/network.go:945 msgid "Bytes sent" msgstr "" @@ -919,7 +919,7 @@ msgstr "" msgid "COMMON NAME" msgstr "" -#: lxc/storage_volume.go:1586 +#: lxc/storage_volume.go:1739 msgid "CONTENT-TYPE" msgstr "" @@ -927,24 +927,24 @@ msgstr "" msgid "COUNT" msgstr "" -#: lxc/info.go:356 +#: lxc/info.go:364 #, c-format msgid "CPU (%s):" msgstr "" -#: lxc/list.go:572 +#: lxc/list.go:580 msgid "CPU USAGE" msgstr "" -#: lxc/info.go:519 +#: lxc/info.go:527 msgid "CPU usage (in seconds)" msgstr "" -#: lxc/info.go:523 +#: lxc/info.go:531 msgid "CPU usage:" msgstr "" -#: lxc/info.go:359 +#: lxc/info.go:367 #, c-format msgid "CPUs (%s):" msgstr "" @@ -953,33 +953,33 @@ msgstr "" msgid "CREATED" msgstr "" -#: lxc/list.go:556 +#: lxc/list.go:564 msgid "CREATED AT" msgstr "" -#: lxc/info.go:130 +#: lxc/info.go:138 #, c-format msgid "CUDA Version: %v" msgstr "" -#: lxc/image.go:1002 +#: lxc/image.go:1054 #, c-format msgid "Cached: %s" msgstr "" -#: lxc/info.go:309 +#: lxc/info.go:317 msgid "Caches:" msgstr "" -#: lxc/move.go:118 +#: lxc/move.go:130 msgid "Can't override configuration or profiles in local rename" msgstr "" -#: lxc/image.go:221 +#: lxc/image.go:233 msgid "Can't provide a name for the target image" msgstr "" -#: lxc/file.go:530 +#: lxc/file.go:562 msgid "Can't pull a directory without --recursive" msgstr "" @@ -988,31 +988,31 @@ msgstr "" msgid "Can't read from stdin: %w" msgstr "" -#: lxc/remote.go:931 +#: lxc/remote.go:996 msgid "Can't remove the default remote" msgstr "" -#: lxc/list.go:586 +#: lxc/list.go:594 msgid "Can't specify --fast with --columns" msgstr "" -#: lxc/list.go:459 +#: lxc/list.go:467 msgid "Can't specify --project with --all-projects" msgstr "" -#: lxc/rename.go:52 +#: lxc/rename.go:60 msgid "Can't specify a different remote for rename" msgstr "" -#: lxc/list.go:602 lxc/storage_volume.go:1596 lxc/warning.go:225 +#: lxc/list.go:610 lxc/storage_volume.go:1749 lxc/warning.go:225 msgid "Can't specify column L when not clustered" msgstr "" -#: lxc/file.go:736 +#: lxc/file.go:768 msgid "Can't supply uid/gid/mode in recursive mode" msgstr "" -#: lxc/config.go:665 lxc/config.go:1035 +#: lxc/config.go:705 lxc/config.go:1100 #, c-format msgid "Can't unset key '%s', it's not currently set" msgstr "" @@ -1021,16 +1021,16 @@ msgstr "" msgid "Can't use an image with --empty" msgstr "" -#: lxc/storage_volume.go:439 +#: lxc/storage_volume.go:492 msgid "" "Cannot set --destination-target when destination server is not clustered" msgstr "" -#: lxc/storage_volume.go:393 +#: lxc/storage_volume.go:446 msgid "Cannot set --target when source server is not clustered" msgstr "" -#: lxc/network_acl.go:824 +#: lxc/network_acl.go:929 #, c-format msgid "Cannot set key: %s" msgstr "" @@ -1039,12 +1039,12 @@ msgstr "" msgid "Cannot use metrics type certificate when using a token" msgstr "" -#: lxc/info.go:403 lxc/info.go:415 +#: lxc/info.go:411 lxc/info.go:423 #, c-format msgid "Card %d:" msgstr "" -#: lxc/info.go:113 +#: lxc/info.go:121 #, c-format msgid "Card: %s (%s)" msgstr "" @@ -1054,7 +1054,7 @@ msgstr "" msgid "Certificate add token for %s deleted" msgstr "" -#: lxc/remote.go:506 +#: lxc/remote.go:524 msgid "Certificate fingerprint" msgstr "" @@ -1064,7 +1064,7 @@ msgid "" "Certificate fingerprint mismatch between certificate token and server %q" msgstr "" -#: lxc/network.go:894 +#: lxc/network.go:986 msgid "Chassis" msgstr "" @@ -1073,7 +1073,7 @@ msgstr "" msgid "Client %s certificate add token:" msgstr "" -#: lxc/remote.go:673 +#: lxc/remote.go:722 msgid "Client certificate now trusted by server:" msgstr "" @@ -1082,94 +1082,94 @@ msgstr "" msgid "Client version: %s\n" msgstr "" -#: lxc/cluster_group.go:218 +#: lxc/cluster_group.go:238 #, c-format msgid "Cluster group %s created" msgstr "" -#: lxc/cluster_group.go:271 +#: lxc/cluster_group.go:299 #, c-format msgid "Cluster group %s deleted" msgstr "" -#: lxc/cluster_group.go:513 +#: lxc/cluster_group.go:569 #, c-format msgid "Cluster group %s isn't currently applied to %s" msgstr "" -#: lxc/cluster_group.go:582 +#: lxc/cluster_group.go:646 #, c-format msgid "Cluster group %s renamed to %s" msgstr "" -#: lxc/cluster.go:1041 +#: lxc/cluster.go:1154 #, c-format msgid "Cluster join token for %s:%s deleted" msgstr "" -#: lxc/cluster_group.go:141 +#: lxc/cluster_group.go:153 #, c-format msgid "Cluster member %s added to cluster groups %s" msgstr "" -#: lxc/cluster_group.go:698 +#: lxc/cluster_group.go:782 #, c-format msgid "Cluster member %s added to group %s" msgstr "" -#: lxc/cluster_group.go:687 +#: lxc/cluster_group.go:771 #, c-format msgid "Cluster member %s is already in group %s" msgstr "" -#: lxc/cluster_group.go:533 +#: lxc/cluster_group.go:589 #, c-format msgid "Cluster member %s removed from group %s" msgstr "" -#: lxc/config.go:106 lxc/config.go:390 lxc/config.go:533 lxc/config.go:739 -#: lxc/config.go:862 lxc/copy.go:62 lxc/info.go:45 lxc/init.go:65 -#: lxc/move.go:67 lxc/network.go:301 lxc/network.go:724 lxc/network.go:793 -#: lxc/network.go:1135 lxc/network.go:1220 lxc/network.go:1284 -#: lxc/network_forward.go:174 lxc/network_forward.go:244 -#: lxc/network_forward.go:461 lxc/network_forward.go:584 -#: lxc/network_forward.go:726 lxc/network_forward.go:803 -#: lxc/network_forward.go:869 lxc/network_load_balancer.go:176 -#: lxc/network_load_balancer.go:246 lxc/network_load_balancer.go:464 -#: lxc/network_load_balancer.go:587 lxc/network_load_balancer.go:730 -#: lxc/network_load_balancer.go:806 lxc/network_load_balancer.go:870 -#: lxc/network_load_balancer.go:971 lxc/network_load_balancer.go:1033 -#: lxc/storage.go:105 lxc/storage.go:372 lxc/storage.go:443 lxc/storage.go:696 -#: lxc/storage.go:790 lxc/storage.go:875 lxc/storage_bucket.go:91 +#: lxc/config.go:106 lxc/config.go:398 lxc/config.go:557 lxc/config.go:779 +#: lxc/config.go:910 lxc/copy.go:62 lxc/info.go:45 lxc/init.go:65 +#: lxc/move.go:67 lxc/network.go:325 lxc/network.go:796 lxc/network.go:877 +#: lxc/network.go:1251 lxc/network.go:1344 lxc/network.go:1416 +#: lxc/network_forward.go:182 lxc/network_forward.go:264 +#: lxc/network_forward.go:497 lxc/network_forward.go:649 +#: lxc/network_forward.go:803 lxc/network_forward.go:892 +#: lxc/network_forward.go:974 lxc/network_load_balancer.go:184 +#: lxc/network_load_balancer.go:266 lxc/network_load_balancer.go:484 +#: lxc/network_load_balancer.go:619 lxc/network_load_balancer.go:774 +#: lxc/network_load_balancer.go:862 lxc/network_load_balancer.go:938 +#: lxc/network_load_balancer.go:1051 lxc/network_load_balancer.go:1125 +#: lxc/storage.go:105 lxc/storage.go:396 lxc/storage.go:479 lxc/storage.go:748 +#: lxc/storage.go:850 lxc/storage.go:943 lxc/storage_bucket.go:91 #: lxc/storage_bucket.go:191 lxc/storage_bucket.go:254 #: lxc/storage_bucket.go:385 lxc/storage_bucket.go:542 #: lxc/storage_bucket.go:635 lxc/storage_bucket.go:701 #: lxc/storage_bucket.go:776 lxc/storage_bucket.go:862 #: lxc/storage_bucket.go:962 lxc/storage_bucket.go:1027 -#: lxc/storage_bucket.go:1163 lxc/storage_volume.go:359 -#: lxc/storage_volume.go:565 lxc/storage_volume.go:662 -#: lxc/storage_volume.go:906 lxc/storage_volume.go:1120 -#: lxc/storage_volume.go:1233 lxc/storage_volume.go:1701 -#: lxc/storage_volume.go:1781 lxc/storage_volume.go:1908 -#: lxc/storage_volume.go:2054 lxc/storage_volume.go:2158 -#: lxc/storage_volume.go:2203 lxc/storage_volume.go:2317 -#: lxc/storage_volume.go:2389 lxc/storage_volume.go:2541 +#: lxc/storage_bucket.go:1163 lxc/storage_volume.go:394 +#: lxc/storage_volume.go:618 lxc/storage_volume.go:723 +#: lxc/storage_volume.go:1011 lxc/storage_volume.go:1237 +#: lxc/storage_volume.go:1366 lxc/storage_volume.go:1854 +#: lxc/storage_volume.go:1952 lxc/storage_volume.go:2091 +#: lxc/storage_volume.go:2251 lxc/storage_volume.go:2367 +#: lxc/storage_volume.go:2428 lxc/storage_volume.go:2555 +#: lxc/storage_volume.go:2643 lxc/storage_volume.go:2807 msgid "Cluster member name" msgstr "" -#: lxc/cluster.go:806 +#: lxc/cluster.go:894 msgid "Cluster member name (alternative to passing it as an argument)" msgstr "" -#: lxc/cluster.go:830 +#: lxc/cluster.go:926 msgid "Cluster member name was provided as both a flag and as an argument" msgstr "" -#: lxc/cluster.go:676 +#: lxc/cluster.go:756 msgid "Clustering enabled" msgstr "" -#: lxc/image.go:1063 lxc/list.go:132 lxc/storage_volume.go:1450 +#: lxc/image.go:1117 lxc/list.go:132 lxc/storage_volume.go:1595 #: lxc/warning.go:93 msgid "Columns" msgstr "" @@ -1198,7 +1198,7 @@ msgstr "" msgid "Config key/value to apply to the new instance" msgstr "" -#: lxc/project.go:101 +#: lxc/project.go:102 msgid "Config key/value to apply to the new project" msgstr "" @@ -1206,29 +1206,29 @@ msgstr "" msgid "Config key/value to apply to the target instance" msgstr "" -#: lxc/cluster.go:771 lxc/cluster_group.go:361 lxc/config.go:273 -#: lxc/config.go:348 lxc/config.go:1278 lxc/config_metadata.go:148 -#: lxc/config_trust.go:314 lxc/image.go:467 lxc/network.go:687 -#: lxc/network_acl.go:625 lxc/network_forward.go:690 -#: lxc/network_load_balancer.go:694 lxc/network_peer.go:611 -#: lxc/network_zone.go:556 lxc/network_zone.go:1152 lxc/profile.go:539 -#: lxc/project.go:339 lxc/storage.go:335 lxc/storage_bucket.go:349 -#: lxc/storage_bucket.go:1126 lxc/storage_volume.go:1039 -#: lxc/storage_volume.go:1071 +#: lxc/cluster.go:859 lxc/cluster_group.go:397 lxc/config.go:281 +#: lxc/config.go:356 lxc/config.go:1343 lxc/config_metadata.go:156 +#: lxc/config_trust.go:314 lxc/image.go:491 lxc/network.go:759 +#: lxc/network_acl.go:698 lxc/network_forward.go:767 +#: lxc/network_load_balancer.go:738 lxc/network_peer.go:698 +#: lxc/network_zone.go:621 lxc/network_zone.go:1316 lxc/profile.go:595 +#: lxc/project.go:364 lxc/storage.go:359 lxc/storage_bucket.go:349 +#: lxc/storage_bucket.go:1126 lxc/storage_volume.go:1156 +#: lxc/storage_volume.go:1188 #, c-format msgid "Config parsing error: %s" msgstr "" -#: lxc/storage_volume.go:566 +#: lxc/storage_volume.go:619 msgid "Content type, block or filesystem" msgstr "" -#: lxc/storage_volume.go:1322 +#: lxc/storage_volume.go:1467 #, c-format msgid "Content type: %s" msgstr "" -#: lxc/info.go:117 +#: lxc/info.go:125 #, c-format msgid "Control: %s (%s)" msgstr "" @@ -1273,15 +1273,15 @@ msgid "" "versions.\n" msgstr "" -#: lxc/config_device.go:356 lxc/config_device.go:357 +#: lxc/config_device.go:408 lxc/config_device.go:409 msgid "Copy profile inherited devices and override configuration keys" msgstr "" -#: lxc/profile.go:250 lxc/profile.go:251 +#: lxc/profile.go:270 lxc/profile.go:271 msgid "Copy profiles" msgstr "" -#: lxc/storage_volume.go:354 lxc/storage_volume.go:355 +#: lxc/storage_volume.go:389 lxc/storage_volume.go:390 msgid "Copy storage volumes" msgstr "" @@ -1289,12 +1289,12 @@ msgstr "" msgid "Copy the instance without its snapshots" msgstr "" -#: lxc/storage_volume.go:361 +#: lxc/storage_volume.go:396 msgid "Copy the volume without its snapshots" msgstr "" -#: lxc/copy.go:63 lxc/image.go:171 lxc/move.go:68 lxc/profile.go:253 -#: lxc/storage_volume.go:362 +#: lxc/copy.go:63 lxc/image.go:171 lxc/move.go:68 lxc/profile.go:273 +#: lxc/storage_volume.go:397 msgid "Copy to a project different from the source" msgstr "" @@ -1302,79 +1302,83 @@ msgstr "" msgid "Copy virtual machine images" msgstr "" -#: lxc/image.go:276 +#: lxc/image.go:288 #, c-format msgid "Copying the image: %s" msgstr "" -#: lxc/storage_volume.go:461 +#: lxc/storage_volume.go:514 #, c-format msgid "Copying the storage volume: %s" msgstr "" -#: lxc/info.go:317 +#: lxc/info.go:325 #, c-format msgid "Core %d" msgstr "" -#: lxc/info.go:315 +#: lxc/info.go:323 msgid "Cores:" msgstr "" -#: lxc/remote.go:556 +#: lxc/remote.go:574 #, c-format msgid "Could not close server cert file %q: %w" msgstr "" -#: lxc/remote.go:237 lxc/remote.go:540 +#: lxc/remote.go:237 lxc/remote.go:558 msgid "Could not create server cert dir" msgstr "" -#: lxc/cluster.go:1105 +#: lxc/cluster.go:1235 #, c-format msgid "Could not find certificate file path: %s" msgstr "" -#: lxc/cluster.go:1109 +#: lxc/cluster.go:1239 #, c-format msgid "Could not find certificate key file path: %s" msgstr "" -#: lxc/auth.go:301 lxc/auth.go:1661 +#: lxc/auth.go:306 lxc/auth.go:1868 #, c-format msgid "Could not parse group: %s" msgstr "" -#: lxc/auth.go:1055 +#: lxc/auth.go:1207 #, c-format msgid "Could not parse identity: %s" msgstr "" -#: lxc/cluster.go:1114 +#: lxc/cluster.go:1244 #, c-format msgid "Could not read certificate file: %s with error: %v" msgstr "" -#: lxc/cluster.go:1119 +#: lxc/cluster.go:1249 #, c-format msgid "Could not read certificate key file: %s with error: %v" msgstr "" -#: lxc/cluster.go:1136 +#: lxc/cluster.go:1266 #, c-format msgid "Could not write new remote certificate for remote '%s' with error: %v" msgstr "" -#: lxc/remote.go:551 +#: lxc/remote.go:569 #, c-format msgid "Could not write server cert file %q: %w" msgstr "" -#: lxc/network_zone.go:1336 +#: lxc/network_zone.go:1536 msgid "Couldn't find a matching entry" msgstr "" -#: lxc/cluster_group.go:157 lxc/cluster_group.go:158 +#: lxc/auth.go:776 +msgid "Create a TLS identity" +msgstr "" + +#: lxc/cluster_group.go:169 lxc/cluster_group.go:170 msgid "Create a cluster group" msgstr "" @@ -1390,11 +1394,15 @@ msgstr "" msgid "Create an empty instance" msgstr "" +#: lxc/auth.go:775 +msgid "Create an identity" +msgstr "" + #: lxc/launch.go:23 lxc/launch.go:24 msgid "Create and start instances from images" msgstr "" -#: lxc/file.go:143 lxc/file.go:438 lxc/file.go:664 +#: lxc/file.go:143 lxc/file.go:462 lxc/file.go:696 msgid "Create any directories necessary" msgstr "" @@ -1402,11 +1410,11 @@ msgstr "" msgid "Create files and directories in instances" msgstr "" -#: lxc/auth.go:98 lxc/auth.go:99 +#: lxc/auth.go:103 lxc/auth.go:104 msgid "Create groups" msgstr "" -#: lxc/auth.go:1472 lxc/auth.go:1473 +#: lxc/auth.go:1679 lxc/auth.go:1680 msgid "Create identity provider groups" msgstr "" @@ -1434,7 +1442,7 @@ msgstr "" msgid "Create new custom storage buckets" msgstr "" -#: lxc/storage_volume.go:557 lxc/storage_volume.go:558 +#: lxc/storage_volume.go:610 lxc/storage_volume.go:611 msgid "Create new custom storage volumes" msgstr "" @@ -1442,39 +1450,39 @@ msgstr "" msgid "Create new instance file templates" msgstr "" -#: lxc/network_acl.go:327 lxc/network_acl.go:328 +#: lxc/network_acl.go:363 lxc/network_acl.go:364 msgid "Create new network ACLs" msgstr "" -#: lxc/network_forward.go:235 lxc/network_forward.go:236 +#: lxc/network_forward.go:255 lxc/network_forward.go:256 msgid "Create new network forwards" msgstr "" -#: lxc/network_load_balancer.go:237 lxc/network_load_balancer.go:238 +#: lxc/network_load_balancer.go:257 lxc/network_load_balancer.go:258 msgid "Create new network load balancers" msgstr "" -#: lxc/network_peer.go:215 lxc/network_peer.go:216 +#: lxc/network_peer.go:235 lxc/network_peer.go:236 msgid "Create new network peering" msgstr "" -#: lxc/network_zone.go:874 lxc/network_zone.go:875 +#: lxc/network_zone.go:984 lxc/network_zone.go:985 msgid "Create new network zone record" msgstr "" -#: lxc/network_zone.go:271 lxc/network_zone.go:272 +#: lxc/network_zone.go:300 lxc/network_zone.go:301 msgid "Create new network zones" msgstr "" -#: lxc/network.go:293 lxc/network.go:294 +#: lxc/network.go:317 lxc/network.go:318 msgid "Create new networks" msgstr "" -#: lxc/profile.go:320 lxc/profile.go:321 +#: lxc/profile.go:352 lxc/profile.go:353 msgid "Create profiles" msgstr "" -#: lxc/project.go:93 lxc/project.go:94 +#: lxc/project.go:94 lxc/project.go:95 msgid "Create projects" msgstr "" @@ -1486,62 +1494,62 @@ msgstr "" msgid "Create the instance with no profiles applied" msgstr "" -#: lxc/image.go:971 lxc/info.go:489 lxc/storage_volume.go:1336 +#: lxc/image.go:1023 lxc/info.go:497 lxc/storage_volume.go:1481 #, c-format msgid "Created: %s" msgstr "" -#: lxc/init.go:178 +#: lxc/init.go:182 #, c-format msgid "Creating %s" msgstr "" -#: lxc/file.go:273 +#: lxc/file.go:281 #, c-format msgid "Creating %s: %%s" msgstr "" -#: lxc/init.go:176 +#: lxc/init.go:180 msgid "Creating the instance" msgstr "" -#: lxc/info.go:137 lxc/info.go:246 +#: lxc/info.go:145 lxc/info.go:254 #, c-format msgid "Current number of VFs: %d" msgstr "" -#: lxc/network_forward.go:150 +#: lxc/network_forward.go:158 msgid "DEFAULT TARGET ADDRESS" msgstr "" -#: lxc/auth.go:377 lxc/cluster.go:189 lxc/cluster_group.go:460 -#: lxc/image.go:1077 lxc/image_alias.go:237 lxc/list.go:557 lxc/network.go:986 -#: lxc/network_acl.go:149 lxc/network_forward.go:149 -#: lxc/network_load_balancer.go:152 lxc/network_peer.go:141 -#: lxc/network_zone.go:140 lxc/network_zone.go:747 lxc/operation.go:173 -#: lxc/profile.go:679 lxc/project.go:529 lxc/storage.go:671 +#: lxc/auth.go:382 lxc/cluster.go:197 lxc/cluster_group.go:504 +#: lxc/image.go:1139 lxc/image_alias.go:237 lxc/list.go:565 lxc/network.go:1086 +#: lxc/network_acl.go:157 lxc/network_forward.go:157 +#: lxc/network_load_balancer.go:160 lxc/network_peer.go:149 +#: lxc/network_zone.go:148 lxc/network_zone.go:828 lxc/operation.go:173 +#: lxc/profile.go:756 lxc/project.go:574 lxc/storage.go:723 #: lxc/storage_bucket.go:513 lxc/storage_bucket.go:833 -#: lxc/storage_volume.go:1585 +#: lxc/storage_volume.go:1738 msgid "DESCRIPTION" msgstr "" -#: lxc/list.go:558 +#: lxc/list.go:566 msgid "DISK USAGE" msgstr "" -#: lxc/storage.go:664 +#: lxc/storage.go:716 msgid "DRIVER" msgstr "" -#: lxc/info.go:109 +#: lxc/info.go:117 msgid "DRM:" msgstr "" -#: lxc/network.go:877 +#: lxc/network.go:969 msgid "Default VLAN ID" msgstr "" -#: lxc/storage_volume.go:2388 +#: lxc/storage_volume.go:2642 msgid "Define a compression algorithm: for backup or none" msgstr "" @@ -1549,7 +1557,7 @@ msgstr "" msgid "Delete a background operation (will attempt to cancel)" msgstr "" -#: lxc/cluster_group.go:235 lxc/cluster_group.go:236 +#: lxc/cluster_group.go:255 lxc/cluster_group.go:256 msgid "Delete a cluster group" msgstr "" @@ -1557,15 +1565,19 @@ msgstr "" msgid "Delete all warnings" msgstr "" -#: lxc/file.go:312 lxc/file.go:313 +#: lxc/auth.go:1237 lxc/auth.go:1238 +msgid "Delete an identity" +msgstr "" + +#: lxc/file.go:320 lxc/file.go:321 msgid "Delete files in instances" msgstr "" -#: lxc/auth.go:152 lxc/auth.go:153 +#: lxc/auth.go:157 lxc/auth.go:158 msgid "Delete groups" msgstr "" -#: lxc/auth.go:1524 lxc/auth.go:1525 +#: lxc/auth.go:1731 lxc/auth.go:1732 msgid "Delete identity provider groups" msgstr "" @@ -1573,11 +1585,11 @@ msgstr "" msgid "Delete image aliases" msgstr "" -#: lxc/image.go:324 lxc/image.go:325 +#: lxc/image.go:336 lxc/image.go:337 msgid "Delete images" msgstr "" -#: lxc/config_template.go:110 lxc/config_template.go:111 +#: lxc/config_template.go:118 lxc/config_template.go:119 msgid "Delete instance file templates" msgstr "" @@ -1589,39 +1601,39 @@ msgstr "" msgid "Delete key from a storage bucket" msgstr "" -#: lxc/network_acl.go:706 lxc/network_acl.go:707 +#: lxc/network_acl.go:787 lxc/network_acl.go:788 msgid "Delete network ACLs" msgstr "" -#: lxc/network_forward.go:722 lxc/network_forward.go:723 +#: lxc/network_forward.go:799 lxc/network_forward.go:800 msgid "Delete network forwards" msgstr "" -#: lxc/network_load_balancer.go:726 lxc/network_load_balancer.go:727 +#: lxc/network_load_balancer.go:770 lxc/network_load_balancer.go:771 msgid "Delete network load balancers" msgstr "" -#: lxc/network_peer.go:643 lxc/network_peer.go:644 +#: lxc/network_peer.go:730 lxc/network_peer.go:731 msgid "Delete network peerings" msgstr "" -#: lxc/network_zone.go:1184 lxc/network_zone.go:1185 +#: lxc/network_zone.go:1348 lxc/network_zone.go:1349 msgid "Delete network zone record" msgstr "" -#: lxc/network_zone.go:588 lxc/network_zone.go:589 +#: lxc/network_zone.go:653 lxc/network_zone.go:654 msgid "Delete network zones" msgstr "" -#: lxc/network.go:372 lxc/network.go:373 +#: lxc/network.go:404 lxc/network.go:405 msgid "Delete networks" msgstr "" -#: lxc/profile.go:394 lxc/profile.go:395 +#: lxc/profile.go:434 lxc/profile.go:435 msgid "Delete profiles" msgstr "" -#: lxc/project.go:181 lxc/project.go:182 +#: lxc/project.go:190 lxc/project.go:191 msgid "Delete projects" msgstr "" @@ -1629,11 +1641,11 @@ msgstr "" msgid "Delete storage buckets" msgstr "" -#: lxc/storage.go:194 lxc/storage.go:195 +#: lxc/storage.go:202 lxc/storage.go:203 msgid "Delete storage pools" msgstr "" -#: lxc/storage_volume.go:658 lxc/storage_volume.go:659 +#: lxc/storage_volume.go:719 lxc/storage_volume.go:720 msgid "Delete storage volumes" msgstr "" @@ -1641,154 +1653,154 @@ msgstr "" msgid "Delete warning" msgstr "" -#: lxc/action.go:33 lxc/action.go:54 lxc/action.go:76 lxc/action.go:99 +#: lxc/action.go:33 lxc/action.go:58 lxc/action.go:84 lxc/action.go:111 #: lxc/alias.go:23 lxc/alias.go:60 lxc/alias.go:110 lxc/alias.go:159 -#: lxc/alias.go:214 lxc/auth.go:31 lxc/auth.go:60 lxc/auth.go:99 -#: lxc/auth.go:153 lxc/auth.go:202 lxc/auth.go:333 lxc/auth.go:393 -#: lxc/auth.go:442 lxc/auth.go:494 lxc/auth.go:517 lxc/auth.go:576 -#: lxc/auth.go:732 lxc/auth.go:766 lxc/auth.go:833 lxc/auth.go:896 -#: lxc/auth.go:957 lxc/auth.go:1085 lxc/auth.go:1108 lxc/auth.go:1166 -#: lxc/auth.go:1235 lxc/auth.go:1257 lxc/auth.go:1435 lxc/auth.go:1473 -#: lxc/auth.go:1525 lxc/auth.go:1574 lxc/auth.go:1693 lxc/auth.go:1753 -#: lxc/auth.go:1802 lxc/auth.go:1853 lxc/auth.go:1876 lxc/auth.go:1929 -#: lxc/cluster.go:30 lxc/cluster.go:123 lxc/cluster.go:207 lxc/cluster.go:256 -#: lxc/cluster.go:307 lxc/cluster.go:368 lxc/cluster.go:440 lxc/cluster.go:472 -#: lxc/cluster.go:522 lxc/cluster.go:605 lxc/cluster.go:690 lxc/cluster.go:805 -#: lxc/cluster.go:881 lxc/cluster.go:983 lxc/cluster.go:1062 -#: lxc/cluster.go:1169 lxc/cluster.go:1191 lxc/cluster_group.go:31 -#: lxc/cluster_group.go:85 lxc/cluster_group.go:158 lxc/cluster_group.go:236 -#: lxc/cluster_group.go:288 lxc/cluster_group.go:404 lxc/cluster_group.go:478 -#: lxc/cluster_group.go:551 lxc/cluster_group.go:599 lxc/cluster_group.go:653 -#: lxc/cluster_role.go:24 lxc/cluster_role.go:51 lxc/cluster_role.go:107 -#: lxc/config.go:33 lxc/config.go:100 lxc/config.go:385 lxc/config.go:518 -#: lxc/config.go:735 lxc/config.go:859 lxc/config.go:894 lxc/config.go:934 -#: lxc/config.go:989 lxc/config.go:1080 lxc/config.go:1111 lxc/config.go:1165 -#: lxc/config_device.go:25 lxc/config_device.go:79 lxc/config_device.go:209 -#: lxc/config_device.go:286 lxc/config_device.go:357 lxc/config_device.go:451 -#: lxc/config_device.go:549 lxc/config_device.go:556 lxc/config_device.go:669 -#: lxc/config_device.go:742 lxc/config_metadata.go:28 lxc/config_metadata.go:56 -#: lxc/config_metadata.go:181 lxc/config_template.go:28 -#: lxc/config_template.go:68 lxc/config_template.go:111 -#: lxc/config_template.go:153 lxc/config_template.go:241 -#: lxc/config_template.go:301 lxc/config_trust.go:34 lxc/config_trust.go:87 -#: lxc/config_trust.go:236 lxc/config_trust.go:350 lxc/config_trust.go:432 -#: lxc/config_trust.go:534 lxc/config_trust.go:580 lxc/config_trust.go:651 -#: lxc/console.go:37 lxc/copy.go:42 lxc/delete.go:32 lxc/exec.go:41 -#: lxc/export.go:32 lxc/file.go:88 lxc/file.go:135 lxc/file.go:313 -#: lxc/file.go:362 lxc/file.go:432 lxc/file.go:657 lxc/file.go:1176 -#: lxc/image.go:38 lxc/image.go:159 lxc/image.go:325 lxc/image.go:380 -#: lxc/image.go:501 lxc/image.go:665 lxc/image.go:904 lxc/image.go:1038 -#: lxc/image.go:1357 lxc/image.go:1444 lxc/image.go:1502 lxc/image.go:1553 -#: lxc/image.go:1608 lxc/image_alias.go:24 lxc/image_alias.go:60 -#: lxc/image_alias.go:107 lxc/image_alias.go:152 lxc/image_alias.go:255 -#: lxc/import.go:29 lxc/info.go:33 lxc/init.go:44 lxc/launch.go:24 -#: lxc/list.go:49 lxc/main.go:83 lxc/manpage.go:22 lxc/monitor.go:34 -#: lxc/move.go:38 lxc/network.go:33 lxc/network.go:136 lxc/network.go:221 -#: lxc/network.go:294 lxc/network.go:373 lxc/network.go:423 lxc/network.go:508 -#: lxc/network.go:593 lxc/network.go:721 lxc/network.go:790 lxc/network.go:913 -#: lxc/network.go:1006 lxc/network.go:1077 lxc/network.go:1129 -#: lxc/network.go:1217 lxc/network.go:1281 lxc/network_acl.go:30 -#: lxc/network_acl.go:95 lxc/network_acl.go:166 lxc/network_acl.go:219 -#: lxc/network_acl.go:267 lxc/network_acl.go:328 lxc/network_acl.go:417 -#: lxc/network_acl.go:497 lxc/network_acl.go:527 lxc/network_acl.go:658 -#: lxc/network_acl.go:707 lxc/network_acl.go:756 lxc/network_acl.go:771 -#: lxc/network_acl.go:892 lxc/network_allocations.go:53 +#: lxc/alias.go:214 lxc/auth.go:36 lxc/auth.go:65 lxc/auth.go:104 +#: lxc/auth.go:158 lxc/auth.go:207 lxc/auth.go:338 lxc/auth.go:398 +#: lxc/auth.go:447 lxc/auth.go:499 lxc/auth.go:522 lxc/auth.go:581 +#: lxc/auth.go:737 lxc/auth.go:776 lxc/auth.go:918 lxc/auth.go:985 +#: lxc/auth.go:1048 lxc/auth.go:1109 lxc/auth.go:1238 lxc/auth.go:1292 +#: lxc/auth.go:1315 lxc/auth.go:1373 lxc/auth.go:1442 lxc/auth.go:1464 +#: lxc/auth.go:1642 lxc/auth.go:1680 lxc/auth.go:1732 lxc/auth.go:1781 +#: lxc/auth.go:1900 lxc/auth.go:1960 lxc/auth.go:2009 lxc/auth.go:2060 +#: lxc/auth.go:2083 lxc/auth.go:2136 lxc/cluster.go:30 lxc/cluster.go:123 +#: lxc/cluster.go:215 lxc/cluster.go:272 lxc/cluster.go:331 lxc/cluster.go:404 +#: lxc/cluster.go:484 lxc/cluster.go:528 lxc/cluster.go:586 lxc/cluster.go:677 +#: lxc/cluster.go:770 lxc/cluster.go:893 lxc/cluster.go:977 lxc/cluster.go:1087 +#: lxc/cluster.go:1175 lxc/cluster.go:1299 lxc/cluster.go:1329 +#: lxc/cluster_group.go:31 lxc/cluster_group.go:85 lxc/cluster_group.go:170 +#: lxc/cluster_group.go:256 lxc/cluster_group.go:316 lxc/cluster_group.go:440 +#: lxc/cluster_group.go:522 lxc/cluster_group.go:607 lxc/cluster_group.go:663 +#: lxc/cluster_group.go:725 lxc/cluster_role.go:24 lxc/cluster_role.go:51 +#: lxc/cluster_role.go:115 lxc/config.go:33 lxc/config.go:100 lxc/config.go:393 +#: lxc/config.go:542 lxc/config.go:775 lxc/config.go:907 lxc/config.go:959 +#: lxc/config.go:999 lxc/config.go:1054 lxc/config.go:1145 lxc/config.go:1176 +#: lxc/config.go:1230 lxc/config_device.go:25 lxc/config_device.go:79 +#: lxc/config_device.go:229 lxc/config_device.go:326 lxc/config_device.go:409 +#: lxc/config_device.go:511 lxc/config_device.go:627 lxc/config_device.go:634 +#: lxc/config_device.go:767 lxc/config_device.go:852 lxc/config_metadata.go:28 +#: lxc/config_metadata.go:56 lxc/config_metadata.go:189 +#: lxc/config_template.go:28 lxc/config_template.go:68 +#: lxc/config_template.go:119 lxc/config_template.go:173 +#: lxc/config_template.go:273 lxc/config_template.go:341 lxc/config_trust.go:34 +#: lxc/config_trust.go:87 lxc/config_trust.go:236 lxc/config_trust.go:350 +#: lxc/config_trust.go:432 lxc/config_trust.go:534 lxc/config_trust.go:580 +#: lxc/config_trust.go:651 lxc/console.go:40 lxc/copy.go:42 lxc/delete.go:32 +#: lxc/exec.go:41 lxc/export.go:32 lxc/file.go:88 lxc/file.go:135 +#: lxc/file.go:321 lxc/file.go:378 lxc/file.go:456 lxc/file.go:689 +#: lxc/file.go:1208 lxc/image.go:38 lxc/image.go:159 lxc/image.go:337 +#: lxc/image.go:396 lxc/image.go:525 lxc/image.go:697 lxc/image.go:948 +#: lxc/image.go:1091 lxc/image.go:1450 lxc/image.go:1541 lxc/image.go:1607 +#: lxc/image.go:1671 lxc/image.go:1734 lxc/image_alias.go:24 +#: lxc/image_alias.go:60 lxc/image_alias.go:107 lxc/image_alias.go:152 +#: lxc/image_alias.go:255 lxc/import.go:29 lxc/info.go:33 lxc/init.go:44 +#: lxc/launch.go:24 lxc/list.go:49 lxc/main.go:83 lxc/manpage.go:22 +#: lxc/monitor.go:34 lxc/move.go:38 lxc/network.go:33 lxc/network.go:136 +#: lxc/network.go:233 lxc/network.go:318 lxc/network.go:405 lxc/network.go:463 +#: lxc/network.go:560 lxc/network.go:657 lxc/network.go:793 lxc/network.go:874 +#: lxc/network.go:1005 lxc/network.go:1106 lxc/network.go:1185 +#: lxc/network.go:1245 lxc/network.go:1341 lxc/network.go:1413 +#: lxc/network_acl.go:30 lxc/network_acl.go:95 lxc/network_acl.go:174 +#: lxc/network_acl.go:235 lxc/network_acl.go:291 lxc/network_acl.go:364 +#: lxc/network_acl.go:461 lxc/network_acl.go:549 lxc/network_acl.go:592 +#: lxc/network_acl.go:731 lxc/network_acl.go:788 lxc/network_acl.go:845 +#: lxc/network_acl.go:860 lxc/network_acl.go:997 lxc/network_allocations.go:53 #: lxc/network_forward.go:33 lxc/network_forward.go:90 -#: lxc/network_forward.go:171 lxc/network_forward.go:236 -#: lxc/network_forward.go:384 lxc/network_forward.go:453 -#: lxc/network_forward.go:551 lxc/network_forward.go:581 -#: lxc/network_forward.go:723 lxc/network_forward.go:785 -#: lxc/network_forward.go:800 lxc/network_forward.go:865 +#: lxc/network_forward.go:179 lxc/network_forward.go:256 +#: lxc/network_forward.go:404 lxc/network_forward.go:489 +#: lxc/network_forward.go:599 lxc/network_forward.go:646 +#: lxc/network_forward.go:800 lxc/network_forward.go:874 +#: lxc/network_forward.go:889 lxc/network_forward.go:970 #: lxc/network_load_balancer.go:33 lxc/network_load_balancer.go:94 -#: lxc/network_load_balancer.go:173 lxc/network_load_balancer.go:238 -#: lxc/network_load_balancer.go:388 lxc/network_load_balancer.go:456 -#: lxc/network_load_balancer.go:554 lxc/network_load_balancer.go:584 -#: lxc/network_load_balancer.go:727 lxc/network_load_balancer.go:788 -#: lxc/network_load_balancer.go:803 lxc/network_load_balancer.go:867 -#: lxc/network_load_balancer.go:953 lxc/network_load_balancer.go:968 -#: lxc/network_load_balancer.go:1029 lxc/network_peer.go:29 -#: lxc/network_peer.go:82 lxc/network_peer.go:159 lxc/network_peer.go:216 -#: lxc/network_peer.go:332 lxc/network_peer.go:400 lxc/network_peer.go:489 -#: lxc/network_peer.go:519 lxc/network_peer.go:644 lxc/network_zone.go:29 -#: lxc/network_zone.go:86 lxc/network_zone.go:157 lxc/network_zone.go:212 -#: lxc/network_zone.go:272 lxc/network_zone.go:359 lxc/network_zone.go:439 -#: lxc/network_zone.go:470 lxc/network_zone.go:589 lxc/network_zone.go:637 -#: lxc/network_zone.go:694 lxc/network_zone.go:764 lxc/network_zone.go:816 -#: lxc/network_zone.go:875 lxc/network_zone.go:961 lxc/network_zone.go:1037 -#: lxc/network_zone.go:1067 lxc/network_zone.go:1185 lxc/network_zone.go:1234 -#: lxc/network_zone.go:1249 lxc/network_zone.go:1295 lxc/operation.go:25 +#: lxc/network_load_balancer.go:181 lxc/network_load_balancer.go:258 +#: lxc/network_load_balancer.go:408 lxc/network_load_balancer.go:476 +#: lxc/network_load_balancer.go:586 lxc/network_load_balancer.go:616 +#: lxc/network_load_balancer.go:771 lxc/network_load_balancer.go:844 +#: lxc/network_load_balancer.go:859 lxc/network_load_balancer.go:935 +#: lxc/network_load_balancer.go:1033 lxc/network_load_balancer.go:1048 +#: lxc/network_load_balancer.go:1121 lxc/network_peer.go:29 +#: lxc/network_peer.go:82 lxc/network_peer.go:167 lxc/network_peer.go:236 +#: lxc/network_peer.go:360 lxc/network_peer.go:445 lxc/network_peer.go:547 +#: lxc/network_peer.go:594 lxc/network_peer.go:731 lxc/network_zone.go:29 +#: lxc/network_zone.go:86 lxc/network_zone.go:165 lxc/network_zone.go:228 +#: lxc/network_zone.go:301 lxc/network_zone.go:396 lxc/network_zone.go:484 +#: lxc/network_zone.go:527 lxc/network_zone.go:654 lxc/network_zone.go:710 +#: lxc/network_zone.go:767 lxc/network_zone.go:845 lxc/network_zone.go:909 +#: lxc/network_zone.go:985 lxc/network_zone.go:1083 lxc/network_zone.go:1172 +#: lxc/network_zone.go:1219 lxc/network_zone.go:1349 lxc/network_zone.go:1410 +#: lxc/network_zone.go:1425 lxc/network_zone.go:1483 lxc/operation.go:25 #: lxc/operation.go:57 lxc/operation.go:107 lxc/operation.go:194 -#: lxc/profile.go:30 lxc/profile.go:105 lxc/profile.go:168 lxc/profile.go:251 -#: lxc/profile.go:321 lxc/profile.go:395 lxc/profile.go:445 lxc/profile.go:573 -#: lxc/profile.go:634 lxc/profile.go:695 lxc/profile.go:771 lxc/profile.go:823 -#: lxc/profile.go:899 lxc/profile.go:955 lxc/project.go:30 lxc/project.go:94 -#: lxc/project.go:182 lxc/project.go:245 lxc/project.go:373 lxc/project.go:434 -#: lxc/project.go:547 lxc/project.go:604 lxc/project.go:683 lxc/project.go:714 -#: lxc/project.go:767 lxc/project.go:826 lxc/publish.go:34 lxc/query.go:34 -#: lxc/rebuild.go:28 lxc/remote.go:35 lxc/remote.go:91 lxc/remote.go:701 -#: lxc/remote.go:739 lxc/remote.go:825 lxc/remote.go:898 lxc/remote.go:954 -#: lxc/remote.go:994 lxc/rename.go:22 lxc/restore.go:24 lxc/snapshot.go:32 -#: lxc/storage.go:34 lxc/storage.go:97 lxc/storage.go:195 lxc/storage.go:245 -#: lxc/storage.go:369 lxc/storage.go:439 lxc/storage.go:611 lxc/storage.go:690 -#: lxc/storage.go:786 lxc/storage.go:872 lxc/storage_bucket.go:30 +#: lxc/profile.go:30 lxc/profile.go:105 lxc/profile.go:180 lxc/profile.go:271 +#: lxc/profile.go:353 lxc/profile.go:435 lxc/profile.go:493 lxc/profile.go:629 +#: lxc/profile.go:703 lxc/profile.go:772 lxc/profile.go:860 lxc/profile.go:920 +#: lxc/profile.go:1009 lxc/profile.go:1073 lxc/project.go:31 lxc/project.go:95 +#: lxc/project.go:191 lxc/project.go:262 lxc/project.go:398 lxc/project.go:472 +#: lxc/project.go:592 lxc/project.go:657 lxc/project.go:745 lxc/project.go:789 +#: lxc/project.go:850 lxc/project.go:917 lxc/publish.go:34 lxc/query.go:34 +#: lxc/rebuild.go:28 lxc/remote.go:35 lxc/remote.go:91 lxc/remote.go:750 +#: lxc/remote.go:788 lxc/remote.go:874 lxc/remote.go:955 lxc/remote.go:1019 +#: lxc/remote.go:1067 lxc/rename.go:22 lxc/restore.go:24 lxc/snapshot.go:32 +#: lxc/storage.go:34 lxc/storage.go:97 lxc/storage.go:203 lxc/storage.go:261 +#: lxc/storage.go:393 lxc/storage.go:475 lxc/storage.go:655 lxc/storage.go:742 +#: lxc/storage.go:846 lxc/storage.go:940 lxc/storage_bucket.go:30 #: lxc/storage_bucket.go:84 lxc/storage_bucket.go:189 lxc/storage_bucket.go:250 #: lxc/storage_bucket.go:383 lxc/storage_bucket.go:459 #: lxc/storage_bucket.go:536 lxc/storage_bucket.go:630 #: lxc/storage_bucket.go:699 lxc/storage_bucket.go:733 #: lxc/storage_bucket.go:774 lxc/storage_bucket.go:853 #: lxc/storage_bucket.go:959 lxc/storage_bucket.go:1023 -#: lxc/storage_bucket.go:1158 lxc/storage_volume.go:44 -#: lxc/storage_volume.go:166 lxc/storage_volume.go:264 -#: lxc/storage_volume.go:355 lxc/storage_volume.go:558 -#: lxc/storage_volume.go:659 lxc/storage_volume.go:734 -#: lxc/storage_volume.go:816 lxc/storage_volume.go:897 -#: lxc/storage_volume.go:1106 lxc/storage_volume.go:1221 -#: lxc/storage_volume.go:1368 lxc/storage_volume.go:1452 -#: lxc/storage_volume.go:1697 lxc/storage_volume.go:1778 -#: lxc/storage_volume.go:1893 lxc/storage_volume.go:2037 -#: lxc/storage_volume.go:2146 lxc/storage_volume.go:2192 -#: lxc/storage_volume.go:2315 lxc/storage_volume.go:2382 -#: lxc/storage_volume.go:2536 lxc/version.go:22 lxc/warning.go:30 +#: lxc/storage_bucket.go:1158 lxc/storage_volume.go:58 +#: lxc/storage_volume.go:169 lxc/storage_volume.go:283 +#: lxc/storage_volume.go:390 lxc/storage_volume.go:611 +#: lxc/storage_volume.go:720 lxc/storage_volume.go:807 +#: lxc/storage_volume.go:905 lxc/storage_volume.go:1002 +#: lxc/storage_volume.go:1223 lxc/storage_volume.go:1354 +#: lxc/storage_volume.go:1513 lxc/storage_volume.go:1597 +#: lxc/storage_volume.go:1850 lxc/storage_volume.go:1949 +#: lxc/storage_volume.go:2076 lxc/storage_volume.go:2234 +#: lxc/storage_volume.go:2355 lxc/storage_volume.go:2417 +#: lxc/storage_volume.go:2553 lxc/storage_volume.go:2636 +#: lxc/storage_volume.go:2802 lxc/version.go:22 lxc/warning.go:30 #: lxc/warning.go:72 lxc/warning.go:263 lxc/warning.go:304 lxc/warning.go:358 msgid "Description" msgstr "" -#: lxc/storage_volume.go:1309 +#: lxc/storage_volume.go:1454 #, c-format msgid "Description: %s" msgstr "" -#: lxc/storage_volume.go:360 lxc/storage_volume.go:1702 +#: lxc/storage_volume.go:395 lxc/storage_volume.go:1855 msgid "Destination cluster member name" msgstr "" -#: lxc/network.go:422 lxc/network.go:423 +#: lxc/network.go:462 lxc/network.go:463 msgid "Detach network interfaces from instances" msgstr "" -#: lxc/network.go:507 lxc/network.go:508 +#: lxc/network.go:559 lxc/network.go:560 msgid "Detach network interfaces from profiles" msgstr "" -#: lxc/storage_volume.go:733 lxc/storage_volume.go:734 +#: lxc/storage_volume.go:806 lxc/storage_volume.go:807 msgid "Detach storage volumes from instances" msgstr "" -#: lxc/storage_volume.go:815 lxc/storage_volume.go:816 +#: lxc/storage_volume.go:904 lxc/storage_volume.go:905 msgid "Detach storage volumes from profiles" msgstr "" -#: lxc/config_device.go:186 +#: lxc/config_device.go:206 #, c-format msgid "Device %s added to %s" msgstr "" -#: lxc/config_device.go:427 +#: lxc/config_device.go:487 #, c-format msgid "Device %s overridden for %s" msgstr "" -#: lxc/config_device.go:530 +#: lxc/config_device.go:608 #, c-format msgid "Device %s removed from %s" msgstr "" @@ -1798,37 +1810,37 @@ msgstr "" msgid "Device already exists: %s" msgstr "" -#: lxc/config_device.go:248 lxc/config_device.go:262 lxc/config_device.go:488 -#: lxc/config_device.go:509 lxc/config_device.go:603 lxc/config_device.go:626 +#: lxc/config_device.go:288 lxc/config_device.go:302 lxc/config_device.go:566 +#: lxc/config_device.go:587 lxc/config_device.go:701 lxc/config_device.go:724 msgid "Device doesn't exist" msgstr "" -#: lxc/config_device.go:629 +#: lxc/config_device.go:727 msgid "" "Device from profile(s) cannot be modified for individual instance. Override " "device or modify profile instead" msgstr "" -#: lxc/config_device.go:512 +#: lxc/config_device.go:590 msgid "" "Device from profile(s) cannot be removed from individual instance. Override " "device or modify profile instead" msgstr "" -#: lxc/config_device.go:265 +#: lxc/config_device.go:305 msgid "Device from profile(s) cannot be retrieved for individual instance" msgstr "" -#: lxc/info.go:266 lxc/info.go:291 +#: lxc/info.go:274 lxc/info.go:299 #, c-format msgid "Device: %s" msgstr "" -#: lxc/init.go:394 +#: lxc/init.go:403 msgid "Didn't get any affected image, instance or snapshot from server" msgstr "" -#: lxc/image.go:682 +#: lxc/image.go:726 msgid "Directory import is not available on this platform" msgstr "" @@ -1836,7 +1848,7 @@ msgstr "" msgid "Directory to run the command in (default /root)" msgstr "" -#: lxc/file.go:1184 +#: lxc/file.go:1216 msgid "Disable authentication when using SSH SFTP listener" msgstr "" @@ -1848,53 +1860,57 @@ msgstr "" msgid "Disable stdin (reads from /dev/null)" msgstr "" -#: lxc/info.go:427 +#: lxc/info.go:435 #, c-format msgid "Disk %d:" msgstr "" -#: lxc/info.go:512 +#: lxc/info.go:520 msgid "Disk usage:" msgstr "" -#: lxc/info.go:422 +#: lxc/info.go:430 msgid "Disk:" msgstr "" -#: lxc/info.go:425 +#: lxc/info.go:433 msgid "Disks:" msgstr "" +#: lxc/image.go:1119 +msgid "Display images from all projects" +msgstr "" + #: lxc/list.go:135 msgid "Display instances from all projects" msgstr "" -#: lxc/cluster.go:527 +#: lxc/cluster.go:591 msgid "Don't require user confirmation for using --force" msgstr "" -#: lxc/main.go:100 +#: lxc/main.go:99 msgid "Don't show progress information" msgstr "" -#: lxc/network.go:864 +#: lxc/network.go:956 msgid "Down delay" msgstr "" -#: lxc/info.go:105 lxc/info.go:191 +#: lxc/info.go:113 lxc/info.go:199 #, c-format msgid "Driver: %v (%v)" msgstr "" -#: lxc/network_zone.go:748 +#: lxc/network_zone.go:829 msgid "ENTRIES" msgstr "" -#: lxc/list.go:866 +#: lxc/list.go:874 msgid "EPHEMERAL" msgstr "" -#: lxc/cluster.go:967 lxc/config_trust.go:516 +#: lxc/cluster.go:1071 lxc/config_trust.go:516 msgid "EXPIRES AT" msgstr "" @@ -1908,39 +1924,39 @@ msgid "" "(interrupt two more times to force)" msgstr "" -#: lxc/cluster_group.go:287 lxc/cluster_group.go:288 +#: lxc/cluster_group.go:315 lxc/cluster_group.go:316 msgid "Edit a cluster group" msgstr "" -#: lxc/auth.go:956 lxc/auth.go:957 +#: lxc/auth.go:1108 lxc/auth.go:1109 msgid "Edit an identity as YAML" msgstr "" -#: lxc/cluster.go:689 lxc/cluster.go:690 +#: lxc/cluster.go:769 lxc/cluster.go:770 msgid "Edit cluster member configurations as YAML" msgstr "" -#: lxc/file.go:361 lxc/file.go:362 +#: lxc/file.go:377 lxc/file.go:378 msgid "Edit files in instances" msgstr "" -#: lxc/auth.go:201 lxc/auth.go:202 +#: lxc/auth.go:206 lxc/auth.go:207 msgid "Edit groups as YAML" msgstr "" -#: lxc/auth.go:1573 lxc/auth.go:1574 +#: lxc/auth.go:1780 lxc/auth.go:1781 msgid "Edit identity provider groups as YAML" msgstr "" -#: lxc/image.go:379 lxc/image.go:380 +#: lxc/image.go:395 lxc/image.go:396 msgid "Edit image properties" msgstr "" -#: lxc/config.go:1164 lxc/config.go:1165 +#: lxc/config.go:1229 lxc/config.go:1230 msgid "Edit instance UEFI variables" msgstr "" -#: lxc/config_template.go:152 lxc/config_template.go:153 +#: lxc/config_template.go:172 lxc/config_template.go:173 msgid "Edit instance file templates" msgstr "" @@ -1952,39 +1968,39 @@ msgstr "" msgid "Edit instance or server configurations as YAML" msgstr "" -#: lxc/network_acl.go:526 lxc/network_acl.go:527 +#: lxc/network_acl.go:591 lxc/network_acl.go:592 msgid "Edit network ACL configurations as YAML" msgstr "" -#: lxc/network.go:592 lxc/network.go:593 +#: lxc/network.go:656 lxc/network.go:657 msgid "Edit network configurations as YAML" msgstr "" -#: lxc/network_forward.go:580 lxc/network_forward.go:581 +#: lxc/network_forward.go:645 lxc/network_forward.go:646 msgid "Edit network forward configurations as YAML" msgstr "" -#: lxc/network_load_balancer.go:583 lxc/network_load_balancer.go:584 +#: lxc/network_load_balancer.go:615 lxc/network_load_balancer.go:616 msgid "Edit network load balancer configurations as YAML" msgstr "" -#: lxc/network_peer.go:518 lxc/network_peer.go:519 +#: lxc/network_peer.go:593 lxc/network_peer.go:594 msgid "Edit network peer configurations as YAML" msgstr "" -#: lxc/network_zone.go:469 lxc/network_zone.go:470 +#: lxc/network_zone.go:526 lxc/network_zone.go:527 msgid "Edit network zone configurations as YAML" msgstr "" -#: lxc/network_zone.go:1066 lxc/network_zone.go:1067 +#: lxc/network_zone.go:1218 lxc/network_zone.go:1219 msgid "Edit network zone record configurations as YAML" msgstr "" -#: lxc/profile.go:444 lxc/profile.go:445 +#: lxc/profile.go:492 lxc/profile.go:493 msgid "Edit profile configurations as YAML" msgstr "" -#: lxc/project.go:244 lxc/project.go:245 +#: lxc/project.go:261 lxc/project.go:262 msgid "Edit project configurations as YAML" msgstr "" @@ -1996,11 +2012,11 @@ msgstr "" msgid "Edit storage bucket key as YAML" msgstr "" -#: lxc/storage.go:244 lxc/storage.go:245 +#: lxc/storage.go:260 lxc/storage.go:261 msgid "Edit storage pool configurations as YAML" msgstr "" -#: lxc/storage_volume.go:896 lxc/storage_volume.go:897 +#: lxc/storage_volume.go:1001 lxc/storage_volume.go:1002 msgid "Edit storage volume configurations as YAML" msgstr "" @@ -2008,17 +2024,17 @@ msgstr "" msgid "Edit trust configurations as YAML" msgstr "" -#: lxc/image.go:1089 lxc/list.go:614 lxc/storage_volume.go:1619 +#: lxc/image.go:1161 lxc/list.go:622 lxc/storage_volume.go:1772 #: lxc/warning.go:236 #, c-format msgid "Empty column entry (redundant, leading or trailing command) in '%s'" msgstr "" -#: lxc/cluster.go:604 +#: lxc/cluster.go:676 msgid "Enable clustering on a single non-clustered LXD server" msgstr "" -#: lxc/cluster.go:605 +#: lxc/cluster.go:677 msgid "" "Enable clustering on a single non-clustered LXD server\n" "\n" @@ -2033,7 +2049,7 @@ msgid "" " for the address if not yet set." msgstr "" -#: lxc/network_zone.go:1251 +#: lxc/network_zone.go:1427 msgid "Entry TTL" msgstr "" @@ -2055,46 +2071,46 @@ msgstr "" msgid "Error decoding data: %v" msgstr "" -#: lxc/publish.go:235 +#: lxc/publish.go:247 #, c-format msgid "Error retrieving aliases: %w" msgstr "" -#: lxc/cluster.go:415 lxc/config.go:625 lxc/config.go:657 lxc/network.go:1195 -#: lxc/network_acl.go:472 lxc/network_forward.go:524 -#: lxc/network_load_balancer.go:527 lxc/network_peer.go:464 -#: lxc/network_zone.go:414 lxc/network_zone.go:1012 lxc/profile.go:877 -#: lxc/project.go:658 lxc/storage.go:752 lxc/storage_bucket.go:603 -#: lxc/storage_volume.go:1970 lxc/storage_volume.go:2008 +#: lxc/cluster.go:459 lxc/config.go:665 lxc/config.go:697 lxc/network.go:1319 +#: lxc/network_acl.go:524 lxc/network_forward.go:572 +#: lxc/network_load_balancer.go:559 lxc/network_peer.go:522 +#: lxc/network_zone.go:459 lxc/network_zone.go:1147 lxc/profile.go:987 +#: lxc/project.go:720 lxc/storage.go:812 lxc/storage_bucket.go:603 +#: lxc/storage_volume.go:2167 lxc/storage_volume.go:2205 #, c-format msgid "Error setting properties: %v" msgstr "" -#: lxc/config.go:619 lxc/config.go:651 +#: lxc/config.go:659 lxc/config.go:691 #, c-format msgid "Error unsetting properties: %v" msgstr "" -#: lxc/cluster.go:409 lxc/network.go:1189 lxc/network_acl.go:466 -#: lxc/network_forward.go:518 lxc/network_load_balancer.go:521 -#: lxc/network_peer.go:458 lxc/network_zone.go:408 lxc/network_zone.go:1006 -#: lxc/profile.go:871 lxc/project.go:652 lxc/storage.go:746 -#: lxc/storage_bucket.go:597 lxc/storage_volume.go:1964 -#: lxc/storage_volume.go:2002 +#: lxc/cluster.go:453 lxc/network.go:1313 lxc/network_acl.go:518 +#: lxc/network_forward.go:566 lxc/network_load_balancer.go:553 +#: lxc/network_peer.go:516 lxc/network_zone.go:453 lxc/network_zone.go:1141 +#: lxc/profile.go:981 lxc/project.go:714 lxc/storage.go:806 +#: lxc/storage_bucket.go:597 lxc/storage_volume.go:2161 +#: lxc/storage_volume.go:2199 #, c-format msgid "Error unsetting property: %v" msgstr "" -#: lxc/config_template.go:206 +#: lxc/config_template.go:238 #, c-format msgid "Error updating template file: %s" msgstr "" -#: lxc/cluster.go:1168 lxc/cluster.go:1169 +#: lxc/cluster.go:1298 lxc/cluster.go:1299 msgid "Evacuate cluster member" msgstr "" -#: lxc/cluster.go:1250 +#: lxc/cluster.go:1396 #, c-format msgid "Evacuating cluster member: %s" msgstr "" @@ -2122,32 +2138,32 @@ msgid "" "AND stdout are terminals (stderr is ignored)." msgstr "" -#: lxc/info.go:632 lxc/info.go:683 lxc/storage_volume.go:1369 -#: lxc/storage_volume.go:1419 +#: lxc/info.go:640 lxc/info.go:691 lxc/storage_volume.go:1514 +#: lxc/storage_volume.go:1564 msgid "Expires at" msgstr "" -#: lxc/image.go:977 +#: lxc/image.go:1029 #, c-format msgid "Expires: %s" msgstr "" -#: lxc/image.go:979 +#: lxc/image.go:1031 msgid "Expires: never" msgstr "" -#: lxc/image.go:500 +#: lxc/image.go:524 msgid "Export and download images" msgstr "" -#: lxc/image.go:501 +#: lxc/image.go:525 msgid "" "Export and download images\n" "\n" "The output target is optional and defaults to the working directory." msgstr "" -#: lxc/storage_volume.go:2381 lxc/storage_volume.go:2382 +#: lxc/storage_volume.go:2635 lxc/storage_volume.go:2636 msgid "Export custom storage volume" msgstr "" @@ -2159,29 +2175,29 @@ msgstr "" msgid "Export instances as backup tarballs." msgstr "" -#: lxc/storage_volume.go:2385 +#: lxc/storage_volume.go:2639 msgid "Export the volume without its snapshots" msgstr "" -#: lxc/export.go:152 lxc/storage_volume.go:2502 +#: lxc/export.go:152 lxc/storage_volume.go:2768 #, c-format msgid "Exporting the backup: %s" msgstr "" -#: lxc/image.go:572 +#: lxc/image.go:604 #, c-format msgid "Exporting the image: %s" msgstr "" -#: lxc/cluster.go:188 +#: lxc/cluster.go:196 msgid "FAILURE DOMAIN" msgstr "" -#: lxc/config_template.go:284 +#: lxc/config_template.go:324 msgid "FILENAME" msgstr "" -#: lxc/config_trust.go:411 lxc/image.go:1074 lxc/image.go:1075 +#: lxc/config_trust.go:411 lxc/image.go:1141 lxc/image.go:1142 #: lxc/image_alias.go:235 msgid "FINGERPRINT" msgstr "" @@ -2190,12 +2206,12 @@ msgstr "" msgid "FIRST SEEN" msgstr "" -#: lxc/file.go:1427 +#: lxc/file.go:1467 #, c-format msgid "Failed SSH handshake with client %q: %v" msgstr "" -#: lxc/file.go:1450 +#: lxc/file.go:1490 #, c-format msgid "Failed accepting channel client %q: %v" msgstr "" @@ -2210,12 +2226,12 @@ msgstr "" msgid "Failed checking instance snapshot exists \"%s:%s\": %w" msgstr "" -#: lxc/file.go:1477 +#: lxc/file.go:1517 #, c-format msgid "Failed connecting to instance SFTP for client %q: %v" msgstr "" -#: lxc/file.go:1262 +#: lxc/file.go:1302 #, c-format msgid "Failed connecting to instance SFTP: %w" msgstr "" @@ -2225,12 +2241,12 @@ msgstr "" msgid "Failed converting token operation to certificate add token: %w" msgstr "" -#: lxc/delete.go:169 +#: lxc/delete.go:173 #, c-format msgid "Failed deleting instance %q in project %q: %w" msgstr "" -#: lxc/delete.go:111 +#: lxc/delete.go:115 #, c-format msgid "Failed deleting instance snapshot %q in project %q: %w" msgstr "" @@ -2245,12 +2261,12 @@ msgstr "" msgid "Failed fetching fingerprint %q: %w" msgstr "" -#: lxc/file.go:1383 +#: lxc/file.go:1423 #, c-format msgid "Failed generating SSH host key: %w" msgstr "" -#: lxc/network_peer.go:305 +#: lxc/network_peer.go:333 #, c-format msgid "Failed getting peer's status: %w" msgstr "" @@ -2260,22 +2276,22 @@ msgstr "" msgid "Failed loading profile %q: %w" msgstr "" -#: lxc/file.go:1388 +#: lxc/file.go:1428 #, c-format msgid "Failed parsing SSH host key: %w" msgstr "" -#: lxc/console.go:366 +#: lxc/console.go:380 #, c-format msgid "Failed starting command: %w" msgstr "" -#: lxc/file.go:1288 +#: lxc/file.go:1328 #, c-format msgid "Failed starting sshfs: %w" msgstr "" -#: lxc/file.go:1415 +#: lxc/file.go:1455 #, c-format msgid "Failed to accept incoming connection: %w" msgstr "" @@ -2289,7 +2305,7 @@ msgstr "" msgid "Failed to close server cert file %q: %w" msgstr "" -#: lxc/move.go:292 lxc/move.go:368 +#: lxc/move.go:304 lxc/move.go:380 #, c-format msgid "Failed to connect to cluster member: %w" msgstr "" @@ -2304,22 +2320,22 @@ msgstr "" msgid "Failed to create alias %s: %w" msgstr "" -#: lxc/remote.go:483 +#: lxc/remote.go:501 #, c-format msgid "Failed to decode trust token: %w" msgstr "" -#: lxc/remote.go:289 +#: lxc/remote.go:307 #, c-format msgid "Failed to find project: %w" msgstr "" -#: lxc/file.go:1400 +#: lxc/file.go:1440 #, c-format msgid "Failed to listen for connection: %w" msgstr "" -#: lxc/copy.go:392 +#: lxc/copy.go:404 #, c-format msgid "Failed to refresh target instance '%s': %v" msgstr "" @@ -2329,7 +2345,7 @@ msgstr "" msgid "Failed to remove alias %s: %w" msgstr "" -#: lxc/file.go:1010 +#: lxc/file.go:1042 #, c-format msgid "Failed to walk path for %s: %s" msgstr "" @@ -2343,17 +2359,17 @@ msgstr "" msgid "Fast mode (same as --columns=nsacPt)" msgstr "" -#: lxc/network.go:944 lxc/network_acl.go:125 lxc/network_zone.go:116 +#: lxc/network.go:1044 lxc/network_acl.go:133 lxc/network_zone.go:124 #: lxc/operation.go:137 msgid "Filtering isn't supported yet" msgstr "" -#: lxc/image.go:962 +#: lxc/image.go:1014 #, c-format msgid "Fingerprint: %s" msgstr "" -#: lxc/cluster.go:1172 +#: lxc/cluster.go:1302 msgid "Force a particular evacuation action" msgstr "" @@ -2361,7 +2377,7 @@ msgstr "" msgid "Force creating files or directories" msgstr "" -#: lxc/cluster.go:1171 +#: lxc/cluster.go:1301 msgid "Force evacuation without user confirmation" msgstr "" @@ -2369,15 +2385,15 @@ msgstr "" msgid "Force pseudo-terminal allocation" msgstr "" -#: lxc/cluster.go:526 +#: lxc/cluster.go:590 msgid "Force removing a member, even if degraded" msgstr "" -#: lxc/cluster.go:1193 +#: lxc/cluster.go:1331 msgid "Force restoration without user confirmation" msgstr "" -#: lxc/action.go:136 +#: lxc/action.go:152 msgid "Force the instance to stop" msgstr "" @@ -2385,11 +2401,11 @@ msgstr "" msgid "Force the removal of running instances" msgstr "" -#: lxc/main.go:96 +#: lxc/main.go:95 msgid "Force using the local unix socket" msgstr "" -#: lxc/cluster.go:534 +#: lxc/cluster.go:606 #, c-format msgid "" "Forcefully removing a server from the cluster should only be done as a last\n" @@ -2413,17 +2429,17 @@ msgid "" "Are you really sure you want to force removing %s? (yes/no): " msgstr "" -#: lxc/alias.go:112 lxc/auth.go:337 lxc/auth.go:770 lxc/auth.go:1697 -#: lxc/cluster.go:125 lxc/cluster.go:882 lxc/cluster_group.go:406 -#: lxc/config_template.go:243 lxc/config_trust.go:352 lxc/config_trust.go:434 -#: lxc/image.go:1064 lxc/image_alias.go:157 lxc/list.go:133 lxc/network.go:917 -#: lxc/network.go:1008 lxc/network_acl.go:98 lxc/network_allocations.go:59 +#: lxc/alias.go:112 lxc/auth.go:342 lxc/auth.go:922 lxc/auth.go:1904 +#: lxc/cluster.go:125 lxc/cluster.go:978 lxc/cluster_group.go:442 +#: lxc/config_template.go:275 lxc/config_trust.go:352 lxc/config_trust.go:434 +#: lxc/image.go:1118 lxc/image_alias.go:157 lxc/list.go:133 lxc/network.go:1009 +#: lxc/network.go:1108 lxc/network_acl.go:98 lxc/network_allocations.go:59 #: lxc/network_forward.go:93 lxc/network_load_balancer.go:97 -#: lxc/network_peer.go:85 lxc/network_zone.go:89 lxc/network_zone.go:697 -#: lxc/operation.go:109 lxc/profile.go:638 lxc/project.go:436 -#: lxc/project.go:828 lxc/remote.go:743 lxc/storage.go:613 +#: lxc/network_peer.go:85 lxc/network_zone.go:89 lxc/network_zone.go:770 +#: lxc/operation.go:109 lxc/profile.go:707 lxc/project.go:474 +#: lxc/project.go:919 lxc/remote.go:792 lxc/storage.go:657 #: lxc/storage_bucket.go:460 lxc/storage_bucket.go:775 -#: lxc/storage_volume.go:1469 lxc/warning.go:94 +#: lxc/storage_volume.go:1614 lxc/warning.go:94 msgid "Format (csv|json|table|yaml|compact)" msgstr "" @@ -2435,43 +2451,43 @@ msgstr "" msgid "Format (man|md|rest|yaml)" msgstr "" -#: lxc/network.go:876 +#: lxc/network.go:968 msgid "Forward delay" msgstr "" -#: lxc/main_aliases.go:99 +#: lxc/main_aliases.go:108 #, c-format msgid "Found alias %q references an argument outside the given number" msgstr "" -#: lxc/info.go:370 lxc/info.go:381 lxc/info.go:386 lxc/info.go:392 +#: lxc/info.go:378 lxc/info.go:389 lxc/info.go:394 lxc/info.go:400 #, c-format msgid "Free: %v" msgstr "" -#: lxc/info.go:318 lxc/info.go:329 +#: lxc/info.go:326 lxc/info.go:337 #, c-format msgid "Frequency: %vMhz" msgstr "" -#: lxc/info.go:327 +#: lxc/info.go:335 #, c-format msgid "Frequency: %vMhz (min: %vMhz, max: %vMhz)" msgstr "" -#: lxc/remote.go:807 +#: lxc/remote.go:856 msgid "GLOBAL" msgstr "" -#: lxc/info.go:398 +#: lxc/info.go:406 msgid "GPU:" msgstr "" -#: lxc/info.go:401 +#: lxc/info.go:409 msgid "GPUs:" msgstr "" -#: lxc/auth.go:818 lxc/auth.go:1737 +#: lxc/auth.go:970 lxc/auth.go:1944 msgid "GROUPS" msgstr "" @@ -2479,63 +2495,63 @@ msgstr "" msgid "Generate manpages for all commands" msgstr "" -#: lxc/remote.go:165 lxc/remote.go:440 +#: lxc/remote.go:165 lxc/remote.go:458 msgid "Generating a client certificate. This may take a minute..." msgstr "" -#: lxc/config.go:933 lxc/config.go:934 +#: lxc/config.go:998 lxc/config.go:999 msgid "Get UEFI variables for instance" msgstr "" -#: lxc/project.go:825 lxc/project.go:826 +#: lxc/project.go:916 lxc/project.go:917 msgid "Get a summary of resource allocations" msgstr "" -#: lxc/image.go:1501 lxc/image.go:1502 +#: lxc/image.go:1606 lxc/image.go:1607 msgid "Get image properties" msgstr "" -#: lxc/network.go:789 lxc/network.go:790 +#: lxc/network.go:873 lxc/network.go:874 msgid "Get runtime information on networks" msgstr "" -#: lxc/cluster.go:309 +#: lxc/cluster.go:333 msgid "Get the key as a cluster property" msgstr "" -#: lxc/network_acl.go:269 +#: lxc/network_acl.go:293 msgid "Get the key as a network ACL property" msgstr "" -#: lxc/network_forward.go:386 +#: lxc/network_forward.go:406 msgid "Get the key as a network forward property" msgstr "" -#: lxc/network_load_balancer.go:391 +#: lxc/network_load_balancer.go:411 msgid "Get the key as a network load balancer property" msgstr "" -#: lxc/network_peer.go:335 +#: lxc/network_peer.go:363 msgid "Get the key as a network peer property" msgstr "" -#: lxc/network.go:725 +#: lxc/network.go:797 msgid "Get the key as a network property" msgstr "" -#: lxc/network_zone.go:215 +#: lxc/network_zone.go:231 msgid "Get the key as a network zone property" msgstr "" -#: lxc/network_zone.go:819 +#: lxc/network_zone.go:912 msgid "Get the key as a network zone record property" msgstr "" -#: lxc/profile.go:578 +#: lxc/profile.go:634 msgid "Get the key as a profile property" msgstr "" -#: lxc/project.go:377 +#: lxc/project.go:402 msgid "Get the key as a project property" msgstr "" @@ -2543,63 +2559,63 @@ msgstr "" msgid "Get the key as a storage bucket property" msgstr "" -#: lxc/storage.go:373 +#: lxc/storage.go:397 msgid "Get the key as a storage property" msgstr "" -#: lxc/storage_volume.go:1121 +#: lxc/storage_volume.go:1238 msgid "Get the key as a storage volume property" msgstr "" -#: lxc/config.go:389 +#: lxc/config.go:397 msgid "Get the key as an instance property" msgstr "" -#: lxc/cluster.go:306 +#: lxc/cluster.go:330 msgid "Get values for cluster member configuration keys" msgstr "" -#: lxc/config_device.go:208 lxc/config_device.go:209 +#: lxc/config_device.go:228 lxc/config_device.go:229 msgid "Get values for device configuration keys" msgstr "" -#: lxc/config.go:384 lxc/config.go:385 +#: lxc/config.go:392 lxc/config.go:393 msgid "Get values for instance or server configuration keys" msgstr "" -#: lxc/network_acl.go:266 lxc/network_acl.go:267 +#: lxc/network_acl.go:290 lxc/network_acl.go:291 msgid "Get values for network ACL configuration keys" msgstr "" -#: lxc/network.go:720 lxc/network.go:721 +#: lxc/network.go:792 lxc/network.go:793 msgid "Get values for network configuration keys" msgstr "" -#: lxc/network_forward.go:383 lxc/network_forward.go:384 +#: lxc/network_forward.go:403 lxc/network_forward.go:404 msgid "Get values for network forward configuration keys" msgstr "" -#: lxc/network_load_balancer.go:387 lxc/network_load_balancer.go:388 +#: lxc/network_load_balancer.go:407 lxc/network_load_balancer.go:408 msgid "Get values for network load balancer configuration keys" msgstr "" -#: lxc/network_peer.go:331 lxc/network_peer.go:332 +#: lxc/network_peer.go:359 lxc/network_peer.go:360 msgid "Get values for network peer configuration keys" msgstr "" -#: lxc/network_zone.go:211 lxc/network_zone.go:212 +#: lxc/network_zone.go:227 lxc/network_zone.go:228 msgid "Get values for network zone configuration keys" msgstr "" -#: lxc/network_zone.go:815 lxc/network_zone.go:816 +#: lxc/network_zone.go:908 lxc/network_zone.go:909 msgid "Get values for network zone record configuration keys" msgstr "" -#: lxc/profile.go:572 lxc/profile.go:573 +#: lxc/profile.go:628 lxc/profile.go:629 msgid "Get values for profile configuration keys" msgstr "" -#: lxc/project.go:372 lxc/project.go:373 +#: lxc/project.go:397 lxc/project.go:398 msgid "Get values for project configuration keys" msgstr "" @@ -2607,30 +2623,30 @@ msgstr "" msgid "Get values for storage bucket configuration keys" msgstr "" -#: lxc/storage.go:368 lxc/storage.go:369 +#: lxc/storage.go:392 lxc/storage.go:393 msgid "Get values for storage pool configuration keys" msgstr "" -#: lxc/storage_volume.go:1105 lxc/storage_volume.go:1106 +#: lxc/storage_volume.go:1222 lxc/storage_volume.go:1223 msgid "Get values for storage volume configuration keys" msgstr "" -#: lxc/storage_volume.go:422 +#: lxc/storage_volume.go:475 #, c-format msgid "Given target %q does not match source volume location %q" msgstr "" -#: lxc/auth.go:137 +#: lxc/auth.go:142 #, c-format msgid "Group %s created" msgstr "" -#: lxc/auth.go:187 +#: lxc/auth.go:192 #, c-format msgid "Group %s deleted" msgstr "" -#: lxc/auth.go:427 lxc/auth.go:1787 +#: lxc/auth.go:432 lxc/auth.go:1994 #, c-format msgid "Group %s renamed to %s" msgstr "" @@ -2643,77 +2659,77 @@ msgstr "" msgid "HARDWARE ADDRESS" msgstr "" -#: lxc/network.go:1053 +#: lxc/network.go:1161 msgid "HOSTNAME" msgstr "" -#: lxc/info.go:558 +#: lxc/info.go:566 msgid "Host interface" msgstr "" -#: lxc/info.go:369 lxc/info.go:380 +#: lxc/info.go:377 lxc/info.go:388 msgid "Hugepages:\n" msgstr "" -#: lxc/file.go:1500 +#: lxc/file.go:1540 #, c-format msgid "I/O copy from SSH to instance failed: %v" msgstr "" -#: lxc/file.go:1489 +#: lxc/file.go:1529 #, c-format msgid "I/O copy from instance to SSH failed: %v" msgstr "" -#: lxc/file.go:1312 +#: lxc/file.go:1352 #, c-format msgid "I/O copy from instance to sshfs failed: %v" msgstr "" -#: lxc/file.go:1322 +#: lxc/file.go:1362 #, c-format msgid "I/O copy from sshfs to instance failed: %v" msgstr "" -#: lxc/network.go:874 lxc/operation.go:171 +#: lxc/network.go:966 lxc/operation.go:171 msgid "ID" msgstr "" -#: lxc/info.go:110 +#: lxc/info.go:118 #, c-format msgid "ID: %d" msgstr "" -#: lxc/info.go:198 lxc/info.go:265 lxc/info.go:290 +#: lxc/info.go:206 lxc/info.go:273 lxc/info.go:298 #, c-format msgid "ID: %s" msgstr "" -#: lxc/auth.go:817 +#: lxc/auth.go:969 msgid "IDENTIFIER" msgstr "" -#: lxc/project.go:523 +#: lxc/project.go:568 msgid "IMAGES" msgstr "" -#: lxc/network.go:1055 +#: lxc/network.go:1163 msgid "IP ADDRESS" msgstr "" -#: lxc/info.go:574 +#: lxc/info.go:582 msgid "IP addresses" msgstr "" -#: lxc/network.go:843 +#: lxc/network.go:935 msgid "IP addresses:" msgstr "" -#: lxc/list.go:552 lxc/network.go:984 +#: lxc/list.go:560 lxc/network.go:1084 msgid "IPV4" msgstr "" -#: lxc/list.go:553 lxc/network.go:985 +#: lxc/list.go:561 lxc/network.go:1085 msgid "IPV6" msgstr "" @@ -2721,12 +2737,16 @@ msgstr "" msgid "ISSUE DATE" msgstr "" -#: lxc/auth.go:1509 +#: lxc/auth.go:829 +msgid "Identity creation only supported for TLS identities" +msgstr "" + +#: lxc/auth.go:1716 #, c-format msgid "Identity provider group %s created" msgstr "" -#: lxc/auth.go:1559 +#: lxc/auth.go:1766 #, c-format msgid "Identity provider group %s deleted" msgstr "" @@ -2739,11 +2759,11 @@ msgstr "" msgid "If the image alias already exists, delete and create a new one" msgstr "" -#: lxc/snapshot.go:46 lxc/storage_volume.go:2202 +#: lxc/snapshot.go:46 lxc/storage_volume.go:2427 msgid "If the snapshot name already exists, delete and create a new one" msgstr "" -#: lxc/main.go:400 +#: lxc/main.go:404 msgid "" "If this is your first time running LXD on this machine, you should also run: " "lxd init" @@ -2753,7 +2773,7 @@ msgstr "" msgid "Ignore any configured auto-expiry for the instance" msgstr "" -#: lxc/storage_volume.go:2201 +#: lxc/storage_volume.go:2426 msgid "Ignore any configured auto-expiry for the storage volume" msgstr "" @@ -2761,15 +2781,15 @@ msgstr "" msgid "Ignore copy errors for volatile files" msgstr "" -#: lxc/action.go:127 +#: lxc/action.go:143 msgid "Ignore the instance state" msgstr "" -#: lxc/image.go:1425 +#: lxc/image.go:1522 msgid "Image already up to date." msgstr "" -#: lxc/image.go:293 +#: lxc/image.go:305 msgid "Image copied successfully!" msgstr "" @@ -2777,33 +2797,33 @@ msgstr "" msgid "Image expiration date (format: rfc3339)" msgstr "" -#: lxc/image.go:648 +#: lxc/image.go:680 msgid "Image exported successfully!" msgstr "" -#: lxc/image.go:348 lxc/image.go:1380 +#: lxc/image.go:364 lxc/image.go:1477 msgid "Image identifier missing" msgstr "" -#: lxc/image.go:420 lxc/image.go:1577 +#: lxc/image.go:444 lxc/image.go:1703 #, c-format msgid "Image identifier missing: %s" msgstr "" -#: lxc/image.go:874 +#: lxc/image.go:918 #, c-format msgid "Image imported with fingerprint: %s" msgstr "" -#: lxc/image.go:1423 +#: lxc/image.go:1520 msgid "Image refreshed successfully!" msgstr "" -#: lxc/action.go:131 lxc/launch.go:45 +#: lxc/action.go:147 lxc/launch.go:45 msgid "Immediately attach to the console" msgstr "" -#: lxc/storage_volume.go:2536 +#: lxc/storage_volume.go:2802 msgid "Import backups of custom volumes including their snapshots." msgstr "" @@ -2811,11 +2831,11 @@ msgstr "" msgid "Import backups of instances including their snapshots." msgstr "" -#: lxc/storage_volume.go:2535 +#: lxc/storage_volume.go:2801 msgid "Import custom storage volumes" msgstr "" -#: lxc/image.go:665 +#: lxc/image.go:697 msgid "" "Import image into the image store\n" "\n" @@ -2825,7 +2845,7 @@ msgid "" "os=Ubuntu release=noble variant=cloud." msgstr "" -#: lxc/image.go:664 +#: lxc/image.go:696 msgid "Import images into the image store" msgstr "" @@ -2833,11 +2853,11 @@ msgstr "" msgid "Import instance backups" msgstr "" -#: lxc/storage_volume.go:2543 +#: lxc/storage_volume.go:2809 msgid "Import type, backup or iso (default \"backup\")" msgstr "" -#: lxc/storage_volume.go:2609 +#: lxc/storage_volume.go:2883 #, c-format msgid "Importing custom volume: %s" msgstr "" @@ -2847,7 +2867,7 @@ msgstr "" msgid "Importing instance: %s" msgstr "" -#: lxc/info.go:227 +#: lxc/info.go:235 msgid "Infiniband:" msgstr "" @@ -2855,41 +2875,41 @@ msgstr "" msgid "Input data" msgstr "" -#: lxc/auth.go:1234 lxc/auth.go:1235 +#: lxc/auth.go:1441 lxc/auth.go:1442 msgid "Inspect permissions" msgstr "" -#: lxc/info.go:684 +#: lxc/info.go:692 msgid "Instance Only" msgstr "" -#: lxc/file.go:1314 +#: lxc/file.go:1354 msgid "Instance disconnected" msgstr "" -#: lxc/file.go:1491 +#: lxc/file.go:1531 #, c-format msgid "Instance disconnected for client %q" msgstr "" -#: lxc/publish.go:81 +#: lxc/publish.go:93 msgid "Instance name is mandatory" msgstr "" -#: lxc/init.go:405 +#: lxc/init.go:414 #, c-format msgid "Instance name is: %s" msgstr "" -#: lxc/config.go:959 lxc/config.go:1017 lxc/config.go:1136 lxc/config.go:1228 +#: lxc/config.go:1024 lxc/config.go:1082 lxc/config.go:1201 lxc/config.go:1293 msgid "Instance name must be specified" msgstr "" -#: lxc/file.go:1234 +#: lxc/file.go:1274 msgid "Instance path cannot be used in SSH SFTP listener mode" msgstr "" -#: lxc/publish.go:351 +#: lxc/publish.go:363 #, c-format msgid "Instance published with fingerprint: %s" msgstr "" @@ -2903,12 +2923,12 @@ msgstr "" msgid "Instance type" msgstr "" -#: lxc/remote.go:392 +#: lxc/remote.go:410 #, c-format msgid "Invalid URL scheme \"%s\" in \"%s\"" msgstr "" -#: lxc/main_aliases.go:95 lxc/main_aliases.go:132 +#: lxc/main_aliases.go:104 lxc/main_aliases.go:147 #, c-format msgid "Invalid argument %q" msgstr "" @@ -2917,12 +2937,12 @@ msgstr "" msgid "Invalid certificate" msgstr "" -#: lxc/list.go:648 +#: lxc/list.go:656 #, c-format msgid "Invalid config key '%s' in '%s'" msgstr "" -#: lxc/list.go:641 +#: lxc/list.go:649 #, c-format msgid "Invalid config key column format (too many fields): '%s'" msgstr "" @@ -2937,7 +2957,7 @@ msgstr "" msgid "Invalid instance name: %s" msgstr "" -#: lxc/file.go:1229 +#: lxc/file.go:1269 #, c-format msgid "Invalid instance path: %q" msgstr "" @@ -2947,70 +2967,70 @@ msgstr "" msgid "Invalid key=value configuration: %s" msgstr "" -#: lxc/list.go:669 +#: lxc/list.go:677 #, c-format msgid "Invalid max width (must -1, 0 or a positive integer) '%s' in '%s'" msgstr "" -#: lxc/list.go:665 +#: lxc/list.go:673 #, c-format msgid "Invalid max width (must be an integer) '%s' in '%s'" msgstr "" -#: lxc/list.go:655 +#: lxc/list.go:663 #, c-format msgid "" "Invalid name in '%s', empty string is only allowed when defining maxWidth" msgstr "" -#: lxc/move.go:136 lxc/storage_volume.go:1829 +#: lxc/move.go:148 lxc/storage_volume.go:2012 msgid "Invalid new snapshot name" msgstr "" -#: lxc/move.go:132 +#: lxc/move.go:144 msgid "Invalid new snapshot name, parent must be the same as source" msgstr "" -#: lxc/storage_volume.go:1825 +#: lxc/storage_volume.go:2008 msgid "Invalid new snapshot name, parent volume must be the same as source" msgstr "" -#: lxc/main.go:502 +#: lxc/main.go:506 msgid "Invalid number of arguments" msgstr "" -#: lxc/file.go:337 +#: lxc/file.go:353 #, c-format msgid "Invalid path %s" msgstr "" -#: lxc/remote.go:381 +#: lxc/remote.go:399 #, c-format msgid "Invalid protocol: %s" msgstr "" -#: lxc/storage_volume.go:953 lxc/storage_volume.go:1154 -#: lxc/storage_volume.go:1266 lxc/storage_volume.go:1814 -#: lxc/storage_volume.go:1947 lxc/storage_volume.go:2087 +#: lxc/storage_volume.go:1070 lxc/storage_volume.go:1287 +#: lxc/storage_volume.go:1411 lxc/storage_volume.go:1997 +#: lxc/storage_volume.go:2144 lxc/storage_volume.go:2296 msgid "Invalid snapshot name" msgstr "" -#: lxc/file.go:503 +#: lxc/file.go:535 #, c-format msgid "Invalid source %s" msgstr "" -#: lxc/file.go:177 lxc/file.go:685 +#: lxc/file.go:185 lxc/file.go:717 #, c-format msgid "Invalid target %s" msgstr "" -#: lxc/file.go:163 +#: lxc/file.go:171 #, c-format msgid "Invalid type %q" msgstr "" -#: lxc/info.go:230 +#: lxc/info.go:238 #, c-format msgid "IsSM: %s (%s)" msgstr "" @@ -3023,71 +3043,71 @@ msgstr "" msgid "LAST SEEN" msgstr "" -#: lxc/list.go:562 +#: lxc/list.go:570 msgid "LAST USED AT" msgstr "" -#: lxc/project.go:887 +#: lxc/project.go:994 msgid "LIMIT" msgstr "" -#: lxc/network_forward.go:148 lxc/network_load_balancer.go:151 +#: lxc/network_forward.go:156 lxc/network_load_balancer.go:159 msgid "LISTEN ADDRESS" msgstr "" -#: lxc/list.go:598 lxc/network.go:1060 lxc/network_forward.go:155 -#: lxc/network_load_balancer.go:157 lxc/operation.go:178 -#: lxc/storage_bucket.go:517 lxc/storage_volume.go:1592 lxc/warning.go:221 +#: lxc/list.go:606 lxc/network.go:1168 lxc/network_forward.go:163 +#: lxc/network_load_balancer.go:165 lxc/operation.go:178 +#: lxc/storage_bucket.go:517 lxc/storage_volume.go:1745 lxc/warning.go:221 msgid "LOCATION" msgstr "" -#: lxc/manpage.go:43 +#: lxc/manpage.go:52 msgid "LXD - Command line client" msgstr "" -#: lxc/console.go:387 +#: lxc/console.go:401 msgid "LXD automatically uses either spicy or remote-viewer when present." msgstr "" -#: lxc/cluster.go:159 lxc/cluster.go:916 lxc/cluster.go:1010 -#: lxc/cluster.go:1101 lxc/cluster_group.go:441 +#: lxc/cluster.go:167 lxc/cluster.go:1020 lxc/cluster.go:1123 +#: lxc/cluster.go:1231 lxc/cluster_group.go:485 msgid "LXD server isn't part of a cluster" msgstr "" -#: lxc/info.go:493 +#: lxc/info.go:501 #, c-format msgid "Last Used: %s" msgstr "" -#: lxc/image.go:983 +#: lxc/image.go:1035 #, c-format msgid "Last used: %s" msgstr "" -#: lxc/image.go:985 +#: lxc/image.go:1037 msgid "Last used: never" msgstr "" -#: lxc/init.go:172 +#: lxc/init.go:176 #, c-format msgid "Launching %s" msgstr "" -#: lxc/init.go:170 +#: lxc/init.go:174 msgid "Launching the instance" msgstr "" -#: lxc/info.go:221 +#: lxc/info.go:229 #, c-format msgid "Link detected: %v" msgstr "" -#: lxc/info.go:223 +#: lxc/info.go:231 #, c-format msgid "Link speed: %dMbit/s (%s duplex)" msgstr "" -#: lxc/network.go:1005 lxc/network.go:1006 +#: lxc/network.go:1105 lxc/network.go:1106 msgid "List DHCP leases" msgstr "" @@ -3099,11 +3119,11 @@ msgstr "" msgid "List all active certificate add tokens" msgstr "" -#: lxc/cluster.go:880 lxc/cluster.go:881 +#: lxc/cluster.go:976 lxc/cluster.go:977 msgid "List all active cluster member join tokens" msgstr "" -#: lxc/cluster_group.go:403 lxc/cluster_group.go:404 +#: lxc/cluster_group.go:439 lxc/cluster_group.go:440 msgid "List all the cluster groups" msgstr "" @@ -3139,7 +3159,7 @@ msgstr "" msgid "List available network zone" msgstr "" -#: lxc/network_zone.go:693 lxc/network_zone.go:694 +#: lxc/network_zone.go:766 lxc/network_zone.go:767 msgid "List available network zone records" msgstr "" @@ -3147,11 +3167,11 @@ msgstr "" msgid "List available network zoneS" msgstr "" -#: lxc/network.go:912 lxc/network.go:913 +#: lxc/network.go:1004 lxc/network.go:1005 msgid "List available networks" msgstr "" -#: lxc/storage.go:610 lxc/storage.go:611 +#: lxc/storage.go:654 lxc/storage.go:655 msgid "List available storage pools" msgstr "" @@ -3159,15 +3179,15 @@ msgstr "" msgid "List background operations" msgstr "" -#: lxc/auth.go:332 lxc/auth.go:333 +#: lxc/auth.go:337 lxc/auth.go:338 msgid "List groups" msgstr "" -#: lxc/auth.go:765 lxc/auth.go:766 +#: lxc/auth.go:917 lxc/auth.go:918 msgid "List identities" msgstr "" -#: lxc/auth.go:1692 lxc/auth.go:1693 +#: lxc/auth.go:1899 lxc/auth.go:1900 msgid "List identity provider groups" msgstr "" @@ -3182,11 +3202,11 @@ msgid "" "Filters may be part of the image hash or part of the image alias name.\n" msgstr "" -#: lxc/image.go:1037 +#: lxc/image.go:1090 msgid "List images" msgstr "" -#: lxc/image.go:1038 +#: lxc/image.go:1091 msgid "" "List images\n" "\n" @@ -3207,17 +3227,18 @@ msgid "" " F - Fingerprint (long)\n" " p - Whether image is public\n" " d - Description\n" +" e - Project\n" " a - Architecture\n" " s - Size\n" " u - Upload date\n" " t - Type" msgstr "" -#: lxc/config_device.go:285 lxc/config_device.go:286 +#: lxc/config_device.go:325 lxc/config_device.go:326 msgid "List instance devices" msgstr "" -#: lxc/config_template.go:240 lxc/config_template.go:241 +#: lxc/config_template.go:272 lxc/config_template.go:273 msgid "List instance file templates" msgstr "" @@ -3320,15 +3341,15 @@ msgstr "" msgid "List operations from all projects" msgstr "" -#: lxc/auth.go:1256 lxc/auth.go:1257 +#: lxc/auth.go:1463 lxc/auth.go:1464 msgid "List permissions" msgstr "" -#: lxc/profile.go:633 lxc/profile.go:634 +#: lxc/profile.go:702 lxc/profile.go:703 msgid "List profiles" msgstr "" -#: lxc/project.go:433 lxc/project.go:434 +#: lxc/project.go:471 lxc/project.go:472 msgid "List projects" msgstr "" @@ -3340,11 +3361,11 @@ msgstr "" msgid "List storage buckets" msgstr "" -#: lxc/storage_volume.go:1447 +#: lxc/storage_volume.go:1592 msgid "List storage volumes" msgstr "" -#: lxc/storage_volume.go:1452 +#: lxc/storage_volume.go:1597 msgid "" "List storage volumes\n" "\n" @@ -3364,7 +3385,7 @@ msgid "" " U - Current disk usage" msgstr "" -#: lxc/remote.go:738 lxc/remote.go:739 +#: lxc/remote.go:787 lxc/remote.go:788 msgid "List the available remotes" msgstr "" @@ -3403,7 +3424,7 @@ msgstr "" msgid "List, show and delete background operations" msgstr "" -#: lxc/info.go:481 lxc/storage_volume.go:1325 +#: lxc/info.go:489 lxc/storage_volume.go:1470 #, c-format msgid "Location: %s" msgstr "" @@ -3412,75 +3433,75 @@ msgstr "" msgid "Log level filtering can only be used with pretty formatting" msgstr "" -#: lxc/info.go:712 +#: lxc/info.go:720 msgid "Log:" msgstr "" -#: lxc/network.go:886 +#: lxc/network.go:978 msgid "Lower device" msgstr "" -#: lxc/network.go:867 +#: lxc/network.go:959 msgid "Lower devices" msgstr "" -#: lxc/network.go:1054 +#: lxc/network.go:1162 msgid "MAC ADDRESS" msgstr "" -#: lxc/info.go:562 +#: lxc/info.go:570 msgid "MAC address" msgstr "" -#: lxc/network.go:835 +#: lxc/network.go:927 #, c-format msgid "MAC address: %s" msgstr "" -#: lxc/info.go:234 +#: lxc/info.go:242 #, c-format msgid "MAD: %s (%s)" msgstr "" -#: lxc/network.go:983 +#: lxc/network.go:1083 msgid "MANAGED" msgstr "" -#: lxc/cluster_group.go:461 +#: lxc/cluster_group.go:505 msgid "MEMBERS" msgstr "" -#: lxc/list.go:563 +#: lxc/list.go:571 msgid "MEMORY USAGE" msgstr "" -#: lxc/list.go:564 +#: lxc/list.go:572 #, c-format msgid "MEMORY USAGE%" msgstr "" -#: lxc/cluster.go:191 +#: lxc/cluster.go:199 msgid "MESSAGE" msgstr "" -#: lxc/network.go:865 +#: lxc/network.go:957 msgid "MII Frequency" msgstr "" -#: lxc/network.go:866 +#: lxc/network.go:958 msgid "MII state" msgstr "" -#: lxc/info.go:566 +#: lxc/info.go:574 msgid "MTU" msgstr "" -#: lxc/network.go:836 +#: lxc/network.go:928 #, c-format msgid "MTU: %d" msgstr "" -#: lxc/image.go:165 lxc/image.go:672 +#: lxc/image.go:165 lxc/image.go:704 msgid "Make image public" msgstr "" @@ -3488,6 +3509,12 @@ msgstr "" msgid "Make the image public (accessible to unauthenticated clients as well)" msgstr "" +#: lxc/auth.go:825 +msgid "" +"Malformed argument, expected `[:]/`, " +"got " +msgstr "" + #: lxc/network.go:32 lxc/network.go:33 msgid "Manage and attach instances to networks" msgstr "" @@ -3516,19 +3543,19 @@ msgstr "" msgid "Manage files in instances" msgstr "" -#: lxc/auth.go:59 lxc/auth.go:60 lxc/auth.go:1434 lxc/auth.go:1435 +#: lxc/auth.go:64 lxc/auth.go:65 lxc/auth.go:1641 lxc/auth.go:1642 msgid "Manage groups" msgstr "" -#: lxc/auth.go:1084 lxc/auth.go:1085 +#: lxc/auth.go:1291 lxc/auth.go:1292 msgid "Manage groups for the identity" msgstr "" -#: lxc/auth.go:731 lxc/auth.go:732 +#: lxc/auth.go:736 lxc/auth.go:737 msgid "Manage identities" msgstr "" -#: lxc/auth.go:1852 lxc/auth.go:1853 +#: lxc/auth.go:2059 lxc/auth.go:2060 msgid "Manage identity provider group mappings" msgstr "" @@ -3559,7 +3586,7 @@ msgid "" "hash or alias name (if one is set)." msgstr "" -#: lxc/config.go:893 lxc/config.go:894 +#: lxc/config.go:958 lxc/config.go:959 msgid "Manage instance UEFI variables" msgstr "" @@ -3575,7 +3602,7 @@ msgstr "" msgid "Manage instance metadata files" msgstr "" -#: lxc/network_acl.go:755 lxc/network_acl.go:756 +#: lxc/network_acl.go:844 lxc/network_acl.go:845 msgid "Manage network ACL rules" msgstr "" @@ -3583,7 +3610,7 @@ msgstr "" msgid "Manage network ACLs" msgstr "" -#: lxc/network_forward.go:784 lxc/network_forward.go:785 +#: lxc/network_forward.go:873 lxc/network_forward.go:874 msgid "Manage network forward ports" msgstr "" @@ -3591,11 +3618,11 @@ msgstr "" msgid "Manage network forwards" msgstr "" -#: lxc/network_load_balancer.go:787 lxc/network_load_balancer.go:788 +#: lxc/network_load_balancer.go:843 lxc/network_load_balancer.go:844 msgid "Manage network load balancer backends" msgstr "" -#: lxc/network_load_balancer.go:952 lxc/network_load_balancer.go:953 +#: lxc/network_load_balancer.go:1032 lxc/network_load_balancer.go:1033 msgid "Manage network load balancer ports" msgstr "" @@ -3607,11 +3634,11 @@ msgstr "" msgid "Manage network peerings" msgstr "" -#: lxc/network_zone.go:1233 lxc/network_zone.go:1234 +#: lxc/network_zone.go:1409 lxc/network_zone.go:1410 msgid "Manage network zone record entries" msgstr "" -#: lxc/network_zone.go:636 lxc/network_zone.go:637 +#: lxc/network_zone.go:709 lxc/network_zone.go:710 msgid "Manage network zone records" msgstr "" @@ -3619,7 +3646,7 @@ msgstr "" msgid "Manage network zones" msgstr "" -#: lxc/auth.go:493 lxc/auth.go:494 +#: lxc/auth.go:498 lxc/auth.go:499 msgid "Manage permissions" msgstr "" @@ -3627,7 +3654,7 @@ msgstr "" msgid "Manage profiles" msgstr "" -#: lxc/project.go:29 lxc/project.go:30 +#: lxc/project.go:30 lxc/project.go:31 msgid "Manage projects" msgstr "" @@ -3651,11 +3678,11 @@ msgstr "" msgid "Manage storage pools and volumes" msgstr "" -#: lxc/storage_volume.go:43 +#: lxc/storage_volume.go:57 msgid "Manage storage volumes" msgstr "" -#: lxc/storage_volume.go:44 +#: lxc/storage_volume.go:58 msgid "" "Manage storage volumes\n" "\n" @@ -3671,7 +3698,7 @@ msgstr "" msgid "Manage trusted clients" msgstr "" -#: lxc/auth.go:30 lxc/auth.go:31 +#: lxc/auth.go:35 lxc/auth.go:36 msgid "Manage user authorization" msgstr "" @@ -3679,62 +3706,62 @@ msgstr "" msgid "Manage warnings" msgstr "" -#: lxc/info.go:138 lxc/info.go:247 +#: lxc/info.go:146 lxc/info.go:255 #, c-format msgid "Maximum number of VFs: %d" msgstr "" -#: lxc/info.go:149 +#: lxc/info.go:157 msgid "Mdev profiles:" msgstr "" -#: lxc/cluster_role.go:87 +#: lxc/cluster_role.go:95 #, c-format msgid "Member %q already has role %q" msgstr "" -#: lxc/cluster_role.go:143 +#: lxc/cluster_role.go:163 #, c-format msgid "Member %q does not have role %q" msgstr "" -#: lxc/cluster.go:861 +#: lxc/cluster.go:957 #, c-format msgid "Member %s join token:" msgstr "" -#: lxc/cluster.go:589 +#: lxc/cluster.go:661 #, c-format msgid "Member %s removed" msgstr "" -#: lxc/cluster.go:502 +#: lxc/cluster.go:566 #, c-format msgid "Member %s renamed to %s" msgstr "" -#: lxc/info.go:530 +#: lxc/info.go:538 msgid "Memory (current)" msgstr "" -#: lxc/info.go:534 +#: lxc/info.go:542 msgid "Memory (peak)" msgstr "" -#: lxc/info.go:546 +#: lxc/info.go:554 msgid "Memory usage:" msgstr "" -#: lxc/info.go:367 +#: lxc/info.go:375 msgid "Memory:" msgstr "" -#: lxc/move.go:310 lxc/move.go:423 +#: lxc/move.go:322 lxc/move.go:435 #, c-format msgid "Migration API failure: %w" msgstr "" -#: lxc/move.go:335 lxc/move.go:428 +#: lxc/move.go:347 lxc/move.go:440 #, c-format msgid "Migration operation failure: %w" msgstr "" @@ -3757,39 +3784,40 @@ msgstr "" msgid "Missing certificate fingerprint" msgstr "" -#: lxc/cluster_group.go:203 lxc/cluster_group.go:261 lxc/cluster_group.go:313 -#: lxc/cluster_group.go:624 +#: lxc/cluster_group.go:223 lxc/cluster_group.go:289 lxc/cluster_group.go:349 +#: lxc/cluster_group.go:696 msgid "Missing cluster group name" msgstr "" -#: lxc/cluster.go:723 lxc/cluster.go:1221 lxc/cluster_group.go:117 -#: lxc/cluster_group.go:503 lxc/cluster_group.go:677 lxc/cluster_role.go:74 -#: lxc/cluster_role.go:130 +#: lxc/cluster.go:811 lxc/cluster.go:1367 lxc/cluster_group.go:129 +#: lxc/cluster_group.go:559 lxc/cluster_group.go:761 lxc/cluster_role.go:82 +#: lxc/cluster_role.go:150 msgid "Missing cluster member name" msgstr "" -#: lxc/auth.go:123 lxc/auth.go:177 lxc/auth.go:255 lxc/auth.go:417 -#: lxc/auth.go:466 lxc/auth.go:541 lxc/auth.go:600 lxc/auth.go:1826 +#: lxc/auth.go:128 lxc/auth.go:182 lxc/auth.go:260 lxc/auth.go:422 +#: lxc/auth.go:471 lxc/auth.go:546 lxc/auth.go:605 lxc/auth.go:2033 msgid "Missing group name" msgstr "" -#: lxc/auth.go:863 lxc/auth.go:1004 lxc/auth.go:1132 lxc/auth.go:1190 +#: lxc/auth.go:820 lxc/auth.go:1015 lxc/auth.go:1156 lxc/auth.go:1273 +#: lxc/auth.go:1339 lxc/auth.go:1397 msgid "Missing identity argument" msgstr "" -#: lxc/auth.go:1496 lxc/auth.go:1549 lxc/auth.go:1615 lxc/auth.go:1777 +#: lxc/auth.go:1703 lxc/auth.go:1756 lxc/auth.go:1822 lxc/auth.go:1984 msgid "Missing identity provider group name" msgstr "" -#: lxc/auth.go:1900 lxc/auth.go:1953 +#: lxc/auth.go:2107 lxc/auth.go:2160 msgid "Missing identity provider group name argument" msgstr "" -#: lxc/config_metadata.go:104 lxc/config_metadata.go:205 -#: lxc/config_template.go:92 lxc/config_template.go:135 -#: lxc/config_template.go:177 lxc/config_template.go:266 -#: lxc/config_template.go:325 lxc/profile.go:129 lxc/profile.go:202 -#: lxc/profile.go:719 lxc/rebuild.go:61 +#: lxc/config_metadata.go:112 lxc/config_metadata.go:221 +#: lxc/config_template.go:100 lxc/config_template.go:155 +#: lxc/config_template.go:209 lxc/config_template.go:306 +#: lxc/config_template.go:377 lxc/profile.go:141 lxc/profile.go:222 +#: lxc/profile.go:808 lxc/rebuild.go:61 msgid "Missing instance name" msgstr "" @@ -3798,124 +3826,124 @@ msgstr "" msgid "Missing key name" msgstr "" -#: lxc/network_forward.go:199 lxc/network_forward.go:413 -#: lxc/network_forward.go:486 lxc/network_forward.go:632 -#: lxc/network_forward.go:751 lxc/network_forward.go:828 -#: lxc/network_forward.go:894 lxc/network_load_balancer.go:201 -#: lxc/network_load_balancer.go:416 lxc/network_load_balancer.go:489 -#: lxc/network_load_balancer.go:635 lxc/network_load_balancer.go:755 -#: lxc/network_load_balancer.go:831 lxc/network_load_balancer.go:895 -#: lxc/network_load_balancer.go:996 lxc/network_load_balancer.go:1058 +#: lxc/network_forward.go:219 lxc/network_forward.go:449 +#: lxc/network_forward.go:534 lxc/network_forward.go:709 +#: lxc/network_forward.go:840 lxc/network_forward.go:933 +#: lxc/network_forward.go:1015 lxc/network_load_balancer.go:221 +#: lxc/network_load_balancer.go:436 lxc/network_load_balancer.go:521 +#: lxc/network_load_balancer.go:679 lxc/network_load_balancer.go:811 +#: lxc/network_load_balancer.go:899 lxc/network_load_balancer.go:975 +#: lxc/network_load_balancer.go:1088 lxc/network_load_balancer.go:1162 msgid "Missing listen address" msgstr "" -#: lxc/config_device.go:120 lxc/config_device.go:233 lxc/config_device.go:315 -#: lxc/config_device.go:381 lxc/config_device.go:475 lxc/config_device.go:584 -#: lxc/config_device.go:693 +#: lxc/config_device.go:140 lxc/config_device.go:273 lxc/config_device.go:367 +#: lxc/config_device.go:441 lxc/config_device.go:553 lxc/config_device.go:682 +#: lxc/config_device.go:803 msgid "Missing name" msgstr "" -#: lxc/network_acl.go:188 lxc/network_acl.go:240 lxc/network_acl.go:291 -#: lxc/network_acl.go:355 lxc/network_acl.go:445 lxc/network_acl.go:577 -#: lxc/network_acl.go:680 lxc/network_acl.go:729 lxc/network_acl.go:849 -#: lxc/network_acl.go:916 +#: lxc/network_acl.go:204 lxc/network_acl.go:264 lxc/network_acl.go:327 +#: lxc/network_acl.go:399 lxc/network_acl.go:497 lxc/network_acl.go:650 +#: lxc/network_acl.go:761 lxc/network_acl.go:818 lxc/network_acl.go:954 +#: lxc/network_acl.go:1037 msgid "Missing network ACL name" msgstr "" -#: lxc/network.go:160 lxc/network.go:245 lxc/network.go:397 lxc/network.go:447 -#: lxc/network.go:532 lxc/network.go:637 lxc/network.go:748 lxc/network.go:816 -#: lxc/network.go:1031 lxc/network.go:1101 lxc/network.go:1159 -#: lxc/network.go:1243 lxc/network_forward.go:119 lxc/network_forward.go:195 -#: lxc/network_forward.go:264 lxc/network_forward.go:409 -#: lxc/network_forward.go:482 lxc/network_forward.go:628 -#: lxc/network_forward.go:747 lxc/network_forward.go:824 -#: lxc/network_forward.go:890 lxc/network_load_balancer.go:123 -#: lxc/network_load_balancer.go:197 lxc/network_load_balancer.go:266 -#: lxc/network_load_balancer.go:412 lxc/network_load_balancer.go:485 -#: lxc/network_load_balancer.go:631 lxc/network_load_balancer.go:751 -#: lxc/network_load_balancer.go:827 lxc/network_load_balancer.go:891 -#: lxc/network_load_balancer.go:992 lxc/network_load_balancer.go:1054 -#: lxc/network_peer.go:111 lxc/network_peer.go:181 lxc/network_peer.go:238 -#: lxc/network_peer.go:356 lxc/network_peer.go:427 lxc/network_peer.go:557 -#: lxc/network_peer.go:666 +#: lxc/network.go:172 lxc/network.go:269 lxc/network.go:437 lxc/network.go:499 +#: lxc/network.go:596 lxc/network.go:709 lxc/network.go:832 lxc/network.go:908 +#: lxc/network.go:1139 lxc/network.go:1217 lxc/network.go:1283 +#: lxc/network.go:1375 lxc/network_forward.go:127 lxc/network_forward.go:215 +#: lxc/network_forward.go:284 lxc/network_forward.go:445 +#: lxc/network_forward.go:530 lxc/network_forward.go:705 +#: lxc/network_forward.go:836 lxc/network_forward.go:929 +#: lxc/network_forward.go:1011 lxc/network_load_balancer.go:131 +#: lxc/network_load_balancer.go:217 lxc/network_load_balancer.go:286 +#: lxc/network_load_balancer.go:432 lxc/network_load_balancer.go:517 +#: lxc/network_load_balancer.go:675 lxc/network_load_balancer.go:807 +#: lxc/network_load_balancer.go:895 lxc/network_load_balancer.go:971 +#: lxc/network_load_balancer.go:1084 lxc/network_load_balancer.go:1158 +#: lxc/network_peer.go:119 lxc/network_peer.go:201 lxc/network_peer.go:266 +#: lxc/network_peer.go:401 lxc/network_peer.go:485 lxc/network_peer.go:644 +#: lxc/network_peer.go:765 msgid "Missing network name" msgstr "" -#: lxc/network_zone.go:179 lxc/network_zone.go:235 lxc/network_zone.go:299 -#: lxc/network_zone.go:387 lxc/network_zone.go:508 lxc/network_zone.go:611 -#: lxc/network_zone.go:717 lxc/network_zone.go:785 lxc/network_zone.go:901 -#: lxc/network_zone.go:985 lxc/network_zone.go:1206 lxc/network_zone.go:1271 -#: lxc/network_zone.go:1316 +#: lxc/network_zone.go:195 lxc/network_zone.go:264 lxc/network_zone.go:336 +#: lxc/network_zone.go:432 lxc/network_zone.go:573 lxc/network_zone.go:684 +#: lxc/network_zone.go:798 lxc/network_zone.go:878 lxc/network_zone.go:1023 +#: lxc/network_zone.go:1120 lxc/network_zone.go:1382 lxc/network_zone.go:1459 +#: lxc/network_zone.go:1516 msgid "Missing network zone name" msgstr "" -#: lxc/network_zone.go:838 lxc/network_zone.go:1104 +#: lxc/network_zone.go:948 lxc/network_zone.go:1268 msgid "Missing network zone record name" msgstr "" -#: lxc/network_peer.go:185 lxc/network_peer.go:242 lxc/network_peer.go:360 -#: lxc/network_peer.go:431 lxc/network_peer.go:561 lxc/network_peer.go:670 +#: lxc/network_peer.go:205 lxc/network_peer.go:270 lxc/network_peer.go:405 +#: lxc/network_peer.go:489 lxc/network_peer.go:648 lxc/network_peer.go:769 msgid "Missing peer name" msgstr "" -#: lxc/storage.go:219 lxc/storage.go:289 lxc/storage.go:395 lxc/storage.go:465 -#: lxc/storage.go:720 lxc/storage.go:818 lxc/storage_bucket.go:113 +#: lxc/storage.go:235 lxc/storage.go:313 lxc/storage.go:431 lxc/storage.go:509 +#: lxc/storage.go:780 lxc/storage.go:886 lxc/storage_bucket.go:113 #: lxc/storage_bucket.go:213 lxc/storage_bucket.go:289 #: lxc/storage_bucket.go:408 lxc/storage_bucket.go:483 #: lxc/storage_bucket.go:565 lxc/storage_bucket.go:657 #: lxc/storage_bucket.go:799 lxc/storage_bucket.go:886 #: lxc/storage_bucket.go:983 lxc/storage_bucket.go:1062 -#: lxc/storage_bucket.go:1185 lxc/storage_volume.go:190 -#: lxc/storage_volume.go:288 lxc/storage_volume.go:588 -#: lxc/storage_volume.go:683 lxc/storage_volume.go:758 -#: lxc/storage_volume.go:840 lxc/storage_volume.go:942 -#: lxc/storage_volume.go:1143 lxc/storage_volume.go:1803 -#: lxc/storage_volume.go:1930 lxc/storage_volume.go:2076 -#: lxc/storage_volume.go:2238 lxc/storage_volume.go:2339 +#: lxc/storage_bucket.go:1185 lxc/storage_volume.go:209 +#: lxc/storage_volume.go:323 lxc/storage_volume.go:649 +#: lxc/storage_volume.go:756 lxc/storage_volume.go:847 +#: lxc/storage_volume.go:945 lxc/storage_volume.go:1059 +#: lxc/storage_volume.go:1276 lxc/storage_volume.go:1986 +#: lxc/storage_volume.go:2127 lxc/storage_volume.go:2285 +#: lxc/storage_volume.go:2476 lxc/storage_volume.go:2593 msgid "Missing pool name" msgstr "" -#: lxc/profile.go:419 lxc/profile.go:493 lxc/profile.go:598 lxc/profile.go:795 -#: lxc/profile.go:850 lxc/profile.go:923 +#: lxc/profile.go:467 lxc/profile.go:549 lxc/profile.go:667 lxc/profile.go:892 +#: lxc/profile.go:960 lxc/profile.go:1041 msgid "Missing profile name" msgstr "" -#: lxc/profile.go:364 lxc/project.go:139 lxc/project.go:211 lxc/project.go:293 -#: lxc/project.go:397 lxc/project.go:571 lxc/project.go:631 lxc/project.go:738 -#: lxc/project.go:851 +#: lxc/profile.go:404 lxc/project.go:148 lxc/project.go:228 lxc/project.go:318 +#: lxc/project.go:435 lxc/project.go:624 lxc/project.go:693 lxc/project.go:821 +#: lxc/project.go:950 msgid "Missing project name" msgstr "" -#: lxc/profile.go:278 +#: lxc/profile.go:310 msgid "Missing source profile name" msgstr "" -#: lxc/storage_volume.go:385 lxc/storage_volume.go:1725 +#: lxc/storage_volume.go:438 lxc/storage_volume.go:1896 msgid "Missing source volume name" msgstr "" -#: lxc/storage_volume.go:1255 +#: lxc/storage_volume.go:1400 msgid "Missing storage pool name" msgstr "" -#: lxc/file.go:788 +#: lxc/file.go:820 msgid "Missing target directory" msgstr "" -#: lxc/network_peer.go:246 +#: lxc/network_peer.go:274 msgid "Missing target network" msgstr "" -#: lxc/network.go:861 +#: lxc/network.go:953 msgid "Mode" msgstr "" -#: lxc/info.go:269 +#: lxc/info.go:277 #, c-format msgid "Model: %s" msgstr "" -#: lxc/info.go:129 +#: lxc/info.go:137 #, c-format msgid "Model: %v" msgstr "" @@ -3931,20 +3959,20 @@ msgid "" "By default the monitor will listen to all message types." msgstr "" -#: lxc/network.go:467 lxc/network.go:552 lxc/storage_volume.go:778 -#: lxc/storage_volume.go:859 +#: lxc/network.go:519 lxc/network.go:616 lxc/storage_volume.go:867 +#: lxc/storage_volume.go:964 msgid "More than one device matches, specify the device name" msgstr "" -#: lxc/file.go:475 +#: lxc/file.go:507 msgid "More than one file to download, but target is not a directory" msgstr "" -#: lxc/file.go:1175 lxc/file.go:1176 +#: lxc/file.go:1207 lxc/file.go:1208 msgid "Mount files from instances" msgstr "" -#: lxc/info.go:283 lxc/info.go:293 +#: lxc/info.go:291 lxc/info.go:301 #, c-format msgid "Mounted: %v" msgstr "" @@ -3969,7 +3997,7 @@ msgid "" "versions.\n" msgstr "" -#: lxc/storage_volume.go:1696 lxc/storage_volume.go:1697 +#: lxc/storage_volume.go:1849 lxc/storage_volume.go:1850 msgid "Move storage volumes between pools" msgstr "" @@ -3977,38 +4005,38 @@ msgstr "" msgid "Move the instance without its snapshots" msgstr "" -#: lxc/storage_volume.go:1703 +#: lxc/storage_volume.go:1856 msgid "Move to a project different from the source" msgstr "" -#: lxc/storage_volume.go:465 +#: lxc/storage_volume.go:518 #, c-format msgid "Moving the storage volume: %s" msgstr "" -#: lxc/network_forward.go:938 lxc/network_load_balancer.go:1102 +#: lxc/network_forward.go:1059 lxc/network_load_balancer.go:1206 msgid "Multiple ports match. Use --force to remove them all" msgstr "" -#: lxc/network_acl.go:971 +#: lxc/network_acl.go:1092 msgid "Multiple rules match. Use --force to remove them all" msgstr "" -#: lxc/image.go:684 +#: lxc/image.go:728 msgid "Must run as root to import from directory" msgstr "" -#: lxc/action.go:234 +#: lxc/action.go:250 msgid "Must supply instance name for: " msgstr "" -#: lxc/auth.go:376 lxc/auth.go:816 lxc/auth.go:1736 lxc/cluster.go:184 -#: lxc/cluster.go:965 lxc/cluster_group.go:459 lxc/config_trust.go:409 -#: lxc/config_trust.go:514 lxc/list.go:565 lxc/network.go:981 -#: lxc/network_acl.go:148 lxc/network_peer.go:140 lxc/network_zone.go:139 -#: lxc/network_zone.go:746 lxc/profile.go:678 lxc/project.go:522 -#: lxc/remote.go:801 lxc/storage.go:663 lxc/storage_bucket.go:512 -#: lxc/storage_bucket.go:832 lxc/storage_volume.go:1584 +#: lxc/auth.go:381 lxc/auth.go:968 lxc/auth.go:1943 lxc/cluster.go:192 +#: lxc/cluster.go:1069 lxc/cluster_group.go:503 lxc/config_trust.go:409 +#: lxc/config_trust.go:514 lxc/list.go:573 lxc/network.go:1081 +#: lxc/network_acl.go:156 lxc/network_peer.go:148 lxc/network_zone.go:147 +#: lxc/network_zone.go:827 lxc/profile.go:755 lxc/project.go:567 +#: lxc/remote.go:850 lxc/storage.go:715 lxc/storage_bucket.go:512 +#: lxc/storage_bucket.go:832 lxc/storage_volume.go:1737 msgid "NAME" msgstr "" @@ -4020,48 +4048,48 @@ msgstr "" msgid "NETWORK" msgstr "" -#: lxc/project.go:528 +#: lxc/project.go:573 msgid "NETWORK ZONES" msgstr "" -#: lxc/project.go:527 +#: lxc/project.go:572 msgid "NETWORKS" msgstr "" -#: lxc/info.go:410 +#: lxc/info.go:418 msgid "NIC:" msgstr "" -#: lxc/info.go:413 +#: lxc/info.go:421 msgid "NICs:" msgstr "" -#: lxc/network.go:958 lxc/operation.go:155 lxc/project.go:480 -#: lxc/project.go:485 lxc/project.go:490 lxc/project.go:495 lxc/project.go:500 -#: lxc/project.go:505 lxc/remote.go:761 lxc/remote.go:766 lxc/remote.go:771 +#: lxc/network.go:1058 lxc/operation.go:155 lxc/project.go:525 +#: lxc/project.go:530 lxc/project.go:535 lxc/project.go:540 lxc/project.go:545 +#: lxc/project.go:550 lxc/remote.go:810 lxc/remote.go:815 lxc/remote.go:820 msgid "NO" msgstr "" -#: lxc/info.go:90 lxc/info.go:176 lxc/info.go:263 +#: lxc/info.go:98 lxc/info.go:184 lxc/info.go:271 #, c-format msgid "NUMA node: %v" msgstr "" -#: lxc/info.go:376 +#: lxc/info.go:384 msgid "NUMA nodes:\n" msgstr "" -#: lxc/info.go:126 +#: lxc/info.go:134 msgid "NVIDIA information:" msgstr "" -#: lxc/info.go:131 +#: lxc/info.go:139 #, c-format msgid "NVRM Version: %v" msgstr "" -#: lxc/info.go:630 lxc/info.go:681 lxc/storage_volume.go:1367 -#: lxc/storage_volume.go:1417 +#: lxc/info.go:638 lxc/info.go:689 lxc/storage_volume.go:1512 +#: lxc/storage_volume.go:1562 msgid "Name" msgstr "" @@ -4069,77 +4097,77 @@ msgstr "" msgid "Name of the project to use for this remote:" msgstr "" -#: lxc/info.go:464 lxc/network.go:834 lxc/storage_volume.go:1307 +#: lxc/info.go:472 lxc/network.go:926 lxc/storage_volume.go:1452 #, c-format msgid "Name: %s" msgstr "" -#: lxc/info.go:305 +#: lxc/info.go:313 #, c-format msgid "Name: %v" msgstr "" -#: lxc/network.go:355 +#: lxc/network.go:387 #, c-format msgid "Network %s created" msgstr "" -#: lxc/network.go:407 +#: lxc/network.go:447 #, c-format msgid "Network %s deleted" msgstr "" -#: lxc/network.go:353 +#: lxc/network.go:385 #, c-format msgid "Network %s pending on member %s" msgstr "" -#: lxc/network.go:1111 +#: lxc/network.go:1227 #, c-format msgid "Network %s renamed to %s" msgstr "" -#: lxc/network_acl.go:399 +#: lxc/network_acl.go:443 #, c-format msgid "Network ACL %s created" msgstr "" -#: lxc/network_acl.go:739 +#: lxc/network_acl.go:828 #, c-format msgid "Network ACL %s deleted" msgstr "" -#: lxc/network_acl.go:690 +#: lxc/network_acl.go:771 #, c-format msgid "Network ACL %s renamed to %s" msgstr "" -#: lxc/network_zone.go:341 +#: lxc/network_zone.go:378 #, c-format msgid "Network Zone %s created" msgstr "" -#: lxc/network_zone.go:621 +#: lxc/network_zone.go:694 #, c-format msgid "Network Zone %s deleted" msgstr "" -#: lxc/network_forward.go:366 +#: lxc/network_forward.go:386 #, c-format msgid "Network forward %s created" msgstr "" -#: lxc/network_forward.go:768 +#: lxc/network_forward.go:857 #, c-format msgid "Network forward %s deleted" msgstr "" -#: lxc/network_load_balancer.go:370 +#: lxc/network_load_balancer.go:390 #, c-format msgid "Network load balancer %s created" msgstr "" -#: lxc/network_load_balancer.go:772 +#: lxc/network_load_balancer.go:828 #, c-format msgid "Network load balancer %s deleted" msgstr "" @@ -4148,41 +4176,41 @@ msgstr "" msgid "Network name" msgstr "" -#: lxc/network_peer.go:309 +#: lxc/network_peer.go:337 #, c-format msgid "Network peer %s created" msgstr "" -#: lxc/network_peer.go:682 +#: lxc/network_peer.go:781 #, c-format msgid "Network peer %s deleted" msgstr "" -#: lxc/network_peer.go:313 +#: lxc/network_peer.go:341 #, c-format msgid "Network peer %s is in unexpected state %q" msgstr "" -#: lxc/network_peer.go:311 +#: lxc/network_peer.go:339 #, c-format msgid "" "Network peer %s pending (please complete mutual peering on peer network)" msgstr "" -#: lxc/network.go:302 +#: lxc/network.go:326 msgid "Network type" msgstr "" -#: lxc/info.go:587 lxc/network.go:851 +#: lxc/info.go:595 lxc/network.go:943 msgid "Network usage:" msgstr "" -#: lxc/network_zone.go:943 +#: lxc/network_zone.go:1065 #, c-format msgid "Network zone record %s created" msgstr "" -#: lxc/network_zone.go:1216 +#: lxc/network_zone.go:1392 #, c-format msgid "Network zone record %s deleted" msgstr "" @@ -4191,7 +4219,7 @@ msgstr "" msgid "New alias to define at target" msgstr "" -#: lxc/image.go:168 lxc/image.go:673 +#: lxc/image.go:168 lxc/image.go:705 msgid "New aliases to add to the image" msgstr "" @@ -4204,82 +4232,86 @@ msgstr "" msgid "No certificate add token for member %s on remote: %s" msgstr "" -#: lxc/cluster.go:1048 +#: lxc/cluster.go:1161 #, c-format msgid "No cluster join token for member %s on remote: %s" msgstr "" -#: lxc/network.go:476 lxc/network.go:561 +#: lxc/network.go:528 lxc/network.go:625 msgid "No device found for this network" msgstr "" -#: lxc/storage_volume.go:787 lxc/storage_volume.go:868 +#: lxc/storage_volume.go:876 lxc/storage_volume.go:973 msgid "No device found for this storage volume" msgstr "" -#: lxc/network_load_balancer.go:926 +#: lxc/network_load_balancer.go:1006 msgid "No matching backend found" msgstr "" -#: lxc/network_forward.go:949 lxc/network_load_balancer.go:1113 +#: lxc/network_forward.go:1070 lxc/network_load_balancer.go:1217 msgid "No matching port(s) found" msgstr "" -#: lxc/network_acl.go:982 +#: lxc/network_acl.go:1103 msgid "No matching rule(s) found" msgstr "" -#: lxc/storage_volume.go:399 lxc/storage_volume.go:1734 +#: lxc/warning.go:393 +msgid "No need to specify a warning UUID when using --all" +msgstr "" + +#: lxc/storage_volume.go:452 lxc/storage_volume.go:1905 msgid "No storage pool for source volume specified" msgstr "" -#: lxc/storage_volume.go:449 lxc/storage_volume.go:1745 +#: lxc/storage_volume.go:502 lxc/storage_volume.go:1916 msgid "No storage pool for target volume specified" msgstr "" -#: lxc/config_device.go:131 lxc/config_device.go:405 +#: lxc/config_device.go:151 lxc/config_device.go:465 #, c-format msgid "No value found in %q" msgstr "" -#: lxc/info.go:378 +#: lxc/info.go:386 #, c-format msgid "Node %d:\n" msgstr "" -#: lxc/storage_volume.go:1841 +#: lxc/storage_volume.go:2024 msgid "Not a snapshot name" msgstr "" -#: lxc/network.go:893 +#: lxc/network.go:985 msgid "OVN:" msgstr "" -#: lxc/storage_volume.go:195 lxc/storage_volume.go:308 +#: lxc/storage_volume.go:214 lxc/storage_volume.go:343 msgid "Only \"custom\" volumes can be attached to instances" msgstr "" -#: lxc/storage_volume.go:2424 +#: lxc/storage_volume.go:2690 msgid "Only \"custom\" volumes can be exported" msgstr "" -#: lxc/storage_volume.go:2251 +#: lxc/storage_volume.go:2489 msgid "Only \"custom\" volumes can be snapshotted" msgstr "" -#: lxc/remote.go:375 +#: lxc/remote.go:393 msgid "Only https URLs are supported for simplestreams" msgstr "" -#: lxc/image.go:761 +#: lxc/image.go:805 msgid "Only https:// is supported for remote image import" msgstr "" -#: lxc/storage_volume.go:1273 +#: lxc/storage_volume.go:1418 msgid "Only instance or custom volumes are supported" msgstr "" -#: lxc/network.go:663 lxc/network.go:1174 +#: lxc/network.go:735 lxc/network.go:1298 msgid "Only managed networks can be modified" msgstr "" @@ -4288,11 +4320,11 @@ msgstr "" msgid "Operation %s deleted" msgstr "" -#: lxc/info.go:685 lxc/storage_volume.go:1421 +#: lxc/info.go:693 lxc/storage_volume.go:1566 msgid "Optimized Storage" msgstr "" -#: lxc/main.go:97 +#: lxc/main.go:96 msgid "Override the source project" msgstr "" @@ -4300,70 +4332,71 @@ msgstr "" msgid "Override the terminal mode (auto, interactive or non-interactive)" msgstr "" -#: lxc/info.go:101 lxc/info.go:187 +#: lxc/info.go:109 lxc/info.go:195 #, c-format msgid "PCI address: %v" msgstr "" -#: lxc/network_peer.go:142 +#: lxc/network_peer.go:150 msgid "PEER" msgstr "" -#: lxc/list.go:567 +#: lxc/list.go:575 msgid "PID" msgstr "" -#: lxc/info.go:485 +#: lxc/info.go:493 #, c-format msgid "PID: %d" msgstr "" -#: lxc/storage_volume.go:1603 +#: lxc/storage_volume.go:1756 msgid "POOL" msgstr "" -#: lxc/network_forward.go:151 lxc/network_load_balancer.go:153 +#: lxc/network_forward.go:159 lxc/network_load_balancer.go:161 msgid "PORTS" msgstr "" -#: lxc/list.go:566 +#: lxc/list.go:574 msgid "PROCESSES" msgstr "" -#: lxc/list.go:568 lxc/project.go:524 +#: lxc/list.go:576 lxc/project.go:569 msgid "PROFILES" msgstr "" -#: lxc/list.go:559 lxc/storage_volume.go:1609 lxc/warning.go:213 +#: lxc/image.go:1140 lxc/list.go:567 lxc/storage_volume.go:1762 +#: lxc/warning.go:213 msgid "PROJECT" msgstr "" -#: lxc/remote.go:803 +#: lxc/remote.go:852 msgid "PROTOCOL" msgstr "" -#: lxc/image.go:1076 lxc/remote.go:805 +#: lxc/image.go:1145 lxc/remote.go:854 msgid "PUBLIC" msgstr "" -#: lxc/info.go:571 lxc/network.go:854 +#: lxc/info.go:579 lxc/network.go:946 msgid "Packets received" msgstr "" -#: lxc/info.go:572 lxc/network.go:855 +#: lxc/info.go:580 lxc/network.go:947 msgid "Packets sent" msgstr "" -#: lxc/info.go:287 +#: lxc/info.go:295 msgid "Partitions:" msgstr "" -#: lxc/main.go:363 +#: lxc/main.go:367 #, c-format msgid "Password for %s: " msgstr "" -#: lxc/action.go:53 lxc/action.go:54 +#: lxc/action.go:57 lxc/action.go:58 msgid "Pause instances" msgstr "" @@ -4379,37 +4412,37 @@ msgstr "" msgid "Please provide client name: " msgstr "" -#: lxc/cluster.go:835 +#: lxc/cluster.go:931 msgid "Please provide cluster member name: " msgstr "" -#: lxc/remote.go:533 +#: lxc/remote.go:551 msgid "Please type 'y', 'n' or the fingerprint: " msgstr "" -#: lxc/info.go:213 +#: lxc/info.go:221 #, c-format msgid "Port type: %s" msgstr "" -#: lxc/info.go:195 +#: lxc/info.go:203 msgid "Ports:" msgstr "" -#: lxc/file.go:1292 +#: lxc/file.go:1332 msgid "Press ctrl+c to finish" msgstr "" -#: lxc/auth.go:302 lxc/auth.go:1056 lxc/auth.go:1662 lxc/cluster.go:772 -#: lxc/cluster_group.go:362 lxc/config.go:274 lxc/config.go:349 -#: lxc/config.go:1279 lxc/config_metadata.go:149 lxc/config_template.go:207 -#: lxc/config_trust.go:315 lxc/image.go:468 lxc/network.go:688 -#: lxc/network_acl.go:626 lxc/network_forward.go:691 -#: lxc/network_load_balancer.go:695 lxc/network_peer.go:612 -#: lxc/network_zone.go:557 lxc/network_zone.go:1153 lxc/profile.go:540 -#: lxc/project.go:340 lxc/storage.go:336 lxc/storage_bucket.go:350 -#: lxc/storage_bucket.go:1127 lxc/storage_volume.go:1040 -#: lxc/storage_volume.go:1072 +#: lxc/auth.go:307 lxc/auth.go:1208 lxc/auth.go:1869 lxc/cluster.go:860 +#: lxc/cluster_group.go:398 lxc/config.go:282 lxc/config.go:357 +#: lxc/config.go:1344 lxc/config_metadata.go:157 lxc/config_template.go:239 +#: lxc/config_trust.go:315 lxc/image.go:492 lxc/network.go:760 +#: lxc/network_acl.go:699 lxc/network_forward.go:768 +#: lxc/network_load_balancer.go:739 lxc/network_peer.go:699 +#: lxc/network_zone.go:622 lxc/network_zone.go:1317 lxc/profile.go:596 +#: lxc/project.go:365 lxc/storage.go:360 lxc/storage_bucket.go:350 +#: lxc/storage_bucket.go:1127 lxc/storage_volume.go:1157 +#: lxc/storage_volume.go:1189 msgid "Press enter to open the editor again or ctrl+c to abort change" msgstr "" @@ -4417,7 +4450,7 @@ msgstr "" msgid "Pretty rendering (short for --format=pretty)" msgstr "" -#: lxc/main.go:95 +#: lxc/main.go:94 msgid "Print help" msgstr "" @@ -4425,51 +4458,51 @@ msgstr "" msgid "Print the raw response" msgstr "" -#: lxc/main.go:94 +#: lxc/main.go:93 msgid "Print version number" msgstr "" -#: lxc/info.go:499 +#: lxc/info.go:507 #, c-format msgid "Processes: %d" msgstr "" -#: lxc/main_aliases.go:202 lxc/main_aliases.go:209 +#: lxc/main_aliases.go:223 lxc/main_aliases.go:230 #, c-format msgid "Processing aliases failed: %s" msgstr "" -#: lxc/info.go:97 lxc/info.go:183 +#: lxc/info.go:105 lxc/info.go:191 #, c-format msgid "Product: %v (%v)" msgstr "" -#: lxc/profile.go:151 +#: lxc/profile.go:163 #, c-format msgid "Profile %s added to %s" msgstr "" -#: lxc/profile.go:378 +#: lxc/profile.go:418 #, c-format msgid "Profile %s created" msgstr "" -#: lxc/profile.go:429 +#: lxc/profile.go:477 #, c-format msgid "Profile %s deleted" msgstr "" -#: lxc/profile.go:729 +#: lxc/profile.go:818 #, c-format msgid "Profile %s isn't currently applied to %s" msgstr "" -#: lxc/profile.go:754 +#: lxc/profile.go:843 #, c-format msgid "Profile %s removed from %s" msgstr "" -#: lxc/profile.go:805 +#: lxc/profile.go:902 #, c-format msgid "Profile %s renamed to %s" msgstr "" @@ -4486,30 +4519,30 @@ msgstr "" msgid "Profile to apply to the target instance" msgstr "" -#: lxc/profile.go:231 +#: lxc/profile.go:251 #, c-format msgid "Profiles %s applied to %s" msgstr "" -#: lxc/image.go:1015 +#: lxc/image.go:1067 msgid "Profiles:" msgstr "" -#: lxc/image.go:1013 +#: lxc/image.go:1065 msgid "Profiles: " msgstr "" -#: lxc/project.go:165 +#: lxc/project.go:174 #, c-format msgid "Project %s created" msgstr "" -#: lxc/project.go:221 +#: lxc/project.go:238 #, c-format msgid "Project %s deleted" msgstr "" -#: lxc/project.go:586 +#: lxc/project.go:639 #, c-format msgid "Project %s renamed to %s" msgstr "" @@ -4518,15 +4551,15 @@ msgstr "" msgid "Project to use for the remote" msgstr "" -#: lxc/image.go:988 +#: lxc/image.go:1040 msgid "Properties:" msgstr "" -#: lxc/image.go:1536 +#: lxc/image.go:1654 msgid "Property not found" msgstr "" -#: lxc/storage_volume.go:1223 +#: lxc/storage_volume.go:1356 msgid "" "Provide the type of the storage volume if it is not custom.\n" "Supported types are custom, container and virtual-machine.\n" @@ -4540,7 +4573,7 @@ msgid "" "\"default\"." msgstr "" -#: lxc/storage_volume.go:1108 +#: lxc/storage_volume.go:1225 msgid "" "Provide the type of the storage volume if it is not custom.\n" "Supported types are custom, image, container and virtual-machine.\n" @@ -4556,7 +4589,7 @@ msgid "" "pool \"default\"." msgstr "" -#: lxc/storage_volume.go:2039 +#: lxc/storage_volume.go:2236 msgid "" "Provide the type of the storage volume if it is not custom.\n" "Supported types are custom, image, container and virtual-machine.\n" @@ -4577,7 +4610,7 @@ msgid "" "called \"data\" in the \"default\" pool." msgstr "" -#: lxc/storage_volume.go:899 +#: lxc/storage_volume.go:1004 msgid "" "Provide the type of the storage volume if it is not custom.\n" "Supported types are custom, image, container and virtual-machine.\n" @@ -4586,7 +4619,7 @@ msgid "" " Update a storage volume using the content of pool.yaml." msgstr "" -#: lxc/storage_volume.go:1898 +#: lxc/storage_volume.go:2081 msgid "" "Provide the type of the storage volume if it is not custom.\n" "Supported types are custom, image, container and virtual-machine.\n" @@ -4599,7 +4632,7 @@ msgid "" "pool \"default\" to seven days." msgstr "" -#: lxc/storage_volume.go:2148 +#: lxc/storage_volume.go:2357 msgid "" "Provide the type of the storage volume if it is not custom.\n" "Supported types are custom, image, container and virtual-machine.\n" @@ -4616,7 +4649,7 @@ msgstr "" msgid "Public image server" msgstr "" -#: lxc/image.go:966 +#: lxc/image.go:1018 #, c-format msgid "Public: %s" msgstr "" @@ -4625,25 +4658,25 @@ msgstr "" msgid "Publish instances as images" msgstr "" -#: lxc/publish.go:254 +#: lxc/publish.go:266 #, c-format msgid "Publishing instance: %s" msgstr "" -#: lxc/file.go:431 lxc/file.go:432 +#: lxc/file.go:455 lxc/file.go:456 msgid "Pull files from instances" msgstr "" -#: lxc/file.go:606 lxc/file.go:957 +#: lxc/file.go:638 lxc/file.go:989 #, c-format msgid "Pulling %s from %s: %%s" msgstr "" -#: lxc/file.go:656 lxc/file.go:657 +#: lxc/file.go:688 lxc/file.go:689 msgid "Push files into instances" msgstr "" -#: lxc/file.go:891 lxc/file.go:1057 +#: lxc/file.go:923 lxc/file.go:1089 #, c-format msgid "Pushing %s to %s: %%s" msgstr "" @@ -4652,11 +4685,11 @@ msgstr "" msgid "Query path must start with /" msgstr "" -#: lxc/image.go:506 lxc/image.go:907 lxc/image.go:1447 +#: lxc/image.go:530 lxc/image.go:951 lxc/image.go:1544 msgid "Query virtual machine images" msgstr "" -#: lxc/project.go:886 +#: lxc/project.go:993 msgid "RESOURCE" msgstr "" @@ -4664,11 +4697,11 @@ msgstr "" msgid "ROLE" msgstr "" -#: lxc/cluster.go:186 +#: lxc/cluster.go:194 msgid "ROLES" msgstr "" -#: lxc/info.go:282 lxc/info.go:292 +#: lxc/info.go:290 lxc/info.go:300 #, c-format msgid "Read-Only: %v" msgstr "" @@ -4681,55 +4714,55 @@ msgstr "" msgid "Rebuild instances" msgstr "" -#: lxc/file.go:439 lxc/file.go:663 +#: lxc/file.go:463 lxc/file.go:695 msgid "Recursively transfer files" msgstr "" -#: lxc/storage_volume.go:363 +#: lxc/storage_volume.go:398 msgid "Refresh and update the existing storage volume copies" msgstr "" -#: lxc/image.go:1356 lxc/image.go:1357 +#: lxc/image.go:1449 lxc/image.go:1450 msgid "Refresh images" msgstr "" -#: lxc/copy.go:414 +#: lxc/copy.go:426 #, c-format msgid "Refreshing instance: %s" msgstr "" -#: lxc/image.go:1389 +#: lxc/image.go:1486 #, c-format msgid "Refreshing the image: %s" msgstr "" -#: lxc/remote.go:855 +#: lxc/remote.go:912 #, c-format msgid "Remote %s already exists" msgstr "" -#: lxc/project.go:793 lxc/remote.go:846 lxc/remote.go:919 lxc/remote.go:975 -#: lxc/remote.go:1015 +#: lxc/project.go:884 lxc/remote.go:903 lxc/remote.go:984 lxc/remote.go:1048 +#: lxc/remote.go:1096 #, c-format msgid "Remote %s doesn't exist" msgstr "" -#: lxc/remote.go:342 +#: lxc/remote.go:360 #, c-format msgid "Remote %s exists as <%s>" msgstr "" -#: lxc/remote.go:927 +#: lxc/remote.go:992 #, c-format msgid "Remote %s is global and cannot be removed" msgstr "" -#: lxc/remote.go:850 lxc/remote.go:923 lxc/remote.go:1019 +#: lxc/remote.go:907 lxc/remote.go:988 lxc/remote.go:1100 #, c-format msgid "Remote %s is static and cannot be modified" msgstr "" -#: lxc/remote.go:316 +#: lxc/remote.go:334 msgid "Remote address must not be empty" msgstr "" @@ -4737,7 +4770,7 @@ msgstr "" msgid "Remote admin password" msgstr "" -#: lxc/remote.go:336 +#: lxc/remote.go:354 msgid "Remote names may not contain colons" msgstr "" @@ -4745,29 +4778,29 @@ msgstr "" msgid "Remote trust token" msgstr "" -#: lxc/info.go:284 +#: lxc/info.go:292 #, c-format msgid "Removable: %v" msgstr "" -#: lxc/delete.go:44 +#: lxc/delete.go:48 #, c-format msgid "Remove %s (yes/no): " msgstr "" -#: lxc/cluster_group.go:478 +#: lxc/cluster_group.go:522 msgid "Remove a cluster member from a cluster group" msgstr "" -#: lxc/auth.go:1165 lxc/auth.go:1166 +#: lxc/auth.go:1372 lxc/auth.go:1373 msgid "Remove a group from an identity" msgstr "" -#: lxc/cluster.go:521 lxc/cluster.go:522 +#: lxc/cluster.go:585 lxc/cluster.go:586 msgid "Remove a member from the cluster" msgstr "" -#: lxc/network_zone.go:1294 +#: lxc/network_zone.go:1482 msgid "Remove a network zone record entry" msgstr "" @@ -4775,63 +4808,63 @@ msgstr "" msgid "Remove aliases" msgstr "" -#: lxc/network_forward.go:866 lxc/network_load_balancer.go:1030 +#: lxc/network_forward.go:971 lxc/network_load_balancer.go:1122 msgid "Remove all ports that match" msgstr "" -#: lxc/network_acl.go:893 +#: lxc/network_acl.go:998 msgid "Remove all rules that match" msgstr "" -#: lxc/network_load_balancer.go:867 +#: lxc/network_load_balancer.go:935 msgid "Remove backend from a load balancer" msgstr "" -#: lxc/network_load_balancer.go:866 +#: lxc/network_load_balancer.go:934 msgid "Remove backends from a load balancer" msgstr "" -#: lxc/network_zone.go:1295 +#: lxc/network_zone.go:1483 msgid "Remove entries from a network zone record" msgstr "" -#: lxc/auth.go:1928 lxc/auth.go:1929 +#: lxc/auth.go:2135 lxc/auth.go:2136 msgid "Remove identities from groups" msgstr "" -#: lxc/config_device.go:450 lxc/config_device.go:451 +#: lxc/config_device.go:510 lxc/config_device.go:511 msgid "Remove instance devices" msgstr "" -#: lxc/cluster_group.go:477 +#: lxc/cluster_group.go:521 msgid "Remove member from group" msgstr "" -#: lxc/auth.go:575 lxc/auth.go:576 +#: lxc/auth.go:580 lxc/auth.go:581 msgid "Remove permissions from groups" msgstr "" -#: lxc/network_forward.go:864 lxc/network_forward.go:865 +#: lxc/network_forward.go:969 lxc/network_forward.go:970 msgid "Remove ports from a forward" msgstr "" -#: lxc/network_load_balancer.go:1028 lxc/network_load_balancer.go:1029 +#: lxc/network_load_balancer.go:1120 lxc/network_load_balancer.go:1121 msgid "Remove ports from a load balancer" msgstr "" -#: lxc/profile.go:694 lxc/profile.go:695 +#: lxc/profile.go:771 lxc/profile.go:772 msgid "Remove profiles from instances" msgstr "" -#: lxc/remote.go:897 lxc/remote.go:898 +#: lxc/remote.go:954 lxc/remote.go:955 msgid "Remove remotes" msgstr "" -#: lxc/cluster_role.go:106 lxc/cluster_role.go:107 +#: lxc/cluster_role.go:114 lxc/cluster_role.go:115 msgid "Remove roles from a cluster member" msgstr "" -#: lxc/network_acl.go:891 lxc/network_acl.go:892 +#: lxc/network_acl.go:996 lxc/network_acl.go:997 msgid "Remove rules from an ACL" msgstr "" @@ -4839,11 +4872,11 @@ msgstr "" msgid "Remove trusted client" msgstr "" -#: lxc/cluster_group.go:550 lxc/cluster_group.go:551 +#: lxc/cluster_group.go:606 lxc/cluster_group.go:607 msgid "Rename a cluster group" msgstr "" -#: lxc/cluster.go:471 lxc/cluster.go:472 +#: lxc/cluster.go:527 lxc/cluster.go:528 msgid "Rename a cluster member" msgstr "" @@ -4852,11 +4885,11 @@ msgstr "" msgid "Rename aliases" msgstr "" -#: lxc/auth.go:392 lxc/auth.go:393 +#: lxc/auth.go:397 lxc/auth.go:398 msgid "Rename groups" msgstr "" -#: lxc/auth.go:1752 lxc/auth.go:1753 +#: lxc/auth.go:1959 lxc/auth.go:1960 msgid "Rename identity provider groups" msgstr "" @@ -4864,49 +4897,49 @@ msgstr "" msgid "Rename instances and snapshots" msgstr "" -#: lxc/network_acl.go:657 lxc/network_acl.go:658 +#: lxc/network_acl.go:730 lxc/network_acl.go:731 msgid "Rename network ACLs" msgstr "" -#: lxc/network.go:1076 lxc/network.go:1077 +#: lxc/network.go:1184 lxc/network.go:1185 msgid "Rename networks" msgstr "" -#: lxc/profile.go:770 lxc/profile.go:771 +#: lxc/profile.go:859 lxc/profile.go:860 msgid "Rename profiles" msgstr "" -#: lxc/project.go:546 lxc/project.go:547 +#: lxc/project.go:591 lxc/project.go:592 msgid "Rename projects" msgstr "" -#: lxc/remote.go:824 lxc/remote.go:825 +#: lxc/remote.go:873 lxc/remote.go:874 msgid "Rename remotes" msgstr "" -#: lxc/storage_volume.go:1778 +#: lxc/storage_volume.go:1949 msgid "Rename storage volumes" msgstr "" -#: lxc/storage_volume.go:1777 +#: lxc/storage_volume.go:1948 msgid "Rename storage volumes and storage volume snapshots" msgstr "" -#: lxc/storage_volume.go:1854 lxc/storage_volume.go:1874 +#: lxc/storage_volume.go:2037 lxc/storage_volume.go:2057 #, c-format msgid "Renamed storage volume from \"%s\" to \"%s\"" msgstr "" -#: lxc/info.go:121 +#: lxc/info.go:129 #, c-format msgid "Render: %s (%s)" msgstr "" -#: lxc/cluster.go:804 lxc/cluster.go:805 +#: lxc/cluster.go:892 lxc/cluster.go:893 msgid "Request a join token for adding a cluster member" msgstr "" -#: lxc/config.go:970 +#: lxc/config.go:1035 msgid "Requested UEFI variable does not exist" msgstr "" @@ -4914,22 +4947,22 @@ msgstr "" msgid "Require user confirmation" msgstr "" -#: lxc/info.go:497 +#: lxc/info.go:505 msgid "Resources:" msgstr "" -#: lxc/action.go:75 +#: lxc/action.go:83 msgid "Restart instances" msgstr "" -#: lxc/action.go:76 +#: lxc/action.go:84 msgid "" "Restart instances\n" "\n" "The opposite of \"lxc pause\" is \"lxc start\"." msgstr "" -#: lxc/cluster.go:1190 lxc/cluster.go:1191 +#: lxc/cluster.go:1328 lxc/cluster.go:1329 msgid "Restore cluster member" msgstr "" @@ -4944,11 +4977,11 @@ msgid "" "If --stateful is passed, then the running state will be restored too." msgstr "" -#: lxc/storage_volume.go:2314 lxc/storage_volume.go:2315 +#: lxc/storage_volume.go:2552 lxc/storage_volume.go:2553 msgid "Restore storage volume snapshots" msgstr "" -#: lxc/cluster.go:1248 +#: lxc/cluster.go:1394 #, c-format msgid "Restoring cluster member: %s" msgstr "" @@ -4957,11 +4990,11 @@ msgstr "" msgid "Restrict the certificate to one or more projects" msgstr "" -#: lxc/console.go:44 +#: lxc/console.go:47 msgid "Retrieve the container's console log" msgstr "" -#: lxc/init.go:348 +#: lxc/init.go:357 #, c-format msgid "Retrieving image: %s" msgstr "" @@ -4970,7 +5003,7 @@ msgstr "" msgid "Revoke certificate add token" msgstr "" -#: lxc/cluster.go:982 +#: lxc/cluster.go:1086 msgid "Revoke cluster member join token" msgstr "" @@ -4982,7 +5015,7 @@ msgstr "" msgid "Run again a specific project" msgstr "" -#: lxc/action.go:122 +#: lxc/action.go:138 msgid "Run against all instances" msgstr "" @@ -4994,38 +5027,38 @@ msgstr "" msgid "SEVERITY" msgstr "" -#: lxc/image.go:1079 +#: lxc/image.go:1146 msgid "SIZE" msgstr "" -#: lxc/list.go:569 +#: lxc/list.go:577 msgid "SNAPSHOTS" msgstr "" -#: lxc/storage.go:668 +#: lxc/storage.go:720 msgid "SOURCE" msgstr "" -#: lxc/info.go:136 lxc/info.go:245 +#: lxc/info.go:144 lxc/info.go:253 msgid "SR-IOV information:" msgstr "" -#: lxc/file.go:1420 +#: lxc/file.go:1460 #, c-format msgid "SSH client connected %q" msgstr "" -#: lxc/file.go:1421 +#: lxc/file.go:1461 #, c-format msgid "SSH client disconnected %q" msgstr "" -#: lxc/cluster.go:190 lxc/list.go:570 lxc/network.go:988 -#: lxc/network_peer.go:143 lxc/storage.go:673 +#: lxc/cluster.go:198 lxc/list.go:578 lxc/network.go:1088 +#: lxc/network_peer.go:151 lxc/storage.go:725 msgid "STATE" msgstr "" -#: lxc/remote.go:806 +#: lxc/remote.go:855 msgid "STATIC" msgstr "" @@ -5033,19 +5066,19 @@ msgstr "" msgid "STATUS" msgstr "" -#: lxc/project.go:526 +#: lxc/project.go:571 msgid "STORAGE BUCKETS" msgstr "" -#: lxc/list.go:555 +#: lxc/list.go:563 msgid "STORAGE POOL" msgstr "" -#: lxc/project.go:525 +#: lxc/project.go:570 msgid "STORAGE VOLUMES" msgstr "" -#: lxc/network.go:875 +#: lxc/network.go:967 msgid "STP" msgstr "" @@ -5066,11 +5099,11 @@ msgstr "" msgid "Server authentication type (tls or oidc)" msgstr "" -#: lxc/remote.go:529 +#: lxc/remote.go:547 msgid "Server certificate NACKed by user" msgstr "" -#: lxc/remote.go:282 lxc/remote.go:669 +#: lxc/remote.go:300 lxc/remote.go:718 msgid "Server doesn't trust us after authentication" msgstr "" @@ -5083,23 +5116,23 @@ msgstr "" msgid "Server version: %s\n" msgstr "" -#: lxc/config.go:988 lxc/config.go:989 +#: lxc/config.go:1053 lxc/config.go:1054 msgid "Set UEFI variables for instance" msgstr "" -#: lxc/cluster.go:367 +#: lxc/cluster.go:403 msgid "Set a cluster member's configuration keys" msgstr "" -#: lxc/file.go:1185 +#: lxc/file.go:1217 msgid "Set authentication user when using SSH SFTP listener" msgstr "" -#: lxc/config_device.go:546 +#: lxc/config_device.go:624 msgid "Set device configuration keys" msgstr "" -#: lxc/config_device.go:549 +#: lxc/config_device.go:627 msgid "" "Set device configuration keys\n" "\n" @@ -5108,7 +5141,7 @@ msgid "" " lxc config device set [:] " msgstr "" -#: lxc/config_device.go:556 +#: lxc/config_device.go:634 msgid "" "Set device configuration keys\n" "\n" @@ -5117,15 +5150,15 @@ msgid "" " lxc profile device set [:] " msgstr "" -#: lxc/image.go:1552 lxc/image.go:1553 +#: lxc/image.go:1670 lxc/image.go:1671 msgid "Set image properties" msgstr "" -#: lxc/config.go:517 +#: lxc/config.go:541 msgid "Set instance or server configuration keys" msgstr "" -#: lxc/config.go:518 +#: lxc/config.go:542 msgid "" "Set instance or server configuration keys\n" "\n" @@ -5134,11 +5167,11 @@ msgid "" " lxc config set [:][] " msgstr "" -#: lxc/network_acl.go:416 +#: lxc/network_acl.go:460 msgid "Set network ACL configuration keys" msgstr "" -#: lxc/network_acl.go:417 +#: lxc/network_acl.go:461 msgid "" "Set network ACL configuration keys\n" "\n" @@ -5147,11 +5180,11 @@ msgid "" " lxc network set [:] " msgstr "" -#: lxc/network.go:1128 +#: lxc/network.go:1244 msgid "Set network configuration keys" msgstr "" -#: lxc/network.go:1129 +#: lxc/network.go:1245 msgid "" "Set network configuration keys\n" "\n" @@ -5160,11 +5193,11 @@ msgid "" " lxc network set [:] " msgstr "" -#: lxc/network_forward.go:452 +#: lxc/network_forward.go:488 msgid "Set network forward keys" msgstr "" -#: lxc/network_forward.go:453 +#: lxc/network_forward.go:489 msgid "" "Set network forward keys\n" "\n" @@ -5173,11 +5206,11 @@ msgid "" " lxc network set [:] " msgstr "" -#: lxc/network_load_balancer.go:455 +#: lxc/network_load_balancer.go:475 msgid "Set network load balancer keys" msgstr "" -#: lxc/network_load_balancer.go:456 +#: lxc/network_load_balancer.go:476 msgid "" "Set network load balancer keys\n" "\n" @@ -5186,11 +5219,11 @@ msgid "" " lxc network set [:] " msgstr "" -#: lxc/network_peer.go:399 +#: lxc/network_peer.go:444 msgid "Set network peer keys" msgstr "" -#: lxc/network_peer.go:400 +#: lxc/network_peer.go:445 msgid "" "Set network peer keys\n" "\n" @@ -5199,11 +5232,11 @@ msgid "" " lxc network set [:] " msgstr "" -#: lxc/network_zone.go:358 +#: lxc/network_zone.go:395 msgid "Set network zone configuration keys" msgstr "" -#: lxc/network_zone.go:359 +#: lxc/network_zone.go:396 msgid "" "Set network zone configuration keys\n" "\n" @@ -5212,15 +5245,15 @@ msgid "" " lxc network set [:] " msgstr "" -#: lxc/network_zone.go:960 lxc/network_zone.go:961 +#: lxc/network_zone.go:1082 lxc/network_zone.go:1083 msgid "Set network zone record configuration keys" msgstr "" -#: lxc/profile.go:822 +#: lxc/profile.go:919 msgid "Set profile configuration keys" msgstr "" -#: lxc/profile.go:823 +#: lxc/profile.go:920 msgid "" "Set profile configuration keys\n" "\n" @@ -5229,11 +5262,11 @@ msgid "" " lxc profile set [:] " msgstr "" -#: lxc/project.go:603 +#: lxc/project.go:656 msgid "Set project configuration keys" msgstr "" -#: lxc/project.go:604 +#: lxc/project.go:657 msgid "" "Set project configuration keys\n" "\n" @@ -5255,11 +5288,11 @@ msgid "" " lxc storage bucket set [:] " msgstr "" -#: lxc/storage.go:689 +#: lxc/storage.go:741 msgid "Set storage pool configuration keys" msgstr "" -#: lxc/storage.go:690 +#: lxc/storage.go:742 msgid "" "Set storage pool configuration keys\n" "\n" @@ -5268,11 +5301,11 @@ msgid "" " lxc storage set [:] " msgstr "" -#: lxc/storage_volume.go:1892 +#: lxc/storage_volume.go:2075 msgid "Set storage volume configuration keys" msgstr "" -#: lxc/storage_volume.go:1893 +#: lxc/storage_volume.go:2076 msgid "" "Set storage volume configuration keys\n" "\n" @@ -5281,7 +5314,7 @@ msgid "" " lxc storage volume set [:] [/] " msgstr "" -#: lxc/remote.go:993 lxc/remote.go:994 +#: lxc/remote.go:1066 lxc/remote.go:1067 msgid "Set the URL for the remote" msgstr "" @@ -5289,7 +5322,7 @@ msgstr "" msgid "Set the file's gid on create" msgstr "" -#: lxc/file.go:666 +#: lxc/file.go:698 msgid "Set the file's gid on push" msgstr "" @@ -5297,7 +5330,7 @@ msgstr "" msgid "Set the file's perms on create" msgstr "" -#: lxc/file.go:667 +#: lxc/file.go:699 msgid "Set the file's perms on push" msgstr "" @@ -5305,47 +5338,47 @@ msgstr "" msgid "Set the file's uid on create" msgstr "" -#: lxc/file.go:665 +#: lxc/file.go:697 msgid "Set the file's uid on push" msgstr "" -#: lxc/cluster.go:370 +#: lxc/cluster.go:406 msgid "Set the key as a cluster property" msgstr "" -#: lxc/network_acl.go:423 +#: lxc/network_acl.go:467 msgid "Set the key as a network ACL property" msgstr "" -#: lxc/network_forward.go:460 +#: lxc/network_forward.go:496 msgid "Set the key as a network forward property" msgstr "" -#: lxc/network_load_balancer.go:463 +#: lxc/network_load_balancer.go:483 msgid "Set the key as a network load balancer property" msgstr "" -#: lxc/network_peer.go:407 +#: lxc/network_peer.go:452 msgid "Set the key as a network peer property" msgstr "" -#: lxc/network.go:1136 +#: lxc/network.go:1252 msgid "Set the key as a network property" msgstr "" -#: lxc/network_zone.go:366 +#: lxc/network_zone.go:403 msgid "Set the key as a network zone property" msgstr "" -#: lxc/network_zone.go:966 +#: lxc/network_zone.go:1088 msgid "Set the key as a network zone record property" msgstr "" -#: lxc/profile.go:830 +#: lxc/profile.go:927 msgid "Set the key as a profile property" msgstr "" -#: lxc/project.go:611 +#: lxc/project.go:664 msgid "Set the key as a project property" msgstr "" @@ -5353,43 +5386,43 @@ msgstr "" msgid "Set the key as a storage bucket property" msgstr "" -#: lxc/storage.go:697 +#: lxc/storage.go:749 msgid "Set the key as a storage property" msgstr "" -#: lxc/storage_volume.go:1909 +#: lxc/storage_volume.go:2092 msgid "Set the key as a storage volume property" msgstr "" -#: lxc/config.go:534 +#: lxc/config.go:558 msgid "Set the key as an instance property" msgstr "" -#: lxc/file.go:1183 +#: lxc/file.go:1215 msgid "Setup SSH SFTP listener on address:port instead of mounting" msgstr "" -#: lxc/main.go:98 +#: lxc/main.go:97 msgid "Show all debug messages" msgstr "" -#: lxc/main.go:99 +#: lxc/main.go:98 msgid "Show all information messages" msgstr "" -#: lxc/auth.go:1801 lxc/auth.go:1802 +#: lxc/auth.go:2008 lxc/auth.go:2009 msgid "Show an identity provider group" msgstr "" -#: lxc/cluster_group.go:598 lxc/cluster_group.go:599 +#: lxc/cluster_group.go:662 lxc/cluster_group.go:663 msgid "Show cluster group configurations" msgstr "" -#: lxc/config_template.go:300 lxc/config_template.go:301 +#: lxc/config_template.go:340 lxc/config_template.go:341 msgid "Show content of instance file templates" msgstr "" -#: lxc/cluster.go:206 lxc/cluster.go:207 +#: lxc/cluster.go:214 lxc/cluster.go:215 msgid "Show details of a cluster member" msgstr "" @@ -5401,15 +5434,15 @@ msgstr "" msgid "Show events from all projects" msgstr "" -#: lxc/config_device.go:668 lxc/config_device.go:669 +#: lxc/config_device.go:766 lxc/config_device.go:767 msgid "Show full device configuration" msgstr "" -#: lxc/auth.go:441 lxc/auth.go:442 +#: lxc/auth.go:446 lxc/auth.go:447 msgid "Show group configurations" msgstr "" -#: lxc/auth.go:833 +#: lxc/auth.go:985 msgid "" "Show identity configurations\n" "\n" @@ -5422,19 +5455,19 @@ msgid "" "method. Use the identifier instead if this occurs.\n" msgstr "" -#: lxc/image.go:1443 lxc/image.go:1444 +#: lxc/image.go:1540 lxc/image.go:1541 msgid "Show image properties" msgstr "" -#: lxc/config.go:1110 lxc/config.go:1111 +#: lxc/config.go:1175 lxc/config.go:1176 msgid "Show instance UEFI variables" msgstr "" -#: lxc/config_metadata.go:180 lxc/config_metadata.go:181 +#: lxc/config_metadata.go:188 lxc/config_metadata.go:189 msgid "Show instance metadata files" msgstr "" -#: lxc/config.go:734 lxc/config.go:735 +#: lxc/config.go:774 lxc/config.go:775 msgid "Show instance or server configurations" msgstr "" @@ -5442,7 +5475,7 @@ msgstr "" msgid "Show instance or server information" msgstr "" -#: lxc/main.go:272 lxc/main.go:273 +#: lxc/main.go:271 lxc/main.go:272 msgid "Show less common commands" msgstr "" @@ -5450,47 +5483,47 @@ msgstr "" msgid "Show local and remote versions" msgstr "" -#: lxc/network_acl.go:165 lxc/network_acl.go:166 +#: lxc/network_acl.go:173 lxc/network_acl.go:174 msgid "Show network ACL configurations" msgstr "" -#: lxc/network_acl.go:218 lxc/network_acl.go:219 +#: lxc/network_acl.go:234 lxc/network_acl.go:235 msgid "Show network ACL log" msgstr "" -#: lxc/network.go:1216 lxc/network.go:1217 +#: lxc/network.go:1340 lxc/network.go:1341 msgid "Show network configurations" msgstr "" -#: lxc/network_forward.go:170 lxc/network_forward.go:171 +#: lxc/network_forward.go:178 lxc/network_forward.go:179 msgid "Show network forward configurations" msgstr "" -#: lxc/network_load_balancer.go:172 lxc/network_load_balancer.go:173 +#: lxc/network_load_balancer.go:180 lxc/network_load_balancer.go:181 msgid "Show network load balancer configurations" msgstr "" -#: lxc/network_peer.go:158 lxc/network_peer.go:159 +#: lxc/network_peer.go:166 lxc/network_peer.go:167 msgid "Show network peer configurations" msgstr "" -#: lxc/network_zone.go:156 lxc/network_zone.go:157 +#: lxc/network_zone.go:164 lxc/network_zone.go:165 msgid "Show network zone configurations" msgstr "" -#: lxc/network_zone.go:763 +#: lxc/network_zone.go:844 msgid "Show network zone record configuration" msgstr "" -#: lxc/network_zone.go:764 +#: lxc/network_zone.go:845 msgid "Show network zone record configurations" msgstr "" -#: lxc/profile.go:898 lxc/profile.go:899 +#: lxc/profile.go:1008 lxc/profile.go:1009 msgid "Show profile configurations" msgstr "" -#: lxc/project.go:713 lxc/project.go:714 +#: lxc/project.go:788 lxc/project.go:789 msgid "Show project options" msgstr "" @@ -5502,19 +5535,19 @@ msgstr "" msgid "Show storage bucket key configurations" msgstr "" -#: lxc/storage.go:785 lxc/storage.go:786 +#: lxc/storage.go:845 lxc/storage.go:846 msgid "Show storage pool configurations and resources" msgstr "" -#: lxc/storage_volume.go:2036 lxc/storage_volume.go:2037 +#: lxc/storage_volume.go:2233 lxc/storage_volume.go:2234 msgid "Show storage volume configurations" msgstr "" -#: lxc/storage_volume.go:1220 lxc/storage_volume.go:1221 +#: lxc/storage_volume.go:1353 lxc/storage_volume.go:1354 msgid "Show storage volume state information" msgstr "" -#: lxc/auth.go:896 +#: lxc/auth.go:1048 msgid "" "Show the current identity\n" "\n" @@ -5524,11 +5557,11 @@ msgid "" "that are granted via identity provider group mappings. \n" msgstr "" -#: lxc/remote.go:700 lxc/remote.go:701 +#: lxc/remote.go:749 lxc/remote.go:750 msgid "Show the default remote" msgstr "" -#: lxc/config.go:738 +#: lxc/config.go:778 msgid "Show the expanded configuration" msgstr "" @@ -5540,11 +5573,11 @@ msgstr "" msgid "Show the resources available to the server" msgstr "" -#: lxc/storage.go:789 +#: lxc/storage.go:849 msgid "Show the resources available to the storage pool" msgstr "" -#: lxc/storage.go:442 +#: lxc/storage.go:478 msgid "Show the used and free space in bytes" msgstr "" @@ -5552,15 +5585,15 @@ msgstr "" msgid "Show trust configurations" msgstr "" -#: lxc/cluster.go:255 lxc/cluster.go:256 +#: lxc/cluster.go:271 lxc/cluster.go:272 msgid "Show useful information about a cluster member" msgstr "" -#: lxc/image.go:903 lxc/image.go:904 +#: lxc/image.go:947 lxc/image.go:948 msgid "Show useful information about images" msgstr "" -#: lxc/storage.go:438 lxc/storage.go:439 +#: lxc/storage.go:474 lxc/storage.go:475 msgid "Show useful information about storage pools" msgstr "" @@ -5568,70 +5601,74 @@ msgstr "" msgid "Show warning" msgstr "" -#: lxc/image.go:963 +#: lxc/image.go:1015 #, c-format msgid "Size: %.2fMiB" msgstr "" -#: lxc/info.go:276 lxc/info.go:294 +#: lxc/info.go:284 lxc/info.go:302 #, c-format msgid "Size: %s" msgstr "" -#: lxc/storage_volume.go:2191 lxc/storage_volume.go:2192 +#: lxc/storage_volume.go:2416 lxc/storage_volume.go:2417 msgid "Snapshot storage volumes" msgstr "" -#: lxc/storage_volume.go:1982 +#: lxc/storage_volume.go:2179 msgid "Snapshots are read-only and can't have their configuration changed" msgstr "" -#: lxc/info.go:599 lxc/storage_volume.go:1346 +#: lxc/info.go:607 lxc/storage_volume.go:1491 msgid "Snapshots:" msgstr "" -#: lxc/info.go:361 +#: lxc/info.go:369 #, c-format msgid "Socket %d:" msgstr "" -#: lxc/action.go:402 +#: lxc/action.go:418 #, c-format msgid "Some instances failed to %s" msgstr "" -#: lxc/image.go:1006 +#: lxc/image.go:1058 msgid "Source:" msgstr "" +#: lxc/warning.go:376 +msgid "Specify a warning UUID or use --all" +msgstr "" + #: lxc/action.go:32 lxc/action.go:33 msgid "Start instances" msgstr "" -#: lxc/launch.go:87 +#: lxc/launch.go:92 #, c-format msgid "Starting %s" msgstr "" -#: lxc/info.go:556 +#: lxc/info.go:564 msgid "State" msgstr "" -#: lxc/network.go:837 +#: lxc/network.go:929 #, c-format msgid "State: %s" msgstr "" -#: lxc/info.go:633 +#: lxc/info.go:641 msgid "Stateful" msgstr "" -#: lxc/info.go:466 +#: lxc/info.go:474 #, c-format msgid "Status: %s" msgstr "" -#: lxc/action.go:98 lxc/action.go:99 +#: lxc/action.go:110 lxc/action.go:111 msgid "Stop instances" msgstr "" @@ -5639,11 +5676,11 @@ msgstr "" msgid "Stop the instance if currently running" msgstr "" -#: lxc/publish.go:143 +#: lxc/publish.go:155 msgid "Stopping instance failed!" msgstr "" -#: lxc/delete.go:140 +#: lxc/delete.go:144 #, c-format msgid "Stopping the instance failed: %s" msgstr "" @@ -5668,17 +5705,17 @@ msgstr "" msgid "Storage bucket key %s removed" msgstr "" -#: lxc/storage.go:177 +#: lxc/storage.go:185 #, c-format msgid "Storage pool %s created" msgstr "" -#: lxc/storage.go:229 +#: lxc/storage.go:245 #, c-format msgid "Storage pool %s deleted" msgstr "" -#: lxc/storage.go:175 +#: lxc/storage.go:183 #, c-format msgid "Storage pool %s pending on member %s" msgstr "" @@ -5687,60 +5724,60 @@ msgstr "" msgid "Storage pool name" msgstr "" -#: lxc/storage_volume.go:641 +#: lxc/storage_volume.go:702 #, c-format msgid "Storage volume %s created" msgstr "" -#: lxc/storage_volume.go:717 +#: lxc/storage_volume.go:790 #, c-format msgid "Storage volume %s deleted" msgstr "" -#: lxc/storage_volume.go:462 +#: lxc/storage_volume.go:515 msgid "Storage volume copied successfully!" msgstr "" -#: lxc/storage_volume.go:466 +#: lxc/storage_volume.go:519 msgid "Storage volume moved successfully!" msgstr "" -#: lxc/action.go:125 +#: lxc/action.go:141 msgid "Store the instance state" msgstr "" -#: lxc/cluster.go:1141 +#: lxc/cluster.go:1271 #, c-format msgid "Successfully updated cluster certificates for remote %s" msgstr "" -#: lxc/info.go:205 +#: lxc/info.go:213 #, c-format msgid "Supported modes: %s" msgstr "" -#: lxc/info.go:209 +#: lxc/info.go:217 #, c-format msgid "Supported ports: %s" msgstr "" -#: lxc/info.go:538 +#: lxc/info.go:546 msgid "Swap (current)" msgstr "" -#: lxc/info.go:542 +#: lxc/info.go:550 msgid "Swap (peak)" msgstr "" -#: lxc/project.go:766 lxc/project.go:767 +#: lxc/project.go:849 lxc/project.go:850 msgid "Switch the current project" msgstr "" -#: lxc/remote.go:953 lxc/remote.go:954 +#: lxc/remote.go:1018 lxc/remote.go:1019 msgid "Switch the default remote" msgstr "" -#: lxc/file.go:167 +#: lxc/file.go:175 msgid "Symlink target path can only be used for type \"symlink\"" msgstr "" @@ -5748,86 +5785,96 @@ msgstr "" msgid "TARGET" msgstr "" -#: lxc/cluster.go:966 lxc/config_trust.go:515 +#: lxc/auth.go:876 +#, c-format +msgid "TLS identity %q (%s) pending identity token:" +msgstr "" + +#: lxc/auth.go:902 +#, c-format +msgid "TLS identity %q created with fingerprint %q" +msgstr "" + +#: lxc/cluster.go:1070 lxc/config_trust.go:515 msgid "TOKEN" msgstr "" -#: lxc/auth.go:815 lxc/config_trust.go:408 lxc/image.go:1081 -#: lxc/image_alias.go:236 lxc/list.go:571 lxc/network.go:982 -#: lxc/network.go:1056 lxc/network_allocations.go:27 lxc/operation.go:172 -#: lxc/storage_volume.go:1583 lxc/warning.go:216 +#: lxc/auth.go:967 lxc/config_trust.go:408 lxc/image.go:1147 +#: lxc/image_alias.go:236 lxc/list.go:579 lxc/network.go:1082 +#: lxc/network.go:1164 lxc/network_allocations.go:27 lxc/operation.go:172 +#: lxc/storage_volume.go:1736 lxc/warning.go:216 msgid "TYPE" msgstr "" -#: lxc/info.go:631 lxc/info.go:682 lxc/storage_volume.go:1418 +#: lxc/info.go:639 lxc/info.go:690 lxc/storage_volume.go:1563 msgid "Taken at" msgstr "" -#: lxc/file.go:1222 +#: lxc/file.go:1262 msgid "Target path and --listen flag cannot be used together" msgstr "" -#: lxc/file.go:1216 +#: lxc/file.go:1256 msgid "Target path must be a directory" msgstr "" -#: lxc/remote.go:161 lxc/remote.go:331 +#: lxc/remote.go:161 lxc/remote.go:349 msgid "" "The --accept-certificate flag is not supported when adding a remote using a " "trust token" msgstr "" -#: lxc/move.go:169 +#: lxc/move.go:181 msgid "The --instance-only flag can't be used with --target" msgstr "" -#: lxc/move.go:207 lxc/move.go:228 +#: lxc/move.go:219 lxc/move.go:240 msgid "The --mode flag can't be used with --storage or --target-project" msgstr "" -#: lxc/move.go:181 +#: lxc/move.go:193 msgid "The --mode flag can't be used with --target" msgstr "" -#: lxc/console.go:126 +#: lxc/console.go:133 msgid "The --show-log flag is only supported for by 'console' output type" msgstr "" -#: lxc/move.go:173 +#: lxc/move.go:185 msgid "The --storage flag can't be used with --target" msgstr "" -#: lxc/move.go:177 +#: lxc/move.go:189 msgid "The --target-project flag can't be used with --target" msgstr "" -#: lxc/move.go:193 +#: lxc/move.go:205 msgid "The destination LXD server is not clustered" msgstr "" -#: lxc/config_device.go:152 lxc/config_device.go:169 lxc/config_device.go:393 +#: lxc/config_device.go:172 lxc/config_device.go:189 lxc/config_device.go:453 msgid "The device already exists" msgstr "" -#: lxc/network_acl.go:882 lxc/network_acl.go:1004 +#: lxc/network_acl.go:987 lxc/network_acl.go:1125 msgid "The direction argument must be one of: ingress, egress" msgstr "" -#: lxc/delete.go:124 +#: lxc/delete.go:128 msgid "The instance is currently running, stop it first or pass --force" msgstr "" -#: lxc/publish.go:112 +#: lxc/publish.go:124 msgid "" "The instance is currently running. Use --force to have it stopped and " "restarted" msgstr "" -#: lxc/init.go:426 +#: lxc/init.go:435 msgid "The instance you are starting doesn't have any network attached to it." msgstr "" -#: lxc/cluster.go:349 +#: lxc/cluster.go:385 #, c-format msgid "The key %q does not exist on cluster member %q" msgstr "" @@ -5842,66 +5889,66 @@ msgstr "" msgid "The local image '%q' couldn't be found, trying '%q:' instead." msgstr "" -#: lxc/config_device.go:398 +#: lxc/config_device.go:458 msgid "The profile device doesn't exist" msgstr "" -#: lxc/cluster.go:340 +#: lxc/cluster.go:376 #, c-format msgid "The property %q does not exist on the cluster member %q: %v" msgstr "" -#: lxc/config.go:459 +#: lxc/config.go:483 #, c-format msgid "The property %q does not exist on the instance %q: %v" msgstr "" -#: lxc/config.go:435 +#: lxc/config.go:459 #, c-format msgid "The property %q does not exist on the instance snapshot %s/%s: %v" msgstr "" -#: lxc/network_load_balancer.go:429 +#: lxc/network_load_balancer.go:449 #, c-format msgid "The property %q does not exist on the load balancer %q: %v" msgstr "" -#: lxc/network.go:765 +#: lxc/network.go:849 #, c-format msgid "The property %q does not exist on the network %q: %v" msgstr "" -#: lxc/network_acl.go:303 +#: lxc/network_acl.go:339 #, c-format msgid "The property %q does not exist on the network ACL %q: %v" msgstr "" -#: lxc/network_forward.go:426 +#: lxc/network_forward.go:462 #, c-format msgid "The property %q does not exist on the network forward %q: %v" msgstr "" -#: lxc/network_peer.go:373 +#: lxc/network_peer.go:418 #, c-format msgid "The property %q does not exist on the network peer %q: %v" msgstr "" -#: lxc/network_zone.go:247 +#: lxc/network_zone.go:276 #, c-format msgid "The property %q does not exist on the network zone %q: %v" msgstr "" -#: lxc/network_zone.go:850 +#: lxc/network_zone.go:960 #, c-format msgid "The property %q does not exist on the network zone record %q: %v" msgstr "" -#: lxc/profile.go:611 +#: lxc/profile.go:680 #, c-format msgid "The property %q does not exist on the profile %q: %v" msgstr "" -#: lxc/project.go:410 +#: lxc/project.go:448 #, c-format msgid "The property %q does not exist on the project %q: %v" msgstr "" @@ -5911,41 +5958,41 @@ msgstr "" msgid "The property %q does not exist on the storage bucket %q: %v" msgstr "" -#: lxc/storage.go:413 +#: lxc/storage.go:449 #, c-format msgid "The property %q does not exist on the storage pool %q: %v" msgstr "" -#: lxc/storage_volume.go:1196 +#: lxc/storage_volume.go:1329 #, c-format msgid "The property %q does not exist on the storage pool volume %q: %v" msgstr "" -#: lxc/storage_volume.go:1173 +#: lxc/storage_volume.go:1306 #, c-format msgid "" "The property %q does not exist on the storage pool volume snapshot %s/%s: %v" msgstr "" -#: lxc/remote.go:524 +#: lxc/remote.go:542 msgid "" "The provided fingerprint does not match the server certificate fingerprint" msgstr "" -#: lxc/info.go:346 +#: lxc/info.go:354 msgid "The server doesn't implement the newer v2 resources API" msgstr "" -#: lxc/move.go:297 +#: lxc/move.go:309 msgid "The source LXD server is not clustered" msgstr "" -#: lxc/network.go:481 lxc/network.go:566 lxc/storage_volume.go:792 -#: lxc/storage_volume.go:873 +#: lxc/network.go:533 lxc/network.go:630 lxc/storage_volume.go:881 +#: lxc/storage_volume.go:978 msgid "The specified device doesn't exist" msgstr "" -#: lxc/network.go:485 lxc/network.go:570 +#: lxc/network.go:537 lxc/network.go:634 msgid "The specified device doesn't match the network" msgstr "" @@ -5953,23 +6000,23 @@ msgstr "" msgid "The type to create (file, symlink, or directory)" msgstr "" -#: lxc/publish.go:85 +#: lxc/publish.go:97 msgid "There is no \"image name\". Did you want an alias?" msgstr "" -#: lxc/config.go:637 +#: lxc/config.go:677 msgid "There is no config key to set on an instance snapshot." msgstr "" -#: lxc/cluster.go:659 +#: lxc/cluster.go:739 msgid "This LXD server is already clustered" msgstr "" -#: lxc/cluster.go:649 +#: lxc/cluster.go:729 msgid "This LXD server is not available on the network" msgstr "" -#: lxc/main.go:294 +#: lxc/main.go:298 msgid "" "This client hasn't been configured to use a remote LXD server yet.\n" "As your platform can't run native Linux instances, you must connect to a " @@ -5981,57 +6028,57 @@ msgid "" "https://multipass.run" msgstr "" -#: lxc/info.go:319 +#: lxc/info.go:327 msgid "Threads:" msgstr "" -#: lxc/action.go:137 +#: lxc/action.go:153 msgid "Time to wait for the instance to shutdown cleanly" msgstr "" -#: lxc/image.go:967 +#: lxc/image.go:1019 msgid "Timestamps:" msgstr "" -#: lxc/init.go:428 +#: lxc/init.go:437 msgid "To attach a network to an instance, use: lxc network attach" msgstr "" -#: lxc/init.go:427 +#: lxc/init.go:436 msgid "To create a new network, use: lxc network create" msgstr "" -#: lxc/console.go:215 +#: lxc/console.go:222 msgid "To detach from the console, press: +a q" msgstr "" -#: lxc/main.go:406 +#: lxc/main.go:410 msgid "" "To start your first container, try: lxc launch ubuntu:24.04\n" "Or for a virtual machine: lxc launch ubuntu:24.04 --vm" msgstr "" -#: lxc/config.go:298 lxc/config.go:479 lxc/config.go:686 lxc/config.go:778 -#: lxc/copy.go:132 lxc/info.go:338 lxc/network.go:822 lxc/storage.go:471 +#: lxc/config.go:306 lxc/config.go:503 lxc/config.go:726 lxc/config.go:826 +#: lxc/copy.go:144 lxc/info.go:346 lxc/network.go:914 lxc/storage.go:515 msgid "To use --target, the destination remote must be a cluster" msgstr "" -#: lxc/storage_volume.go:1331 +#: lxc/storage_volume.go:1476 #, c-format msgid "Total: %s" msgstr "" -#: lxc/info.go:372 lxc/info.go:383 lxc/info.go:388 lxc/info.go:394 +#: lxc/info.go:380 lxc/info.go:391 lxc/info.go:396 lxc/info.go:402 #, c-format msgid "Total: %v" msgstr "" -#: lxc/info.go:217 +#: lxc/info.go:225 #, c-format msgid "Transceiver type: %s" msgstr "" -#: lxc/storage_volume.go:1700 +#: lxc/storage_volume.go:1853 msgid "Transfer mode, one of pull (default), push or relay" msgstr "" @@ -6039,7 +6086,7 @@ msgstr "" msgid "Transfer mode. One of pull (default), push or relay" msgstr "" -#: lxc/storage_volume.go:358 +#: lxc/storage_volume.go:393 msgid "Transfer mode. One of pull (default), push or relay." msgstr "" @@ -6051,34 +6098,39 @@ msgstr "" msgid "Transfer mode. One of pull, push or relay." msgstr "" -#: lxc/image.go:783 +#: lxc/image.go:827 #, c-format msgid "Transferring image: %s" msgstr "" -#: lxc/copy.go:370 lxc/move.go:315 +#: lxc/copy.go:382 lxc/move.go:327 #, c-format msgid "Transferring instance: %s" msgstr "" -#: lxc/network.go:862 +#: lxc/network.go:954 msgid "Transmit policy" msgstr "" -#: lxc/remote.go:326 +#: lxc/remote.go:344 msgid "Trust token cannot be used for public remotes" msgstr "" -#: lxc/remote.go:321 +#: lxc/remote.go:339 msgid "Trust token cannot be used with OIDC authentication" msgstr "" -#: lxc/action.go:288 lxc/launch.go:119 +#: lxc/remote.go:654 +#, c-format +msgid "Trust token for %s: " +msgstr "" + +#: lxc/action.go:304 lxc/launch.go:124 #, c-format msgid "Try `lxc info --show-log %s` for more info" msgstr "" -#: lxc/info.go:555 +#: lxc/info.go:563 msgid "Type" msgstr "" @@ -6086,42 +6138,42 @@ msgstr "" msgid "Type of certificate" msgstr "" -#: lxc/console.go:45 +#: lxc/console.go:48 msgid "" "Type of connection to establish: 'console' for serial console, 'vga' for " "SPICE graphical output" msgstr "" -#: lxc/image.go:965 lxc/info.go:273 lxc/info.go:475 lxc/network.go:838 -#: lxc/storage_volume.go:1316 +#: lxc/image.go:1017 lxc/info.go:281 lxc/info.go:483 lxc/network.go:930 +#: lxc/storage_volume.go:1461 #, c-format msgid "Type: %s" msgstr "" -#: lxc/info.go:473 +#: lxc/info.go:481 #, c-format msgid "Type: %s (ephemeral)" msgstr "" -#: lxc/project.go:864 +#: lxc/project.go:965 msgid "UNLIMITED" msgstr "" -#: lxc/image.go:1080 +#: lxc/image.go:1148 msgid "UPLOAD DATE" msgstr "" -#: lxc/cluster.go:185 lxc/remote.go:802 +#: lxc/cluster.go:193 lxc/remote.go:851 msgid "URL" msgstr "" -#: lxc/project.go:888 lxc/storage_volume.go:1588 +#: lxc/project.go:995 lxc/storage_volume.go:1741 msgid "USAGE" msgstr "" -#: lxc/network.go:987 lxc/network_acl.go:150 lxc/network_allocations.go:24 -#: lxc/network_zone.go:141 lxc/profile.go:680 lxc/project.go:530 -#: lxc/storage.go:672 lxc/storage_volume.go:1587 +#: lxc/network.go:1087 lxc/network_acl.go:158 lxc/network_allocations.go:24 +#: lxc/network_zone.go:149 lxc/profile.go:757 lxc/project.go:575 +#: lxc/storage.go:724 lxc/storage_volume.go:1740 msgid "USED BY" msgstr "" @@ -6129,17 +6181,17 @@ msgstr "" msgid "UUID" msgstr "" -#: lxc/info.go:132 +#: lxc/info.go:140 #, c-format msgid "UUID: %v" msgstr "" -#: lxc/file.go:387 +#: lxc/file.go:411 #, c-format msgid "Unable to create a temporary file: %v" msgstr "" -#: lxc/remote.go:226 lxc/remote.go:260 +#: lxc/remote.go:226 lxc/remote.go:261 msgid "Unavailable remote server" msgstr "" @@ -6148,42 +6200,42 @@ msgstr "" msgid "Unknown certificate type %q" msgstr "" -#: lxc/file.go:1443 +#: lxc/file.go:1483 #, c-format msgid "Unknown channel type for client %q: %s" msgstr "" -#: lxc/image.go:1095 lxc/list.go:623 lxc/storage_volume.go:1625 +#: lxc/image.go:1167 lxc/list.go:631 lxc/storage_volume.go:1778 #: lxc/warning.go:242 #, c-format msgid "Unknown column shorthand char '%c' in '%s'" msgstr "" -#: lxc/console.go:164 +#: lxc/console.go:171 #, c-format msgid "Unknown console type %q" msgstr "" -#: lxc/file.go:997 +#: lxc/file.go:1029 #, c-format msgid "Unknown file type '%s'" msgstr "" -#: lxc/network_acl.go:819 lxc/network_acl.go:938 +#: lxc/network_acl.go:924 lxc/network_acl.go:1059 #, c-format msgid "Unknown key: %s" msgstr "" -#: lxc/console.go:109 +#: lxc/console.go:116 #, c-format msgid "Unknown output type %q" msgstr "" -#: lxc/config.go:1079 lxc/config.go:1080 +#: lxc/config.go:1144 lxc/config.go:1145 msgid "Unset UEFI variables for instance" msgstr "" -#: lxc/cluster.go:439 +#: lxc/cluster.go:483 msgid "Unset a cluster member's configuration keys" msgstr "" @@ -6191,63 +6243,63 @@ msgstr "" msgid "Unset all profiles on the target instance" msgstr "" -#: lxc/config_device.go:741 lxc/config_device.go:742 +#: lxc/config_device.go:851 lxc/config_device.go:852 msgid "Unset device configuration keys" msgstr "" -#: lxc/image.go:1607 lxc/image.go:1608 +#: lxc/image.go:1733 lxc/image.go:1734 msgid "Unset image properties" msgstr "" -#: lxc/config.go:858 lxc/config.go:859 +#: lxc/config.go:906 lxc/config.go:907 msgid "Unset instance or server configuration keys" msgstr "" -#: lxc/network_acl.go:496 lxc/network_acl.go:497 +#: lxc/network_acl.go:548 lxc/network_acl.go:549 msgid "Unset network ACL configuration keys" msgstr "" -#: lxc/network.go:1280 lxc/network.go:1281 +#: lxc/network.go:1412 lxc/network.go:1413 msgid "Unset network configuration keys" msgstr "" -#: lxc/network_forward.go:550 +#: lxc/network_forward.go:598 msgid "Unset network forward configuration keys" msgstr "" -#: lxc/network_forward.go:551 +#: lxc/network_forward.go:599 msgid "Unset network forward keys" msgstr "" -#: lxc/network_load_balancer.go:553 +#: lxc/network_load_balancer.go:585 msgid "Unset network load balancer configuration keys" msgstr "" -#: lxc/network_load_balancer.go:554 +#: lxc/network_load_balancer.go:586 msgid "Unset network load balancer keys" msgstr "" -#: lxc/network_peer.go:488 +#: lxc/network_peer.go:546 msgid "Unset network peer configuration keys" msgstr "" -#: lxc/network_peer.go:489 +#: lxc/network_peer.go:547 msgid "Unset network peer keys" msgstr "" -#: lxc/network_zone.go:438 lxc/network_zone.go:439 +#: lxc/network_zone.go:483 lxc/network_zone.go:484 msgid "Unset network zone configuration keys" msgstr "" -#: lxc/network_zone.go:1036 lxc/network_zone.go:1037 +#: lxc/network_zone.go:1171 lxc/network_zone.go:1172 msgid "Unset network zone record configuration keys" msgstr "" -#: lxc/profile.go:954 lxc/profile.go:955 +#: lxc/profile.go:1072 lxc/profile.go:1073 msgid "Unset profile configuration keys" msgstr "" -#: lxc/project.go:682 lxc/project.go:683 +#: lxc/project.go:744 lxc/project.go:745 msgid "Unset project configuration keys" msgstr "" @@ -6255,51 +6307,51 @@ msgstr "" msgid "Unset storage bucket configuration keys" msgstr "" -#: lxc/storage.go:871 lxc/storage.go:872 +#: lxc/storage.go:939 lxc/storage.go:940 msgid "Unset storage pool configuration keys" msgstr "" -#: lxc/storage_volume.go:2145 lxc/storage_volume.go:2146 +#: lxc/storage_volume.go:2354 lxc/storage_volume.go:2355 msgid "Unset storage volume configuration keys" msgstr "" -#: lxc/cluster.go:442 +#: lxc/cluster.go:486 msgid "Unset the key as a cluster property" msgstr "" -#: lxc/network_acl.go:500 +#: lxc/network_acl.go:552 msgid "Unset the key as a network ACL property" msgstr "" -#: lxc/network_forward.go:554 +#: lxc/network_forward.go:602 msgid "Unset the key as a network forward property" msgstr "" -#: lxc/network_load_balancer.go:557 +#: lxc/network_load_balancer.go:589 msgid "Unset the key as a network load balancer property" msgstr "" -#: lxc/network_peer.go:492 +#: lxc/network_peer.go:550 msgid "Unset the key as a network peer property" msgstr "" -#: lxc/network.go:1285 +#: lxc/network.go:1417 msgid "Unset the key as a network property" msgstr "" -#: lxc/network_zone.go:442 +#: lxc/network_zone.go:487 msgid "Unset the key as a network zone property" msgstr "" -#: lxc/network_zone.go:1040 +#: lxc/network_zone.go:1175 msgid "Unset the key as a network zone record property" msgstr "" -#: lxc/profile.go:959 +#: lxc/profile.go:1077 msgid "Unset the key as a profile property" msgstr "" -#: lxc/project.go:687 +#: lxc/project.go:749 msgid "Unset the key as a project property" msgstr "" @@ -6307,69 +6359,69 @@ msgstr "" msgid "Unset the key as a storage bucket property" msgstr "" -#: lxc/storage.go:876 +#: lxc/storage.go:944 msgid "Unset the key as a storage property" msgstr "" -#: lxc/storage_volume.go:2159 +#: lxc/storage_volume.go:2368 msgid "Unset the key as a storage volume property" msgstr "" -#: lxc/config.go:863 +#: lxc/config.go:911 msgid "Unset the key as an instance property" msgstr "" -#: lxc/storage_volume.go:228 +#: lxc/storage_volume.go:247 msgid "Unsupported content type for attaching to instances" msgstr "" -#: lxc/info.go:704 +#: lxc/info.go:712 #, c-format msgid "Unsupported instance type: %s" msgstr "" -#: lxc/network.go:863 +#: lxc/network.go:955 msgid "Up delay" msgstr "" -#: lxc/cluster.go:1061 +#: lxc/cluster.go:1174 msgid "Update cluster certificate" msgstr "" -#: lxc/cluster.go:1063 +#: lxc/cluster.go:1176 msgid "" "Update cluster certificate with PEM certificate and key read from input " "files." msgstr "" -#: lxc/profile.go:254 +#: lxc/profile.go:274 msgid "Update the target profile from the source if it already exists" msgstr "" -#: lxc/image.go:974 +#: lxc/image.go:1026 #, c-format msgid "Uploaded: %s" msgstr "" -#: lxc/network.go:879 +#: lxc/network.go:971 msgid "Upper devices" msgstr "" -#: lxc/storage_volume.go:1329 +#: lxc/storage_volume.go:1474 #, c-format msgid "Usage: %s" msgstr "" -#: lxc/export.go:42 lxc/storage_volume.go:2387 +#: lxc/export.go:42 lxc/storage_volume.go:2641 msgid "" "Use storage driver optimized format (can only be restored on a similar pool)" msgstr "" -#: lxc/main.go:101 +#: lxc/main.go:100 msgid "Use with help or --help to view sub-commands" msgstr "" -#: lxc/info.go:371 lxc/info.go:382 lxc/info.go:387 lxc/info.go:393 +#: lxc/info.go:379 lxc/info.go:390 lxc/info.go:395 lxc/info.go:401 #, c-format msgid "Used: %v" msgstr "" @@ -6378,7 +6430,7 @@ msgstr "" msgid "User ID to run the command as (default 0)" msgstr "" -#: lxc/cluster.go:553 lxc/delete.go:49 +#: lxc/cluster.go:625 lxc/delete.go:53 msgid "User aborted delete operation" msgstr "" @@ -6387,51 +6439,51 @@ msgid "" "User signaled us three times, exiting. The remote operation will keep running" msgstr "" -#: lxc/info.go:140 lxc/info.go:249 +#: lxc/info.go:148 lxc/info.go:257 #, c-format msgid "VFs: %d" msgstr "" -#: lxc/network.go:887 +#: lxc/network.go:979 msgid "VLAN ID" msgstr "" -#: lxc/network.go:878 +#: lxc/network.go:970 msgid "VLAN filtering" msgstr "" -#: lxc/network.go:885 +#: lxc/network.go:977 msgid "VLAN:" msgstr "" -#: lxc/info.go:301 +#: lxc/info.go:309 #, c-format msgid "Vendor: %v" msgstr "" -#: lxc/info.go:93 lxc/info.go:179 +#: lxc/info.go:101 lxc/info.go:187 #, c-format msgid "Vendor: %v (%v)" msgstr "" -#: lxc/info.go:238 +#: lxc/info.go:246 #, c-format msgid "Verb: %s (%s)" msgstr "" -#: lxc/auth.go:832 +#: lxc/auth.go:984 msgid "View an identity" msgstr "" -#: lxc/auth.go:895 +#: lxc/auth.go:1047 msgid "View the current identity" msgstr "" -#: lxc/storage_volume.go:1420 +#: lxc/storage_volume.go:1565 msgid "Volume Only" msgstr "" -#: lxc/info.go:279 +#: lxc/info.go:287 #, c-format msgid "WWN: %s" msgstr "" @@ -6460,25 +6512,25 @@ msgid "" "re-initialize the instance if a different image or --empty is not specified." msgstr "" -#: lxc/network.go:960 lxc/operation.go:157 lxc/project.go:482 -#: lxc/project.go:487 lxc/project.go:492 lxc/project.go:497 lxc/project.go:502 -#: lxc/project.go:507 lxc/remote.go:763 lxc/remote.go:768 lxc/remote.go:773 +#: lxc/network.go:1060 lxc/operation.go:157 lxc/project.go:527 +#: lxc/project.go:532 lxc/project.go:537 lxc/project.go:542 lxc/project.go:547 +#: lxc/project.go:552 lxc/remote.go:812 lxc/remote.go:817 lxc/remote.go:822 msgid "YES" msgstr "" -#: lxc/exec.go:93 +#: lxc/exec.go:101 msgid "You can't pass -t and -T at the same time" msgstr "" -#: lxc/exec.go:97 +#: lxc/exec.go:105 msgid "You can't pass -t or -T at the same time as --mode" msgstr "" -#: lxc/copy.go:103 +#: lxc/copy.go:115 msgid "You must specify a destination instance name" msgstr "" -#: lxc/copy.go:86 lxc/move.go:281 lxc/move.go:357 +#: lxc/copy.go:98 lxc/move.go:293 lxc/move.go:369 msgid "You must specify a source instance name" msgstr "" @@ -6486,20 +6538,20 @@ msgstr "" msgid "You need to specify an image name or use --empty" msgstr "" -#: lxc/storage_volume.go:814 +#: lxc/storage_volume.go:903 msgid "[] []" msgstr "" -#: lxc/storage_volume.go:262 +#: lxc/storage_volume.go:281 msgid "[] [] []" msgstr "" -#: lxc/auth.go:330 lxc/auth.go:763 lxc/auth.go:894 lxc/auth.go:1690 -#: lxc/cluster.go:120 lxc/cluster.go:879 lxc/cluster_group.go:401 +#: lxc/auth.go:335 lxc/auth.go:915 lxc/auth.go:1046 lxc/auth.go:1897 +#: lxc/cluster.go:120 lxc/cluster.go:975 lxc/cluster_group.go:437 #: lxc/config_trust.go:347 lxc/config_trust.go:430 lxc/monitor.go:32 -#: lxc/network.go:910 lxc/network_acl.go:92 lxc/network_zone.go:83 -#: lxc/operation.go:104 lxc/profile.go:631 lxc/project.go:431 -#: lxc/storage.go:608 lxc/version.go:20 lxc/warning.go:69 +#: lxc/network.go:1002 lxc/network_acl.go:92 lxc/network_zone.go:83 +#: lxc/operation.go:104 lxc/profile.go:700 lxc/project.go:469 +#: lxc/storage.go:652 lxc/version.go:20 lxc/warning.go:69 msgid "[:]" msgstr "" @@ -6507,11 +6559,11 @@ msgstr "" msgid "[:] []" msgstr "" -#: lxc/cluster.go:1059 +#: lxc/cluster.go:1172 msgid "[:] " msgstr "" -#: lxc/cluster.go:603 lxc/config_trust.go:578 +#: lxc/cluster.go:675 lxc/config_trust.go:578 msgid "[:] " msgstr "" @@ -6519,7 +6571,7 @@ msgstr "" msgid "[:] []" msgstr "" -#: lxc/image.go:1035 lxc/list.go:46 +#: lxc/image.go:1088 lxc/list.go:46 msgid "[:] [...]" msgstr "" @@ -6527,32 +6579,32 @@ msgstr "" msgid "[:] [...]" msgstr "" -#: lxc/auth.go:1255 +#: lxc/auth.go:1462 msgid "[:] [project=] [entity_type=]" msgstr "" -#: lxc/network_acl.go:164 lxc/network_acl.go:217 lxc/network_acl.go:525 -#: lxc/network_acl.go:704 +#: lxc/network_acl.go:172 lxc/network_acl.go:233 lxc/network_acl.go:590 +#: lxc/network_acl.go:785 msgid "[:]" msgstr "" -#: lxc/network_acl.go:769 lxc/network_acl.go:890 +#: lxc/network_acl.go:858 lxc/network_acl.go:995 msgid "[:] =..." msgstr "" -#: lxc/network_acl.go:265 lxc/network_acl.go:495 +#: lxc/network_acl.go:289 lxc/network_acl.go:547 msgid "[:] " msgstr "" -#: lxc/network_acl.go:415 +#: lxc/network_acl.go:459 msgid "[:] =..." msgstr "" -#: lxc/network_acl.go:655 +#: lxc/network_acl.go:728 msgid "[:] " msgstr "" -#: lxc/network_acl.go:326 +#: lxc/network_acl.go:362 msgid "[:] [key=value...]" msgstr "" @@ -6560,19 +6612,19 @@ msgstr "" msgid "[:]" msgstr "" -#: lxc/network_zone.go:155 lxc/network_zone.go:468 lxc/network_zone.go:586 +#: lxc/network_zone.go:163 lxc/network_zone.go:525 lxc/network_zone.go:651 msgid "[:]" msgstr "" -#: lxc/network_zone.go:210 lxc/network_zone.go:437 +#: lxc/network_zone.go:226 lxc/network_zone.go:482 msgid "[:] " msgstr "" -#: lxc/network_zone.go:357 +#: lxc/network_zone.go:394 msgid "[:] =..." msgstr "" -#: lxc/network_zone.go:270 +#: lxc/network_zone.go:299 msgid "[:] [key=value...]" msgstr "" @@ -6588,63 +6640,65 @@ msgstr "" msgid "[:] " msgstr "" -#: lxc/auth.go:831 -msgid "[:]/" +#: lxc/auth.go:774 +msgid "" +"[:]/ [] [[--group ]]" msgstr "" -#: lxc/auth.go:1106 lxc/auth.go:1164 lxc/auth.go:1927 -msgid "[:]/ " +#: lxc/auth.go:983 lxc/auth.go:1235 +msgid "[:]/" msgstr "" -#: lxc/cluster.go:688 -msgid "[:]" +#: lxc/auth.go:1313 lxc/auth.go:1371 lxc/auth.go:2134 +msgid "[:]/ " msgstr "" #: lxc/config_trust.go:234 lxc/config_trust.go:531 lxc/config_trust.go:649 msgid "[:]" msgstr "" -#: lxc/auth.go:97 lxc/auth.go:150 lxc/auth.go:200 lxc/auth.go:440 -#: lxc/auth.go:955 lxc/auth.go:1471 lxc/cluster_group.go:156 -#: lxc/cluster_group.go:233 lxc/cluster_group.go:286 lxc/cluster_group.go:597 +#: lxc/auth.go:102 lxc/auth.go:155 lxc/auth.go:205 lxc/auth.go:445 +#: lxc/auth.go:1107 lxc/auth.go:1678 lxc/cluster_group.go:168 +#: lxc/cluster_group.go:253 lxc/cluster_group.go:314 lxc/cluster_group.go:661 msgid "[:]" msgstr "" -#: lxc/auth.go:515 lxc/auth.go:573 +#: lxc/auth.go:520 lxc/auth.go:578 msgid "" "[:] [] " "[=...]" msgstr "" -#: lxc/cluster_group.go:548 +#: lxc/cluster_group.go:604 msgid "[:] " msgstr "" -#: lxc/auth.go:390 +#: lxc/auth.go:395 msgid "[:] " msgstr "" -#: lxc/auth.go:1522 lxc/auth.go:1572 lxc/auth.go:1800 +#: lxc/auth.go:1729 lxc/auth.go:1779 lxc/auth.go:2007 msgid "[:]" msgstr "" -#: lxc/auth.go:1874 +#: lxc/auth.go:2081 msgid "[:] " msgstr "" -#: lxc/auth.go:1750 +#: lxc/auth.go:1957 msgid "[:] " msgstr "" -#: lxc/image.go:378 lxc/image.go:902 lxc/image.go:1442 +#: lxc/image.go:394 lxc/image.go:946 lxc/image.go:1539 msgid "[:]" msgstr "" -#: lxc/image.go:1500 lxc/image.go:1606 +#: lxc/image.go:1605 lxc/image.go:1732 msgid "[:] " msgstr "" -#: lxc/image.go:1551 +#: lxc/image.go:1669 msgid "[:] " msgstr "" @@ -6660,25 +6714,25 @@ msgstr "" msgid "[:] [:][]" msgstr "" -#: lxc/image.go:499 +#: lxc/image.go:523 msgid "[:] []" msgstr "" -#: lxc/image.go:322 lxc/image.go:1355 +#: lxc/image.go:334 lxc/image.go:1448 msgid "[:] [[:]...]" msgstr "" -#: lxc/config.go:1109 lxc/config.go:1163 lxc/config_device.go:289 -#: lxc/config_device.go:663 lxc/config_metadata.go:54 -#: lxc/config_metadata.go:179 lxc/config_template.go:239 lxc/console.go:35 +#: lxc/config.go:1174 lxc/config.go:1228 lxc/config_device.go:329 +#: lxc/config_device.go:761 lxc/config_metadata.go:54 +#: lxc/config_metadata.go:187 lxc/config_template.go:271 lxc/console.go:38 msgid "[:]" msgstr "" -#: lxc/config_device.go:203 lxc/config_device.go:736 +#: lxc/config_device.go:223 lxc/config_device.go:846 msgid "[:] " msgstr "" -#: lxc/config_device.go:548 +#: lxc/config_device.go:626 msgid "[:] =..." msgstr "" @@ -6686,27 +6740,27 @@ msgstr "" msgid "[:] [key=value...]" msgstr "" -#: lxc/config_device.go:355 +#: lxc/config_device.go:407 msgid "[:] [key=value...]" msgstr "" -#: lxc/config.go:932 lxc/config.go:1078 +#: lxc/config.go:997 lxc/config.go:1143 msgid "[:] " msgstr "" -#: lxc/config.go:987 +#: lxc/config.go:1052 msgid "[:] =..." msgstr "" -#: lxc/config_device.go:444 +#: lxc/config_device.go:504 msgid "[:] ..." msgstr "" -#: lxc/profile.go:103 lxc/profile.go:693 +#: lxc/profile.go:103 lxc/profile.go:770 msgid "[:] " msgstr "" -#: lxc/profile.go:165 +#: lxc/profile.go:177 msgid "[:] " msgstr "" @@ -6714,8 +6768,8 @@ msgstr "" msgid "[:] " msgstr "" -#: lxc/config_template.go:66 lxc/config_template.go:108 -#: lxc/config_template.go:151 lxc/config_template.go:299 +#: lxc/config_template.go:66 lxc/config_template.go:116 +#: lxc/config_template.go:171 lxc/config_template.go:339 msgid "[:]