Merge ~joey-mucci/ubuntu/+source/landscape-client:upstream-release-26.02 into ubuntu/+source/landscape-client:ubuntu/devel

Proposed by Joseph Mucci
Status: Needs review
Proposed branch: ~joey-mucci/ubuntu/+source/landscape-client:upstream-release-26.02
Merge into: ubuntu/+source/landscape-client:ubuntu/devel
Diff against target: 19538 lines (+7182/-2975)
274 files modified
.codecov.yml (+1/-1)
.dev-lxc/config.yaml (+1/-0)
.github/workflows/build-jammy.yaml (+79/-0)
.github/workflows/ci.yml (+8/-4)
.github/workflows/codecov.yml (+3/-1)
.github/workflows/integration-test.yml (+32/-0)
.github/workflows/static-analysis.yml (+1/-1)
.github/workflows/update-release-branches.yml (+44/-0)
.gitignore (+3/-0)
Makefile (+31/-29)
Makefile.packaging (+15/-34)
README (+75/-45)
SECURITY.md (+5/-0)
debian/changelog (+72/-0)
debian/patches/fix-landscape-client-manpage.patch (+7/-7)
debian/patches/series (+0/-5)
dev/null (+0/-26)
example.conf (+11/-0)
integration_tests/__init__.py (+0/-0)
integration_tests/test_uaclient_integration.py (+47/-0)
landscape/__init__.py (+2/-1)
landscape/client/amp.py (+6/-3)
landscape/client/attachments.py (+65/-0)
landscape/client/broker/amp.py (+5/-12)
landscape/client/broker/client.py (+17/-14)
landscape/client/broker/config.py (+6/-2)
landscape/client/broker/exchange.py (+40/-27)
landscape/client/broker/exchangestore.py (+1/-0)
landscape/client/broker/ping.py (+5/-7)
landscape/client/broker/registration.py (+8/-5)
landscape/client/broker/server.py (+10/-8)
landscape/client/broker/service.py (+3/-4)
landscape/client/broker/store.py (+6/-12)
landscape/client/broker/tests/helpers.py (+2/-2)
landscape/client/broker/tests/test_amp.py (+2/-6)
landscape/client/broker/tests/test_client.py (+12/-13)
landscape/client/broker/tests/test_config.py (+29/-1)
landscape/client/broker/tests/test_exchange.py (+119/-11)
landscape/client/broker/tests/test_exchangestore.py (+1/-2)
landscape/client/broker/tests/test_ping.py (+1/-3)
landscape/client/broker/tests/test_registration.py (+48/-16)
landscape/client/broker/tests/test_server.py (+14/-10)
landscape/client/broker/tests/test_service.py (+0/-1)
landscape/client/broker/tests/test_store.py (+7/-8)
landscape/client/broker/tests/test_transport.py (+1/-4)
landscape/client/broker/transport.py (+7/-11)
landscape/client/configuration.py (+131/-55)
landscape/client/deployment.py (+16/-18)
landscape/client/exchange.py (+11/-14)
landscape/client/manager/aptsources.py (+35/-12)
landscape/client/manager/config.py (+2/-1)
landscape/client/manager/customgraph.py (+11/-15)
landscape/client/manager/fakepackagemanager.py (+0/-1)
landscape/client/manager/fderecoverykeymanager.py (+170/-0)
landscape/client/manager/keystonetoken.py (+12/-23)
landscape/client/manager/livepatch.py (+2/-3)
landscape/client/manager/packagemanager.py (+0/-1)
landscape/client/manager/plugin.py (+9/-10)
landscape/client/manager/promanagement.py (+83/-0)
landscape/client/manager/scriptexecution.py (+61/-72)
landscape/client/manager/service.py (+2/-4)
landscape/client/manager/shutdownmanager.py (+2/-5)
landscape/client/manager/snapmanager.py (+33/-9)
landscape/client/manager/store.py (+1/-2)
landscape/client/manager/tests/test_aptsources.py (+185/-70)
landscape/client/manager/tests/test_config.py (+3/-2)
landscape/client/manager/tests/test_customgraph.py (+22/-60)
landscape/client/manager/tests/test_fakepackagemanager.py (+1/-2)
landscape/client/manager/tests/test_fderecoverykeymanager.py (+339/-0)
landscape/client/manager/tests/test_hardwareinfo.py (+1/-2)
landscape/client/manager/tests/test_keystonetoken.py (+1/-4)
landscape/client/manager/tests/test_livepatch.py (+3/-4)
landscape/client/manager/tests/test_manager.py (+1/-3)
landscape/client/manager/tests/test_packagemanager.py (+4/-5)
landscape/client/manager/tests/test_plugin.py (+7/-8)
landscape/client/manager/tests/test_processkiller.py (+8/-8)
landscape/client/manager/tests/test_promanagement.py (+259/-0)
landscape/client/manager/tests/test_scriptexecution.py (+103/-149)
landscape/client/manager/tests/test_service.py (+9/-10)
landscape/client/manager/tests/test_shutdownmanager.py (+1/-3)
landscape/client/manager/tests/test_snapmanager.py (+79/-6)
landscape/client/manager/tests/test_snapservicesmanager.py (+3/-6)
landscape/client/manager/tests/test_ubuntuproinfo.py (+223/-53)
landscape/client/manager/tests/test_ubuntuprorebootrequired.py (+3/-3)
landscape/client/manager/tests/test_usermanager.py (+10/-15)
landscape/client/manager/tests/test_usgmanager.py (+370/-0)
landscape/client/manager/ubuntuproinfo.py (+61/-46)
landscape/client/manager/ubuntuprorebootrequired.py (+4/-2)
landscape/client/manager/usermanager.py (+3/-8)
landscape/client/manager/usgmanager.py (+259/-0)
landscape/client/monitor/activeprocessinfo.py (+2/-5)
landscape/client/monitor/aptpreferences.py (+1/-3)
landscape/client/monitor/cephusage.py (+1/-4)
landscape/client/monitor/cloudinit.py (+0/-1)
landscape/client/monitor/computerinfo.py (+8/-4)
landscape/client/monitor/config.py (+0/-1)
landscape/client/monitor/cpuusage.py (+1/-1)
landscape/client/monitor/monitor.py (+1/-0)
landscape/client/monitor/mountinfo.py (+3/-8)
landscape/client/monitor/networkactivity.py (+3/-3)
landscape/client/monitor/networkdevice.py (+1/-1)
landscape/client/monitor/packagemonitor.py (+1/-4)
landscape/client/monitor/rebootrequired.py (+1/-2)
landscape/client/monitor/service.py (+3/-4)
landscape/client/monitor/snapservicesmonitor.py (+0/-1)
landscape/client/monitor/swiftusage.py (+1/-1)
landscape/client/monitor/tests/test_activeprocessinfo.py (+2/-6)
landscape/client/monitor/tests/test_aptpreferences.py (+2/-6)
landscape/client/monitor/tests/test_cephusage.py (+2/-4)
landscape/client/monitor/tests/test_cloud_init.py (+1/-2)
landscape/client/monitor/tests/test_computerinfo.py (+53/-13)
landscape/client/monitor/tests/test_computertags.py (+1/-3)
landscape/client/monitor/tests/test_config.py (+1/-2)
landscape/client/monitor/tests/test_cpuusage.py (+2/-5)
landscape/client/monitor/tests/test_loadaverage.py (+1/-3)
landscape/client/monitor/tests/test_memoryinfo.py (+1/-3)
landscape/client/monitor/tests/test_monitor.py (+1/-3)
landscape/client/monitor/tests/test_mountinfo.py (+7/-13)
landscape/client/monitor/tests/test_networkactivity.py (+1/-3)
landscape/client/monitor/tests/test_networkdevice.py (+1/-3)
landscape/client/monitor/tests/test_packagemonitor.py (+12/-11)
landscape/client/monitor/tests/test_plugin.py (+4/-11)
landscape/client/monitor/tests/test_processorinfo.py (+2/-6)
landscape/client/monitor/tests/test_rebootrequired.py (+1/-3)
landscape/client/monitor/tests/test_service.py (+10/-12)
landscape/client/monitor/tests/test_snapservicesmonitor.py (+2/-5)
landscape/client/monitor/tests/test_swiftusage.py (+2/-4)
landscape/client/monitor/tests/test_temperature.py (+2/-3)
landscape/client/monitor/tests/test_updatemanager.py (+1/-2)
landscape/client/monitor/tests/test_usermonitor.py (+3/-9)
landscape/client/monitor/updatemanager.py (+3/-3)
landscape/client/monitor/usermonitor.py (+1/-5)
landscape/client/package/changer.py (+38/-19)
landscape/client/package/releaseupgrader.py (+10/-18)
landscape/client/package/reporter.py (+115/-43)
landscape/client/package/taskhandler.py (+14/-15)
landscape/client/package/tests/test_changer.py (+66/-63)
landscape/client/package/tests/test_releaseupgrader.py (+17/-32)
landscape/client/package/tests/test_reporter.py (+284/-77)
landscape/client/package/tests/test_taskhandler.py (+8/-16)
landscape/client/reactor.py (+1/-0)
landscape/client/registration.py (+22/-30)
landscape/client/service.py (+3/-6)
landscape/client/serviceconfig.py (+1/-0)
landscape/client/tests/clock.py (+3/-7)
landscape/client/tests/helpers.py (+14/-7)
landscape/client/tests/subunit.py (+2/-7)
landscape/client/tests/test_accumulate.py (+1/-2)
landscape/client/tests/test_amp.py (+14/-19)
landscape/client/tests/test_attachments.py (+128/-0)
landscape/client/tests/test_configuration.py (+170/-48)
landscape/client/tests/test_deployment.py (+11/-11)
landscape/client/tests/test_exchange.py (+8/-16)
landscape/client/tests/test_patch.py (+1/-2)
landscape/client/tests/test_registration.py (+7/-21)
landscape/client/tests/test_service.py (+7/-7)
landscape/client/tests/test_serviceconfig.py (+7/-6)
landscape/client/tests/test_snap_utils.py (+2/-5)
landscape/client/tests/test_watchdog.py (+32/-35)
landscape/client/upgraders/__init__.py (+1/-4)
landscape/client/upgraders/monitor.py (+0/-1)
landscape/client/user/changes.py (+4/-7)
landscape/client/user/management.py (+7/-15)
landscape/client/user/provider.py (+10/-19)
landscape/client/user/tests/helpers.py (+3/-6)
landscape/client/user/tests/test_changes.py (+2/-5)
landscape/client/user/tests/test_management.py (+10/-11)
landscape/client/user/tests/test_provider.py (+12/-26)
landscape/client/watchdog.py (+17/-32)
landscape/lib/amp.py (+13/-14)
landscape/lib/apt/package/facade.py (+78/-42)
landscape/lib/apt/package/skeleton.py (+1/-13)
landscape/lib/apt/package/store.py (+14/-19)
landscape/lib/apt/package/testing.py (+5/-10)
landscape/lib/apt/package/tests/test_facade.py (+130/-90)
landscape/lib/apt/package/tests/test_skeleton.py (+41/-39)
landscape/lib/apt/package/tests/test_store.py (+13/-15)
landscape/lib/backoff.py (+0/-1)
landscape/lib/bpickle.py (+10/-31)
landscape/lib/config.py (+13/-16)
landscape/lib/disk.py (+2/-8)
landscape/lib/encoding.py (+2/-5)
landscape/lib/fd.py (+1/-0)
landscape/lib/fetch.py (+6/-13)
landscape/lib/fs.py (+2/-3)
landscape/lib/logging.py (+1/-4)
landscape/lib/machine_id.py (+38/-0)
landscape/lib/message.py (+1/-3)
landscape/lib/monitor.py (+2/-4)
landscape/lib/network.py (+7/-16)
landscape/lib/os_release.py (+1/-0)
landscape/lib/persist.py (+21/-30)
landscape/lib/process.py (+5/-8)
landscape/lib/reactor.py (+2/-2)
landscape/lib/run_tests.py (+2/-1)
landscape/lib/schema.py (+10/-14)
landscape/lib/sequenceranges.py (+1/-4)
landscape/lib/store.py (+1/-0)
landscape/lib/sysstats.py (+6/-14)
landscape/lib/tag.py (+0/-1)
landscape/lib/testing.py (+20/-42)
landscape/lib/tests/test_amp.py (+12/-13)
landscape/lib/tests/test_bootstrap.py (+7/-9)
landscape/lib/tests/test_cloud.py (+8/-8)
landscape/lib/tests/test_config.py (+9/-28)
landscape/lib/tests/test_disk.py (+7/-6)
landscape/lib/tests/test_encoding.py (+1/-3)
landscape/lib/tests/test_fd.py (+2/-2)
landscape/lib/tests/test_fetch.py (+11/-13)
landscape/lib/tests/test_format.py (+2/-5)
landscape/lib/tests/test_fs.py (+17/-15)
landscape/lib/tests/test_gpg.py (+7/-10)
landscape/lib/tests/test_juju.py (+0/-2)
landscape/lib/tests/test_lock.py (+1/-2)
landscape/lib/tests/test_logging.py (+1/-3)
landscape/lib/tests/test_machine_id.py (+67/-0)
landscape/lib/tests/test_monitor.py (+7/-5)
landscape/lib/tests/test_network.py (+18/-23)
landscape/lib/tests/test_persist.py (+9/-9)
landscape/lib/tests/test_process.py (+3/-5)
landscape/lib/tests/test_reactor.py (+1/-1)
landscape/lib/tests/test_schema.py (+14/-22)
landscape/lib/tests/test_scriptcontent.py (+1/-2)
landscape/lib/tests/test_sequenceranges.py (+25/-13)
landscape/lib/tests/test_sysstats.py (+13/-13)
landscape/lib/tests/test_tag.py (+1/-2)
landscape/lib/tests/test_uaclient.py (+149/-0)
landscape/lib/tests/test_versioning.py (+1/-2)
landscape/lib/tests/test_vm_info.py (+1/-2)
landscape/lib/twisted_util.py (+4/-8)
landscape/lib/uaclient.py (+120/-0)
landscape/lib/user.py (+1/-8)
landscape/lib/versioning.py (+5/-7)
landscape/lib/vm_info.py (+2/-3)
landscape/lib/warning.py (+1/-0)
landscape/message_schemas/message.py (+1/-5)
landscape/message_schemas/server_bound.py (+61/-195)
landscape/message_schemas/test_message.py (+1/-2)
landscape/sysinfo/deployment.py (+28/-15)
landscape/sysinfo/disk.py (+2/-6)
landscape/sysinfo/network.py (+1/-2)
landscape/sysinfo/processes.py (+2/-2)
landscape/sysinfo/sysinfo.py (+1/-3)
landscape/sysinfo/temperature.py (+0/-1)
landscape/sysinfo/tests/test_deployment.py (+145/-71)
landscape/sysinfo/tests/test_disk.py (+1/-2)
landscape/sysinfo/tests/test_memory.py (+1/-3)
landscape/sysinfo/tests/test_network.py (+1/-2)
landscape/sysinfo/tests/test_processes.py (+1/-3)
landscape/sysinfo/tests/test_sysinfo.py (+8/-14)
man/landscape-client.1 (+4/-8)
man/landscape-client.txt (+3/-4)
man/landscape-config.1 (+38/-6)
man/landscape-config.txt (+11/-4)
man/landscape-sysinfo.1 (+2/-2)
man/landscape-sysinfo.txt (+1/-1)
pyproject.toml (+24/-13)
setup.py (+39/-7)
setup_client.py (+0/-1)
setup_sysinfo.py (+0/-1)
snap-http/.github/workflows/integration-test.yml (+17/-2)
snap-http/CHANGELOG.md (+44/-0)
snap-http/pyproject.toml (+1/-1)
snap-http/snap_http/__init__.py (+26/-0)
snap-http/snap_http/api.py (+361/-3)
snap-http/snap_http/http.py (+2/-1)
snap-http/snap_http/types.py (+3/-2)
snap-http/tests/integration/test_api.py (+32/-0)
snap-http/tests/integration/test_snap/snap/snapcraft.yaml (+1/-1)
snap-http/tests/unit/test_api.py (+697/-2)
snap-http/tests/unit/test_http.py (+1/-1)
snap-http/tests/utils.py (+1/-1)
snap/hooks/configure (+13/-1)
snap/snapcraft.yaml.j2 (+37/-7)
Reviewer Review Type Date Requested Status
Simon Poirier (community) Needs Fixing
Ubuntu Sponsors Pending
git-ubuntu import Pending
Review via email: mp+499697@code.launchpad.net

Commit message

New upstream release 26.02.1

To post a comment you must log in.
Revision history for this message
Jan-Yaeger Dhillon (jansdhillon) wrote :

Looks like there's a merge conflict on line 802 of the diff

Revision history for this message
Joseph Mucci (joey-mucci) wrote :

> Looks like there's a merge conflict on line 802 of the diff

Thanks for raising that concern, it should be resolved now.

Revision history for this message
Joseph Mucci (joey-mucci) wrote (last edit ):
Revision history for this message
Simon Poirier (simpoir) wrote (last edit ):

Could you rebase on top of ubuntu/devel to linearize the history and avoid needless merge commits?
Please keep d/changelog as separate commits. It makes reviewing large changes much easier when logical changes are their own commits.

Also see inline comments.

review: Needs Fixing
b56bc36... by Joseph Mucci

26.02.1-0ubuntu1 source changes

9d95a4e... by Joseph Mucci

d/patches: remove patches that have been applied upstream

101df48... by Joseph Mucci

d/p/fix-landscape-client-manpage.patch: refresh for 26.02.1

8a79a30... by Joseph Mucci

d/changelog: update changelog for 26.02.1

Revision history for this message
Joseph Mucci (joey-mucci) wrote :

> Could you rebase on top of ubuntu/devel to linearize the history and avoid
> needless merge commits?
> Please keep d/changelog as separate commits. It makes reviewing large changes
> much easier when logical changes are their own commits.
>
> Also see inline comments.

Thanks for getting to this, let me know if there are any more concerns.

Unmerged commits

8a79a30... by Joseph Mucci

d/changelog: update changelog for 26.02.1

101df48... by Joseph Mucci

d/p/fix-landscape-client-manpage.patch: refresh for 26.02.1

9d95a4e... by Joseph Mucci

d/patches: remove patches that have been applied upstream

b56bc36... by Joseph Mucci

26.02.1-0ubuntu1 source changes

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/.codecov.yml b/.codecov.yml
2index 0ca0528..185cc8a 100644
3--- a/.codecov.yml
4+++ b/.codecov.yml
5@@ -17,7 +17,7 @@ coverage:
6 patch:
7 default:
8 target: 100%
9- threshold: 0.05
10+ threshold: 1%
11 only_pulls: true
12 changes: off
13 parsers:
14diff --git a/.dev-lxc/config.yaml b/.dev-lxc/config.yaml
15index 0639ded..462c876 100644
16--- a/.dev-lxc/config.yaml
17+++ b/.dev-lxc/config.yaml
18@@ -9,3 +9,4 @@ config:
19 dev-lxc-exec:
20 - make depends
21 - git submodule update --init
22+ - curl -LsSf https://astral.sh/ruff/0.14.5/install.sh | sudo RUFF_INSTALL_DIR=/usr/local/bin sh
23diff --git a/.flake8 b/.flake8
24deleted file mode 100644
25index c3a3cd2..0000000
26--- a/.flake8
27+++ /dev/null
28@@ -1,5 +0,0 @@
29-[flake8]
30-max-line-length = 79
31-max-complexity = 18
32-select = B,C,E,F,W,T4,B9
33-ignore = E203, W503
34diff --git a/.github/workflows/build-jammy.yaml b/.github/workflows/build-jammy.yaml
35new file mode 100644
36index 0000000..3a64dc6
37--- /dev/null
38+++ b/.github/workflows/build-jammy.yaml
39@@ -0,0 +1,79 @@
40+name: Build Jammy
41+
42+on:
43+
44+ # Enable manual run against a specific ref
45+ workflow_dispatch:
46+ inputs:
47+ ref:
48+ description: "Git ref to build from (branch name, tag, commit SHA, etc.)"
49+ required: false
50+ default: ''
51+
52+ # Enable use from other workflows
53+ workflow_call:
54+ inputs:
55+ ref:
56+ description: "Git ref to build from (branch name, tag, commit SHA, etc.)"
57+ required: true
58+ type: string
59+
60+ # Run on any tag pushes
61+ push:
62+ tags:
63+ - "**"
64+
65+jobs:
66+ build-jammy:
67+ name: Build Jammy
68+ runs-on: ubuntu-22.04
69+
70+ steps:
71+ # Get the reference to build from, if provided.
72+ - name: Get reference to build from
73+ id: get-reference
74+ run: echo "ref=${{ github.event.inputs.ref || github.event.repository.default_branch }}" >> $GITHUB_OUTPUT
75+
76+ - name: Check out Git repository
77+ uses: actions/checkout@v4
78+ with:
79+ ref: ${{ steps.get-reference.outputs.ref }}
80+ submodules: true
81+
82+ - name: Install build dependencies
83+ run: sudo apt update && sudo apt install -y debhelper po-debconf libdistro-info-perl dh-python python3-dev python3-distutils-extra lsb-release gawk net-tools python3-apt python3-twisted python3-configobj python3-pycurl python3-pytest locales-all devscripts
84+
85+ - name: Build .deb
86+ run: |
87+ mkdir -p ../source-build
88+ mkdir -p ../deb-build
89+
90+ make tarball
91+ rm -r sdist/
92+
93+ debuild --no-lintian -S -sa --no-sign
94+ cp ../landscape-client_* ../source-build/
95+
96+ # If we use this to automatically make releases or upload to the PPA, we should enable checks.
97+ DEB_BUILD_OPTIONS=nocheck debuild -b --no-sign
98+ cp ../landscape-client_*.orig.tar.gz ../deb-build/
99+ cp ../landscape-*.*deb ../deb-build/
100+
101+ # Artifact upload steps do not allow relative paths
102+ mv ../source-build/ ./
103+ mv ../deb-build/ ./
104+
105+ - name: Upload source build
106+ uses: actions/upload-artifact@v4
107+ with:
108+ name: source-build
109+ path: ./source-build/
110+
111+ - name: Upload deb build
112+ uses: actions/upload-artifact@v4
113+ with:
114+ name: deb-build
115+ path: ./deb-build/
116+
117+ - run: |
118+ echo "::notice::Built from ref: ${{ steps.get-reference.outputs.ref }}"
119diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
120index 74030e8..6261c17 100644
121--- a/.github/workflows/ci.yml
122+++ b/.github/workflows/ci.yml
123@@ -5,18 +5,22 @@ jobs:
124 runs-on: ${{ matrix.os }}
125 strategy:
126 matrix:
127- os: ["ubuntu-22.04", "ubuntu-20.04", "ubuntu-24.04"]
128+ os: ["ubuntu-22.04", "ubuntu-24.04"]
129 steps:
130 - uses: actions/checkout@v4
131 with:
132 submodules: true
133 - run: make depends-ci
134 - run: make check TRIAL=/usr/bin/trial3
135- lint:
136+
137+ run-ruff:
138+ if: '!github.event.pull_request.draft'
139 runs-on: ubuntu-latest
140+
141 steps:
142 - uses: actions/checkout@v4
143 with:
144 submodules: true
145- - run: make depends
146- - run: make lint
147+
148+ - uses: astral-sh/ruff-action@v3
149+ - run: make ruff-check
150diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml
151index 69e5543..f6caf3c 100644
152--- a/.github/workflows/codecov.yml
153+++ b/.github/workflows/codecov.yml
154@@ -12,4 +12,6 @@ jobs:
155 - run: make depends-ci
156 - run: make coverage TRIAL=/usr/bin/trial3
157 - name: upload
158- uses: codecov/codecov-action@v3
159+ uses: codecov/codecov-action@v5
160+ env:
161+ CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
162diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml
163new file mode 100644
164index 0000000..8074f29
165--- /dev/null
166+++ b/.github/workflows/integration-test.yml
167@@ -0,0 +1,32 @@
168+name: Integration Tests
169+
170+on:
171+ schedule:
172+ # Weekly on Mondays at noon UTC.
173+ - cron: '0 12 * * 1'
174+ workflow_dispatch:
175+
176+env:
177+ TEST_PRO_TOKEN: ${{ secrets.TEST_PRO_TOKEN }}
178+
179+jobs:
180+ pytest_integration_tests:
181+ strategy:
182+ matrix:
183+ runner: ["ubuntu-24.04", "ubuntu-22.04"]
184+ runs-on: ${{ matrix.runner }}
185+ steps:
186+ # Many of the tests require root privileges, so the tests run using sudo.
187+ - uses: actions/checkout@v5
188+ with:
189+ submodules: true
190+
191+ # In 22.04, the pytest executable was called py.test-3.
192+ # We attempt to symlink it to pytest, not worrying if the linking fails.
193+ - name: install python dependencies
194+ run: |
195+ make depends-ci
196+ sudo ln -s /usr/bin/py.test-3 /usr/bin/pytest || true
197+
198+ - name: run pytest integration tests
199+ run: sudo -E pytest -m integration
200diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml
201index af08167..b71fe4e 100644
202--- a/.github/workflows/static-analysis.yml
203+++ b/.github/workflows/static-analysis.yml
204@@ -7,7 +7,7 @@ on:
205 - cron: '0 6 * * 0' # Run at 6:00a (arbitrary) to avoid peak activity on runners
206 jobs:
207 TICS:
208- runs-on: ubuntu-latest
209+ runs-on: [self-hosted, linux, amd64, tiobe, noble]
210 steps:
211 - name: Checkout master branch
212 uses: actions/checkout@v4
213diff --git a/.github/workflows/update-release-branches.yml b/.github/workflows/update-release-branches.yml
214new file mode 100644
215index 0000000..749fe67
216--- /dev/null
217+++ b/.github/workflows/update-release-branches.yml
218@@ -0,0 +1,44 @@
219+name: Update release branches
220+on:
221+ push:
222+ branches:
223+ - main
224+ workflow_dispatch:
225+
226+jobs:
227+ update-release-branches:
228+ runs-on: ubuntu-latest
229+ strategy:
230+ matrix:
231+ include:
232+ - branch: core-24
233+ base: core24
234+ - branch: core-22
235+ base: core22
236+
237+ steps:
238+ - uses: actions/checkout@v4
239+ with:
240+ fetch-depth: 0
241+ submodules: true
242+
243+ - name: Install dependencies
244+ run: |
245+ pip install --user jinja2-cli
246+
247+ - name: Update ${{ matrix.branch }} branch
248+ run: |
249+ # necessary otherwise git will crash & despair when we attempt to commit
250+ git config user.name "${{ github.actor }}"
251+ git config user.email "${{ github.actor_id }}+${{ github.actor }}@users.noreply.github.com"
252+
253+ git fetch origin ${{ matrix.branch }}
254+ git checkout ${{ matrix.branch }}
255+ git merge -X theirs main
256+
257+ jinja2 snap/snapcraft.yaml.j2 -D base=${{ matrix.base }} > snap/snapcraft.yaml
258+
259+ git add --force snap/snapcraft.yaml
260+ git commit -m "chore: update snapcraft.yaml" || true
261+
262+ git push origin ${{ matrix.branch }}
263diff --git a/.gitignore b/.gitignore
264index 4d34aeb..ff4aa38 100644
265--- a/.gitignore
266+++ b/.gitignore
267@@ -36,3 +36,6 @@ landscape_client.egg-info
268 .pybuild
269 venv
270 .venv
271+
272+# templated, generate with `make snap-yaml`
273+snap/snapcraft.yaml
274diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
275deleted file mode 100644
276index df96450..0000000
277--- a/.pre-commit-config.yaml
278+++ /dev/null
279@@ -1,43 +0,0 @@
280-# See https://pre-commit.com for more information
281-# See https://pre-commit.com/hooks.html for more hooks
282-repos:
283-- repo: https://github.com/pre-commit/pre-commit-hooks
284- rev: v4.4.0
285- hooks:
286- - id: trailing-whitespace
287- - id: end-of-file-fixer
288- - id: check-yaml
289- - id: check-added-large-files
290- - id: debug-statements
291-- repo: https://github.com/pre-commit/pre-commit-hooks
292- rev: v2.3.0
293- hooks:
294- - id: flake8
295- args:
296- - "--max-line-length=79"
297- - "--select=B,C,E,F,W,T4,B9"
298- - "--ignore=E203,W503"
299-- repo: https://github.com/psf/black
300- rev: 22.12.0
301- hooks:
302- - id: black
303- args:
304- - --line-length=79
305- - --include='\.pyi?$'
306-- repo: https://github.com/asottile/reorder_python_imports
307- rev: v2.3.0
308- hooks:
309- - id: reorder-python-imports
310- args: [--py3-plus]
311-- repo: https://github.com/asottile/add-trailing-comma
312- rev: v2.0.1
313- hooks:
314- - id: add-trailing-comma
315- args: [--py36-plus]
316-exclude: >
317- (?x)(
318- \.git
319- | \.csv$
320- | \.__pycache__
321- | \.log$
322- )
323diff --git a/Makefile b/Makefile
324index 7ca4ff9..58e5662 100644
325--- a/Makefile
326+++ b/Makefile
327@@ -4,12 +4,7 @@ PYTHON ?= python3
328 SNAPCRAFT = SNAPCRAFT_BUILD_INFO=1 snapcraft
329 TRIAL ?= -m landscape.lib.run_tests
330 TRIAL_ARGS ?=
331-PRE_COMMIT ?= $(HOME)/.local/bin/pre-commit
332
333-# PEP8 rules ignored:
334-# W503 https://www.flake8rules.com/rules/W503.html
335-# E203 Whitespace before ':' (enforced by Black)
336-PEP8_IGNORED = W503,E203
337
338 .PHONY: help
339 help: ## Print help about available targets
340@@ -17,20 +12,19 @@ help: ## Print help about available targets
341
342 .PHONY: depends
343 depends:
344- sudo apt update && sudo apt-get -y install python3-configobj python3-coverage python3-distutils-extra\
345- python3-flake8 python3-mock python3-netifaces python3-pip python3-pycurl python3-twisted\
346+ sudo apt update && sudo apt-get -y install python3-configobj python3-coverage \
347+ python3-mock python3-netifaces python3-pip python3-pycurl python3-twisted\
348 net-tools
349
350 .PHONY: depends-dev
351 depends-dev: depends
352- pip install pre-commit
353- $(PRE_COMMIT) install
354+ pip install jinja2-cli ruff coverage[toml]
355
356 # -common seems a catch-22, but this is just a shortcut to
357 # initialize user and dirs, some used through tests.
358 .PHONY: depends-ci
359 depends-ci: depends
360- sudo apt-get -y install landscape-common
361+ sudo apt-get -y install landscape-common python3-pytest
362
363 all: build
364
365@@ -44,23 +38,27 @@ build:
366 .PHONY: check
367 check: TRIAL_ARGS=
368 check: build
369- PYTHONPATH=$(PYTHONPATH):$(CURDIR) LC_ALL=C $(PYTHON) $(TRIAL) --unclean-warnings $(TRIAL_ARGS) landscape
370+ @if ! echo "$$DEB_BUILD_OPTIONS" | grep -qw nocheck; then \
371+ PYTHONPATH=$(PYTHONPATH):$(CURDIR) LC_ALL=C $(PYTHON) $(TRIAL) --unclean-warnings $(TRIAL_ARGS) landscape; \
372+ fi
373
374 .PHONY: coverage
375 coverage:
376 PYTHONPATH=$(PYTHONPATH):$(CURDIR) LC_ALL=C $(PYTHON) -m coverage run $(TRIAL) --unclean-warnings landscape
377 PYTHONPATH=$(PYTHONPATH):$(CURDIR) LC_ALL=C $(PYTHON) -m coverage xml
378
379-.PHONY: lint
380-lint:
381- $(PYTHON) -m flake8 --ignore $(PEP8_IGNORED) `find landscape -name \*.py`
382+.PHONY: ruff-fix
383+ruff-fix:
384+ ruff check --fix
385+ ruff format
386
387-.PHONY: pyflakes
388-pyflakes:
389- -pyflakes `find landscape -name \*.py`
390+.PHONY: ruff-check
391+ruff-check:
392+ ruff check
393+ ruff format --check
394
395-pre-commit:
396- -pre-commit run -a
397+.PHONY: lint
398+lint: ruff-fix
399
400 .PHONY: clean
401 clean:
402@@ -116,36 +114,40 @@ tags:
403 etags:
404 -etags --languages=python -R .
405
406+.PHONY: snap-yaml
407+snap-yaml:
408+ jinja2 snap/snapcraft.yaml.j2 -D base=core24 > snap/snapcraft.yaml
409+
410+.PHONY: snap-install
411 snap-install:
412 $(eval VERSION=$(shell yq ".version" snap/snapcraft.yaml))
413 sudo snap install --devmode landscape-client_$(VERSION)_amd64.snap
414-.PHONY: snap-install
415
416-snap-remote-build:
417- snapcraft remote-build
418 .PHONY: snap-remote-build
419+snap-remote-build: snap-yaml
420+ snapcraft remote-build
421
422+.PHONY: snap-remove
423 snap-remove:
424 sudo snap remove --purge landscape-client
425-.PHONY: snap-remove
426
427+.PHONY: snap-shell
428 snap-shell: snap-install
429 sudo snap run --shell landscape-client.landscape-client
430-.PHONY: snap-shell
431
432-snap-debug:
433- $(SNAPCRAFT) -v --debug
434 .PHONY: snap-debug
435+snap-debug: snap-yaml
436+ $(SNAPCRAFT) -v --debug
437
438+.PHONY: snap-clean
439 snap-clean: snap-remove
440 $(eval VERSION=$(shell yq ".version" snap/snapcraft.yaml))
441 $(SNAPCRAFT) clean
442 -rm landscape-client_$(VERSION)_amd64.snap
443-.PHONY: snap-clean
444
445-snap:
446- $(SNAPCRAFT)
447 .PHONY: snap
448+snap: snap-yaml
449+ $(SNAPCRAFT)
450
451 # TICS expects coverage info to be in ./coverage/.coverage
452 .PHONY: prepare-tics-analysis
453diff --git a/Makefile.packaging b/Makefile.packaging
454index d3c90ad..31ff873 100644
455--- a/Makefile.packaging
456+++ b/Makefile.packaging
457@@ -4,20 +4,11 @@ UBUNTU_RELEASE := $(shell lsb_release -cs)
458 # will be updated behind your back with the current result of that
459 # command everytime it is mentioned/used.
460 UPSTREAM_VERSION := $(shell python3 -c "from landscape import UPSTREAM_VERSION; print(UPSTREAM_VERSION)")
461-CHANGELOG_VERSION := $(shell dpkg-parsechangelog | grep ^Version | cut -f 2 -d " " | cut -f 1 -d '-')
462-GIT_HASH := $(shell git rev-parse --short HEAD)
463-# We simulate a git "revno" for the sake of sortability.
464-GIT_REVNO := $(shell git rev-list --count HEAD)
465-ifeq (+git,$(findstring +git,$(UPSTREAM_VERSION)))
466-TARBALL_VERSION := $(UPSTREAM_VERSION)
467-else
468-TARBALL_VERSION := $(UPSTREAM_VERSION)+git$(GIT_REVNO)
469-endif
470
471-.PHONY: origtarball
472-origtarball: sdist
473- cp -f sdist/landscape-client-$(TARBALL_VERSION).tar.gz \
474- ../landscape-client_$(TARBALL_VERSION).orig.tar.gz
475+.PHONY: tarball
476+tarball: sdist
477+ cp -f sdist/landscape-client-$(UPSTREAM_VERSION).tar.gz \
478+ ../landscape-client_$(UPSTREAM_VERSION).orig.tar.gz
479
480 .PHONY: prepchangelog
481 prepchangelog:
482@@ -30,17 +21,11 @@ prepchangelog:
483 echo "ERROR: please set \$$DEBEMAIL (NAME <EMAIL>)" 1>&2 ;\
484 exit 1 ;\
485 fi
486-# add a temporary entry for a local build if needed
487-ifeq (,$(findstring +git,$(CHANGELOG_VERSION)))
488- dch -v $(TARBALL_VERSION)-0ubuntu0 "New local test build" --distribution $(UBUNTU_RELEASE)
489-else
490-# just update the timestamp
491 dch --distribution $(UBUNTU_RELEASE) --release $(UBUNTU_RELEASE)
492-endif
493
494 .PHONY: updateversion
495 updateversion:
496- sed -i -e "s/^UPSTREAM_VERSION.*/UPSTREAM_VERSION = \"$(TARBALL_VERSION)\"/g" \
497+ sed -i -e "s/^UPSTREAM_VERSION.*/UPSTREAM_VERSION = \"$(UPSTREAM_VERSION)\"/g" \
498 landscape/__init__.py
499
500 .PHONY: package
501@@ -48,23 +33,19 @@ package: clean prepchangelog updateversion ## Generate the debian packages (use
502 debuild -b $(DEBUILD_OPTS)
503
504 .PHONY: sourcepackage
505-sourcepackage: origtarball prepchangelog updateversion
506+sourcepackage: tarball prepchangelog updateversion
507 # need to remove sdist here because it doesn't exist in the
508- # orig tarball
509+ # tarball
510 rm -rf sdist
511 debuild -S $(DEBUILD_OPTS)
512
513-.PHONY: releasetarball
514-releasetarball:
515- $(MAKE) sdist TARBALL_VERSION=$(UPSTREAM_VERSION)
516-
517 .PHONY: sdist
518 sdist: clean
519- mkdir -p sdist/landscape-client-$(TARBALL_VERSION)
520- git ls-files --recurse-submodules | xargs -I {} cp -r --parents {} sdist/landscape-client-$(TARBALL_VERSION)
521- rm -rf sdist/landscape-client-$(TARBALL_VERSION)/debian
522- sed -i -e "s/^UPSTREAM_VERSION.*/UPSTREAM_VERSION = \"$(TARBALL_VERSION)\"/g" \
523- sdist/landscape-client-$(TARBALL_VERSION)/landscape/__init__.py
524- cd sdist && tar cfz landscape-client-$(TARBALL_VERSION).tar.gz landscape-client-$(TARBALL_VERSION)
525- cd sdist && md5sum landscape-client-$(TARBALL_VERSION).tar.gz > landscape-client-$(TARBALL_VERSION).tar.gz.md5
526- rm -rf sdist/landscape-client-$(TARBALL_VERSION)
527+ mkdir -p sdist/landscape-client-$(UPSTREAM_VERSION)
528+ git ls-files --recurse-submodules | xargs -I {} cp -r --parents {} sdist/landscape-client-$(UPSTREAM_VERSION)
529+ rm -rf sdist/landscape-client-$(UPSTREAM_VERSION)/debian
530+ sed -i -e "s/^UPSTREAM_VERSION.*/UPSTREAM_VERSION = \"$(UPSTREAM_VERSION)\"/g" \
531+ sdist/landscape-client-$(UPSTREAM_VERSION)/landscape/__init__.py
532+ cd sdist && tar cfz landscape-client-$(UPSTREAM_VERSION).tar.gz landscape-client-$(UPSTREAM_VERSION)
533+ cd sdist && md5sum landscape-client-$(UPSTREAM_VERSION).tar.gz > landscape-client-$(UPSTREAM_VERSION).tar.gz.md5
534+ rm -rf sdist/landscape-client-$(UPSTREAM_VERSION)
535diff --git a/README b/README
536index bceb5e3..c463317 100644
537--- a/README
538+++ b/README
539@@ -6,13 +6,14 @@
540 Add our beta PPA to get the latest updates to the landscape-client package
541
542 #### Add repo to an Ubuntu series
543-```
544+
545+```shell
546 sudo add-apt-repository ppa:landscape/self-hosted-beta
547 ```
548
549 #### Add repo to a Debian based series that is not Ubuntu (experimental)
550
551-```
552+```shell
553 # 1. Install our signing key
554 gpg --keyserver keyserver.ubuntu.com --recv-keys 6e85a86e4652b4e6
555 gpg --export 6e85a86e4652b4e6 | sudo tee -a /usr/share/keyrings/landscape-client-keyring.gpg > /dev/null
556@@ -22,7 +23,8 @@ echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/lands
557 ```
558
559 #### Install the package
560-```
561+
562+```shell
563 sudo apt update && sudo apt install landscape-client
564 ```
565
566@@ -30,8 +32,8 @@ sudo apt update && sudo apt install landscape-client
567
568 The Landscape Client generally runs as a combination of the `root` and
569 `landscape` users. It is possible to disable the administrative features of
570-Landscape and run only the monitoring parts of it without using the `root`
571-user at all.
572+Landscape and run only the monitoring parts without using the `root` user at
573+all.
574
575 If you wish to use the Landscape Client in this way, it's recommended that you
576 perform these steps immediately after installing the landscape-client package.
577@@ -44,6 +46,7 @@ DAEMON_USER=landscape
578 ```
579
580 Edit `/etc/landscape/client.conf` and add the following line:
581+
582 ```
583 monitor_only = true
584 ```
585@@ -66,15 +69,15 @@ git submodule update --init
586
587 To run the full test suite, run the following command:
588
589-```
590+```shell
591 make check
592 ```
593
594 When you want to test the landscape client manually without management
595 features, you can simply run:
596
597-```
598-$ ./scripts/landscape-client
599+```shell
600+./scripts/landscape-client
601 ```
602
603 This defaults to the `landscape-client.conf` configuration file.
604@@ -83,69 +86,83 @@ When you want to test management features manually, you'll need to run as root.
605 There's a configuration file `root-client.conf` which specifies use of the
606 system bus.
607
608-```
609-$ sudo ./scripts/landscape-client -c root-client.conf
610+```shell
611+sudo ./scripts/landscape-client -c root-client.conf
612 ```
613
614 Before opening a PR, make sure to run the full test suite and lint:
615-```
616+
617+```shell
618 make check
619 make lint
620 ```
621
622 You can run a specific test by running the following (for example):
623-```
624+
625+```shell
626 python3 -m twisted.trial landscape.client.broker.tests.test_client.BrokerClientTest.test_ping
627 ```
628
629 ### Building the Landscape Client snap
630
631 First, you need to ensure that you have the appropriate tools installed:
632-```
633-$ sudo snap install snapcraft --classic
634-$ lxd init --auto
635+
636+```shell
637+sudo snap install snapcraft --classic
638+lxd init --auto
639 ```
640
641 There are various make targets defined to assist in the lifecycle of
642-building and testing the snap. To simply build the snap with the minimum
643-of debug information displayed:
644+building and testing the snap. To generate the snap's `snapcraft.yaml` file:
645+
646+```shell
647+make snap-yaml
648 ```
649-$ make snap
650+
651+To simply build the snap with the minimum of debug information displayed:
652+
653+```shell
654+make snap
655 ```
656
657 If you would prefer to see more information displayed showing the progress
658 of the build, and would like to get dropped into a debug shell within the
659 snap container in the event of an error:
660-```
661-$ make snap-debug
662+
663+```shell
664+make snap-debug
665 ```
666
667 To use the make targets below, make sure you have [yq](https://github.com/mikefarah/yq) installed. You can install it using [Homebrew](https://brew.sh/) or as a snap:
668
669-```console
670-$ brew install yq
671-$ snap install yq
672+```shell
673+brew install yq
674+snap install yq
675 ```
676
677 To install the resulting snap:
678-```
679-$ make snap-install
680+
681+```shell
682+make snap-install
683 ```
684
685 To remove a previously installed snap:
686-```
687-$ make snap-remove
688+
689+```shell
690+make snap-remove
691 ```
692
693 To clean the intermediate files as well as the snap itself from the local
694 build environment:
695-```
696-$ make snap-clean
697+
698+```shell
699+make snap-clean
700 ```
701
702 To enter into a shell environment within the snap container:
703-```
704-$ make snap-shell
705+
706+```shell
707+make snap-shell
708 ```
709
710 If you wish to upload the snap to the store, you will first need to get
711@@ -156,23 +173,27 @@ https://snapcraft.io/docs/creating-your-developer-account
712
713 After obtaining and confirming your store credentials, you then need to
714 log in using the snapcraft tool:
715-```
716-$ snapcraft login
717+
718+```shell
719+snapcraft login
720 ```
721
722 Since snapcraft version 7.x and higher, the credentials are stored in the
723 gnome keyring on your local workstation. If you are building in an
724 environment without a GUI (e.g. in a multipass or lxc container), you
725 will need to install the gnome keyring tools:
726-```
727-$ sudo apt install gnome-keyring
728+
729+```shell
730+sudo apt install gnome-keyring
731 ```
732
733 You will then need to initialze the default keyring as follows:
734+
735+```shell
736+dbus-run-session -- bash
737+gnome-keyring-daemon --unlock
738 ```
739-$ dbus-run-session -- bash
740-$ gnome-keyring-daemon --unlock
741-```
742+
743 The gnome-keyring-daemon will prompt you (without a prompt) to type in
744 the initial unlock password (typically the same password for the account
745 you are using - if you are using the default multipass or lxc "ubuntu"
746@@ -183,16 +204,20 @@ Type the login password and hit <ENTER> followed by <CTRL>+D to end
747 the input.
748
749 At this point, you should be able to log into snapcraft:
750+
751+```shell
752+snapcraft login
753 ```
754-$ snapcraft login
755-```
756+
757 You will be prompted for your UbuntuOne email address, password and,
758 if set up this way, your second factor for authentication. If you
759 are successful, you should be able to query your login credentials
760 with:
761+
762+```shell
763+snapcraft whoami
764 ```
765-$ snapcraft whoami
766-```
767+
768 A common mistake that first-time users of this process might make
769 is that after running the gnome-keyring-daemon command, they will
770 exit the dbus session shell. Do NOT do that. Do all subsequent
771@@ -208,6 +233,11 @@ logged into the store.
772
773 ### Automatic Builds and Releases
774
775-The latest version of landscape-client snap will be built from the most recent commit and
776-published to the `latest/edge` channel via [a recipe on launchpad](https://launchpad.net/~landscape/landscape-client/+snap/core-dev).
777-Other channels are promoted and released manually.
778+The landscape-client snap is automatically built from the most recent commit on the release branches and published to the `<base>/edge` channel of the respective core base:
779+
780+| Core Base | Edge Channel | Build Recipe |
781+|-----------|--------------|----------------------------------------------------------------------------|
782+| 24 | `24/edge` | [core-24](https://launchpad.net/~landscape/landscape-client/+snap/core-24) |
783+| 22 | `22/edge` | [core-22](https://launchpad.net/~landscape/landscape-client/+snap/core-22) |
784+
785+Other channels (`<base>/beta`, `<base>/candidate`, `<base>/stable`) are promoted and released manually.
786diff --git a/SECURITY.md b/SECURITY.md
787new file mode 100644
788index 0000000..906ed0d
789--- /dev/null
790+++ b/SECURITY.md
791@@ -0,0 +1,5 @@
792+# Security policy
793+
794+To report a security issue, file a [Private Security Report](https://github.com/canonical/landscape-client/security/advisories/new) or send an email to [security@ubuntu.com](mailto:security@ubuntu.com) with a description of the issue, the steps you took to create the issue, affected versions, and, if known, mitigations for the issue.
795+
796+The [Ubuntu Security disclosure and embargo policy](https://ubuntu.com/security/disclosure-policy) contains more information about what you can expect when you contact us and what we expect from you.
797diff --git a/debian/changelog b/debian/changelog
798index 1a20b31..dd766f7 100644
799--- a/debian/changelog
800+++ b/debian/changelog
801@@ -1,3 +1,75 @@
802+landscape-client (26.02.1-0ubuntu1) resolute; urgency=medium
803+
804+ * New upstream release 26.02.1
805+ - d/patches: refresh patches that have not been applied upstream
806+ - fix-landscape-client-manpage.patch
807+ - d/patches: remove patches that have been applied upstream
808+ - 2087852-feat-manage-ubuntu-sources-glob.patch
809+ - allow-http-proxy-in-tests.patch
810+ - fix-apt-source-file-management.patch
811+ - package-reporter-high-cpu.patch
812+ - unittest-makeSuite-deprecation.patch
813+ - fix: restore functionality and tests for python 3.14
814+ - feat: add FDE recovery key manager plugin
815+ - fix: package changer uses proxy when configured
816+ - refactor: Revert addition of `--ssl-ca` flag
817+ - fix: Move WSL config options out of unsaved_options
818+ - fix: landscape-config reads deprecated ssl_public_key field
819+ - refactor: clean up bpickle dumps
820+ - fix: don't explicitly make root owner of the executables
821+ - refactor: move uaclient integration test out of landscape directory
822+ - refactor: remove last instance of ssl_public_key
823+ - build: remove git suffix for packaging
824+ - fix: example. conf pro management plugin
825+ - Bug fix: add id for devmode snaps for server indexing
826+ - feat: add weekly integration testing for uaclient/pro
827+ - fix: add python3-packaging dependency to landscape-client
828+ - fix: uaclient wrapper with snap and core devices
829+ - fix: make improvements to landscape-config --show
830+ - feat: activity to detach pro
831+ - feat: activity for attaching pro
832+ - feat: add --show argument to landscape-config
833+ - fix: Add deprecation warning for --is-registered in landscape-client
834+ - refactor: add wrapper for uaclient library calls
835+ - fix: rename ssl-public-key parameter
836+ - build: add python3-packaging to build dependencies and fix test cases for build
837+ - Add setuptools, remove setup and fix paths in Snapcraft.yaml
838+ - fix: imports for snap/core devices
839+ - refactor: change ubuntu pro info to use `uaclient.status` instead of subprocess
840+ - fix: bump codecov upload version and use secret token
841+ - refactor: do not cast to list when not needed
842+ - feat: add --script-tempdir configuration
843+ - fix: use machine_id in registration message schema
844+ - feat: add machine-id to registration and computer info
845+ - refactor: cleanup string continuations
846+ - refactor: remove obsolete user message schemas
847+ - refactor: remove obsolete register-* message schemas
848+ - refactor: remove obsolete hardware-inventory message schema
849+ - refactor: remove obsolete computer-uptime message schema
850+ - refactor: remove oboslete client-uptime message schema
851+ - refactor: delete unused eucalyptus message schema
852+ - feat: unknown hashes per request is configurable
853+ - refactor: delete `landscape/lib/compat.py`
854+ - feat: log pending message count
855+ - feat: configurably exclude package sources
856+ - fix: invalid plugins do not crash landscape-sysinfo (LP: #1754002)
857+ - fix: .list and .sources files are restored when a repository profile is disassociated from a noble (or later) instance
858+ - fix: usgmanager should accept only the profile or the tailoring file
859+ - fix: ensure usgmanager plugin runs in deferred
860+ - feat: add usgmanager plugin to example.conf
861+ - fix: add run-id and operation-id fields to usg-audit message
862+ - fix: make config tests insensitive to http(s) proxy settings
863+ - fix: remove deprecated use of `unittest.makeSuite` to get tests passing on >=py312
864+ - feat: add optional --authenticated-attach-code to configuration parameters
865+ - feat: add authenticated attach code to registration message
866+ - refactor: stop using twisted.python.compat
867+ - feat: add support for .sources when applying repository profiles
868+ - fix: log rotation in snap
869+ - fix: removed snapd-control-managed from snapcraft.yaml apps stanza
870+ - fix: lint failure E226 - missing arithmetic whitespace
871+
872+ -- Joey Mucci <joseph.mucci@canonical.com> Thu, 05 Feb 2026 17:38:31 +0000
873+
874 landscape-client (24.12-0ubuntu5) resolute; urgency=medium
875
876 * No-change mass rebuild for Ubuntu 26.04 (LP: #2132257)
877diff --git a/debian/patches/2087852-feat-manage-ubuntu-sources-glob.patch b/debian/patches/2087852-feat-manage-ubuntu-sources-glob.patch
878deleted file mode 100644
879index 7dd82a5..0000000
880--- a/debian/patches/2087852-feat-manage-ubuntu-sources-glob.patch
881+++ /dev/null
882@@ -1,188 +0,0 @@
883-Subject: feat: add support for .sources when applying repository profiles
884-
885- This is the backport of an upstream commit which added support to manage
886- ubuntu sources in DEB822 format (.sources files) alongside the one-liner
887- format (.list files). The bug reported is outlined in LP: #2087852.
888-
889-Origin: upstream, https://github.com/canonical/landscape-client/commit/556f87c2819b218029ba0a13e2b2ceddfbec3e6b
890-Bug-Ubuntu: https://bugs.launchpad.net/landscape-client/+bug/2087852
891-Last-Update: 2025-03-31
892-
893-diff --git a/landscape/client/manager/aptsources.py b/landscape/client/manager/aptsources.py
894-index 179fa723..785fa215 100644
895---- a/landscape/client/manager/aptsources.py
896-+++ b/landscape/client/manager/aptsources.py
897-@@ -27,6 +27,11 @@ class AptSources(ManagerPlugin):
898- SOURCES_LIST_D = "/etc/apt/sources.list.d"
899- TRUSTED_GPG_D = "/etc/apt/trusted.gpg.d"
900-
901-+ """
902-+ Valid file patterns for one-line and Deb822-style sources, respectively.
903-+ """
904-+ SOURCES_LIST_D_FILE_PATTERNS = ["*.list", "*.sources"]
905-+
906- def register(self, registry):
907- super().register(registry)
908- registry.register_message(
909-@@ -140,10 +145,14 @@ def _handle_sources(self, ignored, sources):
910- "manage_sources_list_d",
911- True,
912- )
913-+
914- if manage_sources_list_d not in FALSE_VALUES:
915-- filenames = glob.glob(os.path.join(self.SOURCES_LIST_D, "*.list"))
916-- for filename in filenames:
917-- shutil.move(filename, f"{filename}.save")
918-+ for pattern in self.SOURCES_LIST_D_FILE_PATTERNS:
919-+ filenames = glob.glob(
920-+ os.path.join(self.SOURCES_LIST_D, pattern)
921-+ )
922-+ for filename in filenames:
923-+ shutil.move(filename, f"{filename}.save")
924-
925- for source in sources:
926- filename = os.path.join(
927-diff --git a/landscape/client/manager/tests/test_aptsources.py b/landscape/client/manager/tests/test_aptsources.py
928-index aa648be6..c17aac44 100644
929---- a/landscape/client/manager/tests/test_aptsources.py
930-+++ b/landscape/client/manager/tests/test_aptsources.py
931-@@ -260,20 +260,28 @@ def buggy_source_handler(*args):
932-
933- def test_renames_sources_list_d(self):
934- """
935-- The sources files in sources.list.d are renamed to .save when a message
936-- is received if config says to manage them, which is the default.
937-+ The sources files (.list, .sources) in sources.list.d
938-+ are renamed to .save when a message is received
939-+ if config says to manage them, which is the default.
940- """
941-+ FILE_1_LIST = os.path.join(
942-+ self.sourceslist.SOURCES_LIST_D, "file1.list"
943-+ )
944-+
945-+ FILE_2_SOURCES = os.path.join(
946-+ self.sourceslist.SOURCES_LIST_D, "file2.sources"
947-+ )
948- with open(
949-- os.path.join(self.sourceslist.SOURCES_LIST_D, "file1.list"),
950-+ FILE_1_LIST,
951- "w",
952-- ) as sources1:
953-- sources1.write("ok\n")
954-+ ) as source1:
955-+ source1.write("ok\n")
956-
957- with open(
958-- os.path.join(self.sourceslist.SOURCES_LIST_D, "file2.list.save"),
959-+ FILE_2_SOURCES,
960- "w",
961-- ) as sources2:
962-- sources2.write("ok\n")
963-+ ) as source2:
964-+ source2.write("ok\n")
965-
966- self.manager.dispatch_message(
967- {
968-@@ -285,45 +293,42 @@ def test_renames_sources_list_d(self):
969- )
970-
971- self.assertFalse(
972-- os.path.exists(
973-- os.path.join(self.sourceslist.SOURCES_LIST_D, "file1.list"),
974-- ),
975-+ os.path.exists(FILE_1_LIST),
976- )
977-
978-+ self.assertFalse(os.path.exists(FILE_2_SOURCES))
979-+
980- self.assertTrue(
981-- os.path.exists(
982-- os.path.join(
983-- self.sourceslist.SOURCES_LIST_D,
984-- "file1.list.save",
985-- ),
986-- ),
987-+ os.path.exists(f"{FILE_1_LIST}.save"),
988- )
989-
990- self.assertTrue(
991-- os.path.exists(
992-- os.path.join(
993-- self.sourceslist.SOURCES_LIST_D,
994-- "file2.list.save",
995-- ),
996-- ),
997-+ os.path.exists(f"{FILE_2_SOURCES}.save"),
998- )
999-
1000- def test_does_not_rename_sources_list_d(self):
1001- """
1002-- The sources files in sources.list.d are not renamed to .save when a
1003-- message is received if config says not to manage them.
1004-+ The sources files (.list, .sources) in sources.list.d
1005-+ are not renamed to .save when a message is received
1006-+ if config says not to manage them
1007- """
1008-+ FILE_3_LIST = os.path.join(
1009-+ self.sourceslist.SOURCES_LIST_D, "file3.list"
1010-+ )
1011-+
1012-+ FILE_4_SOURCES = os.path.join(
1013-+ self.sourceslist.SOURCES_LIST_D, "file4.sources"
1014-+ )
1015- with open(
1016-- os.path.join(self.sourceslist.SOURCES_LIST_D, "file1.list"),
1017-+ FILE_3_LIST,
1018- "w",
1019-- ) as sources1:
1020-- sources1.write("ok\n")
1021--
1022-+ ) as source3:
1023-+ source3.write("ok\n")
1024- with open(
1025-- os.path.join(self.sourceslist.SOURCES_LIST_D, "file2.list.save"),
1026-+ FILE_4_SOURCES,
1027- "w",
1028-- ) as sources2:
1029-- sources2.write("ok\n")
1030-+ ) as source4:
1031-+ source4.write("ok\n")
1032-
1033- self.manager.config.manage_sources_list_d = False
1034- self.manager.dispatch_message(
1035-@@ -336,27 +341,19 @@ def test_does_not_rename_sources_list_d(self):
1036- )
1037-
1038- self.assertTrue(
1039-- os.path.exists(
1040-- os.path.join(self.sourceslist.SOURCES_LIST_D, "file1.list"),
1041-- ),
1042-+ os.path.exists(FILE_3_LIST),
1043-+ )
1044-+
1045-+ self.assertTrue(
1046-+ os.path.exists(FILE_4_SOURCES),
1047- )
1048-
1049- self.assertFalse(
1050-- os.path.exists(
1051-- os.path.join(
1052-- self.sourceslist.SOURCES_LIST_D,
1053-- "file1.list.save",
1054-- ),
1055-- ),
1056-+ os.path.exists(f"{FILE_3_LIST}.save"),
1057- )
1058-
1059-- self.assertTrue(
1060-- os.path.exists(
1061-- os.path.join(
1062-- self.sourceslist.SOURCES_LIST_D,
1063-- "file2.list.save",
1064-- ),
1065-- ),
1066-+ self.assertFalse(
1067-+ os.path.exists(f"{FILE_4_SOURCES}.save"),
1068- )
1069-
1070- def test_create_landscape_sources(self):
1071diff --git a/debian/patches/allow-http-proxy-in-tests.patch b/debian/patches/allow-http-proxy-in-tests.patch
1072deleted file mode 100644
1073index 3fc6473..0000000
1074--- a/debian/patches/allow-http-proxy-in-tests.patch
1075+++ /dev/null
1076@@ -1,60 +0,0 @@
1077-Description: Fix config tests; make them insensitive to http(s) proxy environment variables
1078-Author: Mitch Burton <mitch.burton@canonical.com>
1079-Origin: upstream
1080-Bug-Ubuntu: https://bugs.launchpad.net/ubuntu/+source/landscape-client/+bug/2106263
1081-Applied-Upstream: https://github.com/canonical/landscape-client/commit/e79f605e3c69fc5b9b529a9997e4e89a6728066f
1082-Last-Update: 2025-04-07
1083----
1084-This patch header follows DEP-3: http://dep.debian.net/deps/dep3/
1085-Index: landscape-client/landscape/client/tests/test_configuration.py
1086-===================================================================
1087---- landscape-client.orig/landscape/client/tests/test_configuration.py
1088-+++ landscape-client/landscape/client/tests/test_configuration.py
1089-@@ -756,6 +756,7 @@ class ConfigurationFunctionsTest(Landsca
1090- )
1091-
1092- @mock.patch("landscape.client.configuration.ServiceConfig")
1093-+ @mock.patch("os.environ", new={})
1094- def test_silent_setup(self, mock_serviceconfig):
1095- """
1096- Only command-line options are used in silent mode.
1097-@@ -775,6 +776,7 @@ url = https://landscape.canonical.com/me
1098- )
1099-
1100- @mock.patch("landscape.client.configuration.ServiceConfig")
1101-+ @mock.patch("os.environ", {})
1102- def test_silent_setup_no_register(self, mock_serviceconfig):
1103- """
1104- Called with command line options to write a config file but no
1105-@@ -846,6 +848,7 @@ url = https://landscape.canonical.com/me
1106- )
1107-
1108- @mock.patch("landscape.client.configuration.ServiceConfig")
1109-+ @mock.patch("os.environ", {})
1110- def test_silent_setup_unicode_computer_title(self, mock_serviceconfig):
1111- """
1112- Setup accepts a non-ascii computer title and registration is
1113-@@ -880,6 +883,7 @@ url = https://landscape.canonical.com/me
1114-
1115- @mock.patch("landscape.client.configuration.input")
1116- @mock.patch("landscape.client.configuration.ServiceConfig")
1117-+ @mock.patch("os.environ", new={})
1118- def test_silent_script_users_imply_script_execution_plugin(
1119- self,
1120- mock_serviceconfig,
1121-@@ -951,6 +955,7 @@ bus = session
1122- mock_serviceconfig.set_start_on_boot.assert_called_once_with(True)
1123-
1124- @mock.patch("landscape.client.configuration.ServiceConfig")
1125-+ @mock.patch("os.environ", new={})
1126- def test_silent_setup_with_ping_url(self, mock_serviceconfig):
1127- mock_serviceconfig.restart_landscape.return_value = True
1128- filename = self.makeFile(
1129-@@ -1814,6 +1819,7 @@ registration_key = shared-secret
1130- )
1131-
1132- @mock.patch("landscape.client.configuration.ServiceConfig")
1133-+ @mock.patch("os.environ", new={})
1134- def test_import_from_file_may_reset_old_options(self, mock_serviceconfig):
1135- """
1136- This test ensures that setting an empty option in an imported
1137diff --git a/debian/patches/fix-apt-source-file-management.patch b/debian/patches/fix-apt-source-file-management.patch
1138deleted file mode 100644
1139index 1d1f07b..0000000
1140--- a/debian/patches/fix-apt-source-file-management.patch
1141+++ /dev/null
1142@@ -1,204 +0,0 @@
1143-Description: restore .list and .sources files when a repo profile is disassociated.
1144-Author: Mitch Burton <mitch.burton@canonical.com>
1145-Origin: backport, https://github.com/canonical/landscape-client/commit/c9900d7a31eb6d5822bd2a66d0a6bfa3e926a7a6
1146-Bug-Ubuntu: https://bugs.launchpad.net/ubuntu/+source/landscape-client/+bug/2087852
1147-Applied-Upstream: 25.04
1148-Last-Update: 2025-05-30
1149----
1150-This patch header follows DEP-3: http://dep.debian.net/deps/dep3/
1151-Index: landscape-client/landscape/client/manager/aptsources.py
1152-===================================================================
1153---- landscape-client.orig/landscape/client/manager/aptsources.py 2025-05-30 15:51:00.602533338 -0700
1154-+++ landscape-client/landscape/client/manager/aptsources.py 2025-05-30 15:53:54.187667629 -0700
1155-@@ -114,6 +114,12 @@
1156-
1157- saved_sources = f"{self.SOURCES_LIST}.save"
1158-
1159-+ manage_sources_list_d = getattr(
1160-+ self.registry.config,
1161-+ "manage_sources_list_d",
1162-+ True,
1163-+ )
1164-+
1165- if sources:
1166- fd, path = tempfile.mkstemp()
1167- os.close(fd)
1168-@@ -135,24 +141,34 @@
1169- original_stat.st_uid,
1170- original_stat.st_gid,
1171- )
1172-+
1173-+ if manage_sources_list_d not in FALSE_VALUES:
1174-+ for pattern in self.SOURCES_LIST_D_FILE_PATTERNS:
1175-+ filenames = glob.glob(
1176-+ os.path.join(self.SOURCES_LIST_D, pattern)
1177-+ )
1178-+ for filename in filenames:
1179-+ shutil.move(filename, f"{filename}.save")
1180- else:
1181- # Re-instate original sources
1182- if os.path.isfile(saved_sources):
1183- shutil.move(saved_sources, self.SOURCES_LIST)
1184-
1185-- manage_sources_list_d = getattr(
1186-- self.registry.config,
1187-- "manage_sources_list_d",
1188-- True,
1189-- )
1190-+ if manage_sources_list_d not in FALSE_VALUES:
1191-+ for pattern in self.SOURCES_LIST_D_FILE_PATTERNS:
1192-+ filenames = glob.glob(
1193-+ os.path.join(self.SOURCES_LIST_D, f"{pattern}.save")
1194-+ )
1195-+ for filename in filenames:
1196-+ restored_filename = filename.removesuffix(".save")
1197-+ shutil.move(filename, restored_filename)
1198-+ # Delete Landscape source files prefixed with `landscape-`
1199-+ landscape_source_filenames = glob.glob(
1200-+ os.path.join(self.SOURCES_LIST_D, "landscape-*.list")
1201-+ )
1202-
1203-- if manage_sources_list_d not in FALSE_VALUES:
1204-- for pattern in self.SOURCES_LIST_D_FILE_PATTERNS:
1205-- filenames = glob.glob(
1206-- os.path.join(self.SOURCES_LIST_D, pattern)
1207-- )
1208-- for filename in filenames:
1209-- shutil.move(filename, f"{filename}.save")
1210-+ for source_filename in landscape_source_filenames:
1211-+ os.remove(source_filename)
1212-
1213- for source in sources:
1214- filename = os.path.join(
1215-Index: landscape-client/landscape/client/manager/tests/test_aptsources.py
1216-===================================================================
1217---- landscape-client.orig/landscape/client/manager/tests/test_aptsources.py 2025-05-30 15:51:00.602533338 -0700
1218-+++ landscape-client/landscape/client/manager/tests/test_aptsources.py 2025-05-30 15:54:55.930259108 -0700
1219-@@ -165,6 +165,109 @@
1220- with open(self.sourceslist.SOURCES_LIST) as sources:
1221- self.assertEqual("original content\n", sources.read())
1222-
1223-+ def test_restore_sources_list_d(self):
1224-+ """
1225-+ When getting a repository message without sources, AptSources
1226-+ restores the previous files in sources.list.d.
1227-+ """
1228-+ FILE_1_LIST = os.path.join(
1229-+ self.sourceslist.SOURCES_LIST_D, "file1.list"
1230-+ )
1231-+ FILE_2_SOURCES = os.path.join(
1232-+ self.sourceslist.SOURCES_LIST_D, "file2.sources"
1233-+ )
1234-+ with open(
1235-+ FILE_1_LIST,
1236-+ "w",
1237-+ ) as source1:
1238-+ source1.write("ok\n")
1239-+ with open(
1240-+ FILE_2_SOURCES,
1241-+ "w",
1242-+ ) as source2:
1243-+ source2.write("ok\n")
1244-+ self.manager.dispatch_message(
1245-+ {
1246-+ "type": "apt-sources-replace",
1247-+ "sources": [{"name": "bla", "content": b""}],
1248-+ "gpg-keys": [],
1249-+ "operation-id": 1,
1250-+ },
1251-+ )
1252-+ self.assertFalse(
1253-+ os.path.exists(FILE_1_LIST),
1254-+ )
1255-+ self.assertFalse(os.path.exists(FILE_2_SOURCES))
1256-+ self.assertTrue(
1257-+ os.path.exists(f"{FILE_1_LIST}.save"),
1258-+ )
1259-+ self.assertTrue(
1260-+ os.path.exists(f"{FILE_2_SOURCES}.save"),
1261-+ )
1262-+ self.manager.dispatch_message(
1263-+ {
1264-+ "type": "apt-sources-replace",
1265-+ "sources": [],
1266-+ "gpg-keys": [],
1267-+ "operation-id": 2,
1268-+ },
1269-+ )
1270-+ self.assertTrue(
1271-+ os.path.exists(FILE_1_LIST),
1272-+ )
1273-+ self.assertTrue(os.path.exists(FILE_2_SOURCES))
1274-+ self.assertFalse(
1275-+ os.path.exists(f"{FILE_1_LIST}.save"),
1276-+ )
1277-+ self.assertFalse(
1278-+ os.path.exists(f"{FILE_2_SOURCES}.save"),
1279-+ )
1280-+ def test_restore_sources_list_d_removes_old_profile_files(self):
1281-+ """
1282-+ When getting a repository message without sources, old
1283-+ source files in `/etc/apt/sources.list.d` prefixed with
1284-+ `landscape-` will be removed.
1285-+ """
1286-+ first_source_name = "ginger"
1287-+ self.manager.dispatch_message(
1288-+ {
1289-+ "type": "apt-sources-replace",
1290-+ "sources": [{"name": first_source_name, "content": b""}],
1291-+ "gpg-keys": [],
1292-+ "operation-id": 1,
1293-+ },
1294-+ )
1295-+ first_sources_path = os.path.join(
1296-+ self.sourceslist.SOURCES_LIST_D,
1297-+ f"landscape-{first_source_name}.list",
1298-+ )
1299-+ self.assertTrue(os.path.exists(first_sources_path))
1300-+ second_source_name = "ace rothstein"
1301-+ self.manager.dispatch_message(
1302-+ {
1303-+ "type": "apt-sources-replace",
1304-+ "sources": [{"name": second_source_name, "content": b""}],
1305-+ "gpg-keys": [],
1306-+ "operation-id": 2,
1307-+ },
1308-+ )
1309-+ second_sources_path = os.path.join(
1310-+ self.sourceslist.SOURCES_LIST_D,
1311-+ f"landscape-{second_source_name}.list",
1312-+ )
1313-+ self.assertTrue(os.path.exists(f"{first_sources_path}.save"))
1314-+ self.assertTrue(os.path.exists(second_sources_path))
1315-+ self.manager.dispatch_message(
1316-+ {
1317-+ "type": "apt-sources-replace",
1318-+ "sources": [],
1319-+ "gpg-keys": [],
1320-+ "operation-id": 3,
1321-+ },
1322-+ )
1323-+ self.assertFalse(os.path.exists(first_sources_path))
1324-+ self.assertFalse(os.path.exists(second_sources_path))
1325-+
1326- def test_sources_list_permissions(self):
1327- """
1328- When getting a repository message, L{AptSources} keeps sources.list
1329-@@ -286,12 +389,16 @@
1330- self.manager.dispatch_message(
1331- {
1332- "type": "apt-sources-replace",
1333-- "sources": [],
1334-+ "sources": [{"name": "bla", "content": b""}],
1335- "gpg-keys": [],
1336- "operation-id": 1,
1337- },
1338- )
1339-
1340-+ self.assertTrue(
1341-+ os.path.exists(self.sourceslist.SOURCES_LIST),
1342-+ )
1343-+
1344- self.assertFalse(
1345- os.path.exists(FILE_1_LIST),
1346- )
1347diff --git a/debian/patches/fix-landscape-client-manpage.patch b/debian/patches/fix-landscape-client-manpage.patch
1348index 729891a..9a34d2b 100644
1349--- a/debian/patches/fix-landscape-client-manpage.patch
1350+++ b/debian/patches/fix-landscape-client-manpage.patch
1351@@ -1,19 +1,19 @@
1352 Description: Fix indentation in landscape-client manpage
1353-Author: Mitch Burton <mitch.burton@canonical.com>
1354+Author: Joey Mucci <joseph.mucci@canonical.com>
1355 Bug: https://bugs.launchpad.net/landscape-client/+bug/2076667
1356-Last-Update: 2024-08-12
1357+Last-Update: 2026-02-02
1358 ---
1359 This patch header follows DEP-3: http://dep.debian.net/deps/dep3/
1360 --- a/man/landscape-client.1
1361 +++ b/man/landscape-client.1
1362 @@ -1,5 +1,5 @@
1363 .\" Text automatically generated by txt2man
1364--.TH landscape-client 1 "08 July 2024" "" ""
1365-+.TH landscape-client 1 "12 August 2024" "" ""
1366+-.TH landscape-client 1 "25 November 2025" "" ""
1367++.TH landscape-client 1 "02 February 2026" "" ""
1368 .SH NAME
1369 \fBlandscape-client \fP- Landscape system client
1370 \fB
1371-@@ -92,11 +92,14 @@
1372+@@ -88,11 +88,14 @@
1373 .SH EXAMPLES
1374
1375 To run the client in the foreground, with all logging data printed to standard
1376@@ -32,7 +32,7 @@ This patch header follows DEP-3: http://dep.debian.net/deps/dep3/
1377 To run the client in the background with a particular configuration file:
1378 .PP
1379 .nf
1380-@@ -105,7 +108,6 @@
1381+@@ -101,7 +104,6 @@
1382
1383 .fam T
1384 .fi
1385@@ -42,7 +42,7 @@ This patch header follows DEP-3: http://dep.debian.net/deps/dep3/
1386 .PP
1387 --- a/man/landscape-client.txt
1388 +++ b/man/landscape-client.txt
1389-@@ -41,7 +41,7 @@
1390+@@ -40,7 +40,7 @@
1391 EXAMPLES
1392
1393 To run the client in the foreground, with all logging data printed to standard
1394diff --git a/debian/patches/package-reporter-high-cpu.patch b/debian/patches/package-reporter-high-cpu.patch
1395deleted file mode 100644
1396index 03e4866..0000000
1397--- a/debian/patches/package-reporter-high-cpu.patch
1398+++ /dev/null
1399@@ -1,117 +0,0 @@
1400-Description: Reduce CPU usage of package-reporter (LP: #2099283)
1401-Author: Jan-Yaeger Dhillon <jan.dhillon@canonical.com>
1402-Origin: upstream, https://github.com/canonical/landscape-client/commit/f5a6d8a924097a37e70ec25522bd748a341212bf
1403-Bug-Ubuntu: https://bugs.launchpad.net/landscape-client/+bug/2099283
1404-Reviewed-by: Kevin Nasto <kevin.nasto@canonical.com>
1405-Applied Upstream: 25.04, https://github.com/canonical/landscape-client/commit/f5a6d8a924097a37e70ec25522bd748a341212bf
1406-Last-Update: 2025-05-12
1407----
1408-Index: landscape-client/landscape/client/package/reporter.py
1409-===================================================================
1410---- landscape-client.orig/landscape/client/package/reporter.py
1411-+++ landscape-client/landscape/client/package/reporter.py
1412-@@ -698,53 +698,53 @@ class PackageReporter(PackageTaskHandler
1413- backports_archive = "{}-backports".format(os_release_info["code-name"])
1414- security_archive = "{}-security".format(os_release_info["code-name"])
1415-
1416-- for package in self._facade.get_packages():
1417-+ for package_version in self._facade.get_packages():
1418-+ # Get archives from the list of PackageFiles
1419-+ # for the given package version rather than using
1420-+ # package_version.origins from the Python apt package.
1421-+ # We only want to check the archives, and creating
1422-+ # Origins using package_version.origins is expensive.
1423-+ # See /usr/lib/python3/dist-packages/apt/package.py
1424-+ archives = [
1425-+ # Ex. jammy-backports
1426-+ package_file.archive
1427-+ for package_file, _ in package_version._cand.file_list
1428-+ ]
1429-+
1430- # Don't include package versions from the official backports
1431- # archive. The backports archive is enabled by default since
1432- # xenial with a pinning policy of 100. Ideally we would
1433- # support pinning, but we don't yet. In the mean time, we
1434- # ignore backports, so that packages don't get automatically
1435- # upgraded to the backports version.
1436-- backport_origins = [
1437-- origin
1438-- for origin in package.origins
1439-- if origin.archive == backports_archive
1440-- ]
1441-- if backport_origins and (
1442-- len(backport_origins) == len(package.origins)
1443-- ):
1444-+ if all(archive == backports_archive for archive in archives):
1445- # Ignore the version if it's only in the official
1446- # backports archive. If it's somewhere else as well,
1447- # e.g. a PPA, we assume it was added manually and the
1448- # user wants to get updates from it.
1449- continue
1450-- hash = self._facade.get_package_hash(package)
1451-+ hash = self._facade.get_package_hash(package_version)
1452- id = self._store.get_hash_id(hash)
1453- if id is not None:
1454-- if self._facade.is_package_installed(package):
1455-+ if self._facade.is_package_installed(package_version):
1456- current_installed.add(id)
1457-- if self._facade.is_package_available(package):
1458-+ if self._facade.is_package_available(package_version):
1459- current_available.add(id)
1460-- if self._facade.is_package_autoremovable(package):
1461-+ if self._facade.is_package_autoremovable(package_version):
1462- current_autoremovable.add(id)
1463- else:
1464- current_available.add(id)
1465-
1466- # Are there any packages that this package is an upgrade for?
1467-- if self._facade.is_package_upgrade(package):
1468-+ if self._facade.is_package_upgrade(package_version):
1469- current_upgrades.add(id)
1470-
1471- # Is this package present in the security pocket?
1472-- security_origins = any(
1473-- origin
1474-- for origin in package.origins
1475-- if origin.archive == security_archive
1476-- )
1477-- if security_origins:
1478-+ if security_archive in archives:
1479- current_security.add(id)
1480-
1481-- for package in self._facade.get_locked_packages():
1482-- hash = self._facade.get_package_hash(package)
1483-+ for package_version in self._facade.get_locked_packages():
1484-+ hash = self._facade.get_package_hash(package_version)
1485- id = self._store.get_hash_id(hash)
1486- if id is not None:
1487- current_locked.add(id)
1488-Index: landscape-client/landscape/client/package/tests/test_reporter.py
1489-===================================================================
1490---- landscape-client.orig/landscape/client/package/tests/test_reporter.py
1491-+++ landscape-client/landscape/client/package/tests/test_reporter.py
1492-@@ -1262,6 +1262,24 @@ class PackageReporterAptTest(LandscapeTe
1493- result = self.reporter.detect_packages_changes()
1494- return result.addCallback(got_result)
1495-
1496-+ def test_compute_packages_changes_package_origins_not_called(self):
1497-+ """
1498-+ Archive info is extracted directly from the package versions
1499-+ and the apt.package.Version.origins property is not called
1500-+ """
1501-+ with mock.patch("apt.package.Version.origins") as version_origins_mock:
1502-+ self.successResultOf(self.reporter._compute_packages_changes())
1503-+ version_origins_mock.assert_not_called()
1504-+
1505-+ def test_compute_packages_changes_origins_not_created(self):
1506-+ """
1507-+ Archive info is extracted directly from the package versions
1508-+ and no Origins are created (expensive find_index() is not called)
1509-+ """
1510-+ with mock.patch("apt.package.Origin.__init__") as origin_mock:
1511-+ self.successResultOf(self.reporter._compute_packages_changes())
1512-+ origin_mock.assert_not_called()
1513-+
1514- def test_detect_packages_changes_with_backports_others(self):
1515- """
1516- Packages coming from backport archives that aren't named like
1517diff --git a/debian/patches/series b/debian/patches/series
1518index 9c191d0..051c373 100644
1519--- a/debian/patches/series
1520+++ b/debian/patches/series
1521@@ -1,6 +1 @@
1522 fix-landscape-client-manpage.patch
1523-unittest-makeSuite-deprecation.patch
1524-2087852-feat-manage-ubuntu-sources-glob.patch
1525-allow-http-proxy-in-tests.patch
1526-package-reporter-high-cpu.patch
1527-fix-apt-source-file-management.patch
1528diff --git a/debian/patches/unittest-makeSuite-deprecation.patch b/debian/patches/unittest-makeSuite-deprecation.patch
1529deleted file mode 100644
1530index e06382e..0000000
1531--- a/debian/patches/unittest-makeSuite-deprecation.patch
1532+++ /dev/null
1533@@ -1,40 +0,0 @@
1534-Description: Fix tests on python versions where unittest.makeSuite has been removed
1535-Author: Mitch Burton <mitch.burton@canonical.com>
1536-Origin: upstream
1537-Bug-Ubuntu: https://bugs.launchpad.net/ubuntu/+source/landscape-client/+bug/2106263
1538-Applied-Upstream: https://github.com/canonical/landscape-client/commit/e3f051ad6a33029845f9f11b17649299004598a7
1539-Last-Update: 2025-03-26
1540----
1541-This patch header follows DEP-3: http://dep.debian.net/deps/dep3/
1542-Index: landscape-client/landscape/lib/tests/test_sequenceranges.py
1543-===================================================================
1544---- landscape-client.orig/landscape/lib/tests/test_sequenceranges.py
1545-+++ landscape-client/landscape/lib/tests/test_sequenceranges.py
1546-@@ -297,11 +297,21 @@ class RemoveFromRangesTest(unittest.Test
1547- def test_suite():
1548- return unittest.TestSuite(
1549- (
1550-- unittest.makeSuite(SequenceToRangesTest),
1551-- unittest.makeSuite(RangesToSequenceTest),
1552-- unittest.makeSuite(SequenceRangesTest),
1553-- unittest.makeSuite(FindRangesIndexTest),
1554-- unittest.makeSuite(AddToRangesTest),
1555-- unittest.makeSuite(RemoveFromRangesTest),
1556-+ unittest.defaultTestLoader.loadTestsFromTestCase(
1557-+ SequenceToRangesTest
1558-+ ),
1559-+ unittest.defaultTestLoader.loadTestsFromTestCase(
1560-+ RangesToSequenceTest
1561-+ ),
1562-+ unittest.defaultTestLoader.loadTestsFromTestCase(
1563-+ SequenceRangesTest
1564-+ ),
1565-+ unittest.defaultTestLoader.loadTestsFromTestCase(
1566-+ FindRangesIndexTest
1567-+ ),
1568-+ unittest.defaultTestLoader.loadTestsFromTestCase(AddToRangesTest),
1569-+ unittest.defaultTestLoader.loadTestsFromTestCase(
1570-+ RemoveFromRangesTest
1571-+ ),
1572- ),
1573- )
1574diff --git a/example.conf b/example.conf
1575index 81a5d36..c81e686 100644
1576--- a/example.conf
1577+++ b/example.conf
1578@@ -181,6 +181,7 @@ cloud = True
1579 # UbuntuProInfo - Ubuntu Pro registration information
1580 # LivePatch - Livepath status information
1581 # UbuntuProRebootRequired - informs if the system needs to be rebooted
1582+# ProManagement - allows for operations that manage pro
1583 #
1584 # The special value "ALL" is an alias for the entire list of plugins above and is the default.
1585 manager_plugins = ALL
1586@@ -190,6 +191,10 @@ manager_plugins = ALL
1587 # The ScriptExecution manager plugin is not enabled by default.
1588 # The following example would enable it.
1589 #include_manager_plugins = ScriptExecution
1590+#
1591+# The UsgManager manager plugin is not enabled by default.
1592+# The following example would enable it.
1593+#include_manager_plugins = UsgManager
1594
1595 # A comma-separated list of usernames that scripts can run as.
1596 #
1597@@ -214,3 +219,9 @@ manager_plugins = ALL
1598 # should match the uid assigned to the host machine.
1599 # For all other computers, do not set this parameter.
1600 #hostagent_uid = the-uid-of-the-host-machine
1601+
1602+# This parameter determines how many unknown package hashes client
1603+# will send to server at one time. The default is 500 and the maximum
1604+# is 2000. Note that increasing the value may result in higher CPU usage
1605+# by the client machine during this package reporting.
1606+#max_unknown_hashes_per_request = 500
1607diff --git a/integration_tests/__init__.py b/integration_tests/__init__.py
1608new file mode 100644
1609index 0000000..e69de29
1610--- /dev/null
1611+++ b/integration_tests/__init__.py
1612diff --git a/integration_tests/test_uaclient_integration.py b/integration_tests/test_uaclient_integration.py
1613new file mode 100644
1614index 0000000..0b91893
1615--- /dev/null
1616+++ b/integration_tests/test_uaclient_integration.py
1617@@ -0,0 +1,47 @@
1618+"""
1619+Integration tests for uaclient (Ubuntu Pro) interaction.
1620+
1621+These are not run when unit tests are run. They are ignored by the unit
1622+testrunner, twisted.trial, because they are pytest tests.
1623+"""
1624+
1625+import os
1626+
1627+import pytest
1628+
1629+from landscape.lib import uaclient
1630+
1631+
1632+@pytest.mark.integration
1633+def test_attach_status_detach_pro():
1634+ """
1635+ Attaches a pro token, checks the status, detaches it, then checks the
1636+ status again.
1637+ """
1638+ token = os.environ.get("TEST_PRO_TOKEN")
1639+
1640+ assert token is not None
1641+
1642+ uaclient.attach_pro(token)
1643+
1644+ pro_status = uaclient.get_pro_status()
1645+
1646+ assert "contract" in pro_status
1647+ contract = pro_status["contract"]
1648+
1649+ assert "products" in contract
1650+ products = contract["products"]
1651+
1652+ assert "free" in products
1653+
1654+ uaclient.detach_pro()
1655+
1656+ pro_status = uaclient.get_pro_status()
1657+
1658+ assert "contract" in pro_status
1659+ contract = pro_status["contract"]
1660+
1661+ assert "products" in contract
1662+ products = contract["products"]
1663+
1664+ assert products == []
1665diff --git a/landscape/__init__.py b/landscape/__init__.py
1666index 3faa180..e787b26 100644
1667--- a/landscape/__init__.py
1668+++ b/landscape/__init__.py
1669@@ -1,5 +1,6 @@
1670 DEBIAN_REVISION = ""
1671-UPSTREAM_VERSION = "24.12"
1672+UPSTREAM_VERSION = "26.02.1"
1673+PYTHON_VERSION = "26.02.1"
1674 VERSION = f"{UPSTREAM_VERSION}{DEBIAN_REVISION}"
1675
1676 # The minimum server API version that all Landscape servers are known to speak
1677diff --git a/landscape/client/amp.py b/landscape/client/amp.py
1678index 5b5d448..d608e64 100644
1679--- a/landscape/client/amp.py
1680+++ b/landscape/client/amp.py
1681@@ -10,12 +10,15 @@ This module implements a few conveniences built around L{landscape.lib.amp} to
1682 let the various services connect to each other in an easy and idiomatic way,
1683 and have them respond to standard requests like "ping" or "exit".
1684 """
1685+
1686 import logging
1687 import os
1688
1689-from landscape.lib.amp import MethodCallClientFactory
1690-from landscape.lib.amp import MethodCallServerFactory
1691-from landscape.lib.amp import RemoteObject
1692+from landscape.lib.amp import (
1693+ MethodCallClientFactory,
1694+ MethodCallServerFactory,
1695+ RemoteObject,
1696+)
1697
1698
1699 class ComponentPublisher:
1700diff --git a/landscape/client/attachments.py b/landscape/client/attachments.py
1701new file mode 100644
1702index 0000000..cf21f6e
1703--- /dev/null
1704+++ b/landscape/client/attachments.py
1705@@ -0,0 +1,65 @@
1706+import os
1707+
1708+from landscape import VERSION
1709+from landscape.client import GROUP, USER
1710+from landscape.lib.fetch import fetch_async
1711+from landscape.lib.persist import Persist
1712+
1713+
1714+async def save_attachments(
1715+ config,
1716+ attachments,
1717+ dest,
1718+ uid=None,
1719+ gid=None,
1720+) -> None:
1721+ """Downloads `attachments` from Landscape Server, writing them to `dest`.
1722+
1723+ :param config: The Landscape Client configuration.
1724+ :param attachments: The names and IDs of the attachments to download, an
1725+ iterable of pairs.
1726+ :param dest: The directory to write the downloaded attachments to.
1727+ :param uid: The user who should own the files.
1728+ :param gid: The group that should own the files.
1729+
1730+ :raises HTTPCodeError: If Server responds with an error HTTP code.
1731+ """
1732+ root_path = config.url.rsplit("/", 1)[0] + "/attachment/"
1733+ headers = {
1734+ "User-Agent": "landscape-client/" + VERSION,
1735+ "Content-Type": "application/octet-stream",
1736+ "X-Computer-ID": _get_secure_id(config),
1737+ }
1738+
1739+ for filename, attachment_id in attachments:
1740+ if isinstance(attachment_id, str):
1741+ # Backward-compatibility with inline attachments.
1742+ data = attachment_id.encode()
1743+ else:
1744+ data = await fetch_async(
1745+ root_path + str(attachment_id),
1746+ cainfo=config.ssl_public_key,
1747+ headers=headers,
1748+ )
1749+
1750+ full_filename = os.path.join(dest, filename)
1751+ with open(full_filename, "wb") as attachment:
1752+ attachment.write(data)
1753+
1754+ os.chmod(full_filename, 0o600)
1755+ if uid is not None:
1756+ os.chown(full_filename, uid, gid)
1757+
1758+
1759+def _get_secure_id(config) -> str:
1760+ """Retrieves the secure ID from the broker persistent storage."""
1761+ persist = Persist(
1762+ filename=os.path.join(config.data_path, "broker.bpickle"),
1763+ user=USER,
1764+ group=GROUP,
1765+ )
1766+
1767+ secure_id = persist.root_at("registration").get("secure-id")
1768+ secure_id = secure_id.decode("ascii")
1769+
1770+ return secure_id
1771diff --git a/landscape/client/broker/amp.py b/landscape/client/broker/amp.py
1772index 2278b08..5fab544 100644
1773--- a/landscape/client/broker/amp.py
1774+++ b/landscape/client/broker/amp.py
1775@@ -1,16 +1,11 @@
1776-from twisted.internet.defer import execute
1777-from twisted.internet.defer import maybeDeferred
1778-from twisted.internet.defer import succeed
1779-from twisted.python.compat import iteritems
1780+from twisted.internet.defer import execute, maybeDeferred, succeed
1781
1782-from landscape.client.amp import ComponentConnector
1783-from landscape.client.amp import get_remote_methods
1784+from landscape.client.amp import ComponentConnector, get_remote_methods
1785 from landscape.client.broker.client import BrokerClient
1786 from landscape.client.broker.server import BrokerServer
1787 from landscape.client.manager.manager import Manager
1788 from landscape.client.monitor.monitor import Monitor
1789-from landscape.lib.amp import MethodCallArgument
1790-from landscape.lib.amp import RemoteObject
1791+from landscape.lib.amp import MethodCallArgument, RemoteObject
1792
1793
1794 class RemoteBroker(RemoteObject):
1795@@ -60,7 +55,7 @@ class FakeRemoteBroker:
1796 def method(*args, **kwargs):
1797 for arg in args:
1798 assert MethodCallArgument.check(arg)
1799- for k, v in iteritems(kwargs):
1800+ for k, v in kwargs.items():
1801 assert MethodCallArgument.check(v)
1802 return execute(original, *args, **kwargs)
1803
1804@@ -121,6 +116,4 @@ def get_component_registry():
1805 RemoteMonitorConnector,
1806 RemoteManagerConnector,
1807 ]
1808- return {
1809- connector.component.name: connector for connector in all_connectors
1810- }
1811+ return {connector.component.name: connector for connector in all_connectors}
1812diff --git a/landscape/client/broker/client.py b/landscape/client/broker/client.py
1813index 5d3dd4a..47b9312 100644
1814--- a/landscape/client/broker/client.py
1815+++ b/landscape/client/broker/client.py
1816@@ -1,18 +1,18 @@
1817 import random
1818 import sys
1819 import traceback
1820-from logging import debug
1821-from logging import error
1822-from logging import exception
1823-from logging import info
1824+from logging import debug, error, exception, info
1825+from typing import TYPE_CHECKING
1826
1827-from twisted.internet.defer import maybeDeferred
1828-from twisted.internet.defer import succeed
1829+from twisted.internet.defer import maybeDeferred, succeed
1830
1831 from landscape.client.amp import remote
1832 from landscape.lib.format import format_object
1833 from landscape.lib.twisted_util import gather_results
1834
1835+if TYPE_CHECKING:
1836+ from landscape.client.broker.config import BrokerConfiguration
1837+
1838
1839 class HandlerNotFoundError(Exception):
1840 """A handler for the given message type was not found."""
1841@@ -44,7 +44,7 @@ class BrokerClientPlugin:
1842 _session_id = None
1843 _loop = None
1844
1845- def register(self, client):
1846+ def register(self, client: "BrokerClient"):
1847 self.client = client
1848 self.client.reactor.call_on("resynchronize", self._resynchronize)
1849 deferred = self.client.broker.get_session_id(scope=self.scope)
1850@@ -171,7 +171,7 @@ class BrokerClient:
1851
1852 name = "client"
1853
1854- def __init__(self, reactor, config):
1855+ def __init__(self, reactor, config: "BrokerConfiguration"):
1856 super().__init__()
1857 self.reactor = reactor
1858 self.broker = None
1859@@ -235,12 +235,15 @@ class BrokerClient:
1860 handler = self._registered_messages.get(typ)
1861 if handler is None:
1862 raise HandlerNotFoundError(typ)
1863- try:
1864- return handler(message)
1865- except Exception:
1866- exception(
1867- f"Error running message handler for type {typ!r}: {handler!r}",
1868- )
1869+
1870+ d = maybeDeferred(handler, message)
1871+ d.addErrback(self._error_log, typ, handler)
1872+ return d
1873+
1874+ def _error_log(self, _, typ, handler):
1875+ exception(
1876+ f"Error running message handler for type {typ!r}: {handler!r}",
1877+ )
1878
1879 @remote
1880 def message(self, message):
1881diff --git a/landscape/client/broker/config.py b/landscape/client/broker/config.py
1882index 2e7849f..609c45a 100644
1883--- a/landscape/client/broker/config.py
1884+++ b/landscape/client/broker/config.py
1885@@ -1,4 +1,5 @@
1886 """Configuration class for the broker."""
1887+
1888 import os
1889
1890 from landscape.client.deployment import Configuration
1891@@ -91,8 +92,7 @@ class BrokerConfiguration(Configuration):
1892 )
1893 parser.add_argument(
1894 "--tags",
1895- help="Comma separated list of tag names to be sent "
1896- "to the server.",
1897+ help="Comma separated list of tag names to be sent to the server.",
1898 )
1899 parser.add_argument(
1900 "--hostagent-uid",
1901@@ -107,6 +107,10 @@ class BrokerConfiguration(Configuration):
1902 "that Landscape assigned to the installation activity for the "
1903 "host machine.",
1904 )
1905+ parser.add_argument(
1906+ "--authenticated-attach-code",
1907+ help="A one-time-use, Landscape server-generated code.",
1908+ )
1909
1910 return parser
1911
1912diff --git a/landscape/client/broker/exchange.py b/landscape/client/broker/exchange.py
1913index b223472..4f27a80 100644
1914--- a/landscape/client/broker/exchange.py
1915+++ b/landscape/client/broker/exchange.py
1916@@ -342,25 +342,19 @@ Diagram::
1917 14. Schedule exchange
1918
1919 """
1920+
1921 import logging
1922 import time
1923
1924-from twisted.internet.defer import Deferred
1925-from twisted.internet.defer import succeed
1926+from twisted.internet.defer import Deferred, succeed
1927
1928-from landscape import CLIENT_API
1929-from landscape import DEFAULT_SERVER_API
1930-from landscape import SERVER_API
1931+from landscape import CLIENT_API, DEFAULT_SERVER_API, SERVER_API
1932 from landscape.lib.backoff import ExponentialBackoff
1933-from landscape.lib.compat import _PY3
1934-from landscape.lib.fetch import HTTPCodeError
1935-from landscape.lib.fetch import PyCurlError
1936+from landscape.lib.fetch import HTTPCodeError, PyCurlError
1937 from landscape.lib.format import format_delta
1938 from landscape.lib.hashlib import md5
1939-from landscape.lib.message import got_next_expected
1940-from landscape.lib.message import RESYNC
1941-from landscape.lib.versioning import is_version_higher
1942-from landscape.lib.versioning import sort_versions
1943+from landscape.lib.message import RESYNC, got_next_expected
1944+from landscape.lib.versioning import is_version_higher, sort_versions
1945
1946
1947 class MessageExchange:
1948@@ -422,6 +416,7 @@ class MessageExchange:
1949 self._exchange_store = exchange_store
1950 self._stopped = False
1951 self._backoff_counter = ExponentialBackoff(300, 7200) # 5 to 120 min
1952+ self._exchange_state = {}
1953
1954 self.register_message("accepted-types", self._handle_accepted_types)
1955 self.register_message("resynchronize", self._handle_resynchronize)
1956@@ -441,8 +436,7 @@ class MessageExchange:
1957 context = self._exchange_store.get_message_context(operation_id)
1958 if context is None:
1959 logging.warning(
1960- "No message context for message with "
1961- f"operation-id: {operation_id}",
1962+ f"No message context for message with operation-id: {operation_id}",
1963 )
1964 return False
1965
1966@@ -551,8 +545,7 @@ class MessageExchange:
1967 if "exchange" in message:
1968 self._config.exchange_interval = message["exchange"]
1969 logging.info(
1970- "Exchange interval set "
1971- f"to {self._config.exchange_interval:d} seconds.",
1972+ f"Exchange interval set to {self._config.exchange_interval:d} seconds.",
1973 )
1974 if "urgent-exchange" in message:
1975 self._config.urgent_exchange_interval = message["urgent-exchange"]
1976@@ -585,8 +578,7 @@ class MessageExchange:
1977 start_time = time.time()
1978 if self._urgent_exchange:
1979 logging.info(
1980- "Starting urgent message exchange "
1981- f"with {self._transport.get_url()}.",
1982+ f"Starting urgent message exchange with {self._transport.get_url()}.",
1983 )
1984 else:
1985 logging.info(
1986@@ -694,10 +686,7 @@ class MessageExchange:
1987 # It's a bit tricky to test as it is preventing rehooking 'exchange'
1988 # while there's a background thread doing the exchange itself.
1989 if not self._exchanging and (
1990- force
1991- or self._exchange_id is None
1992- or urgent
1993- and not self._urgent_exchange
1994+ force or self._exchange_id is None or urgent and not self._urgent_exchange
1995 ):
1996 if urgent:
1997 self._urgent_exchange = True
1998@@ -711,8 +700,7 @@ class MessageExchange:
1999 backoff_delay = self._backoff_counter.get_random_delay()
2000 if backoff_delay:
2001 logging.warning(
2002- "Server is busy. Backing off client for {} "
2003- "seconds".format(backoff_delay),
2004+ f"Server is busy. Backing off client for {backoff_delay} seconds",
2005 )
2006 interval += backoff_delay
2007
2008@@ -777,6 +765,24 @@ class MessageExchange:
2009 i = None
2010 if i is not None:
2011 del messages[i:]
2012+
2013+ for message in messages:
2014+ if message.get("type") == "fde-recovery-key" and message["successful"]:
2015+ if "recovery-key" in self._exchange_state:
2016+ message["recovery-key"] = self._exchange_state["recovery-key"]
2017+ logging.info(
2018+ "Added the recovery key to the FDE recovery key message."
2019+ )
2020+ else:
2021+ message["successful"] = False
2022+ message["result-text"] = (
2023+ "Landscape Client could not send the recovery key."
2024+ "Please regenerate it."
2025+ )
2026+ logging.info(
2027+ "Could not add the recovery key to the FDE recovery key"
2028+ "message."
2029+ )
2030 else:
2031 server_api = store.get_server_api()
2032 payload = {
2033@@ -859,7 +865,7 @@ class MessageExchange:
2034 # be 3.2, because it's the one that didn't have this field.
2035 server_api = result.get("server-api", b"3.2")
2036
2037- if _PY3 and not isinstance(server_api, bytes):
2038+ if not isinstance(server_api, bytes):
2039 # The "server-api" field in the bpickle payload sent by the server
2040 # is a string, however in Python 3 we need to convert it to bytes,
2041 # since that's what the rest of the code expects.
2042@@ -890,7 +896,11 @@ class MessageExchange:
2043 message_store.commit()
2044
2045 if message_store.get_pending_messages(1):
2046- logging.info("Pending messages remain after the last exchange.")
2047+ count = message_store.count_pending_messages()
2048+ logging.info(
2049+ "Pending messages remaining after the last exchange: %d",
2050+ count,
2051+ )
2052 # Either the server asked us for old messages, or we
2053 # otherwise have more messages even after transferring
2054 # what we could.
2055@@ -939,6 +949,9 @@ class MessageExchange:
2056 def get_client_accepted_message_types(self):
2057 return sorted(self._client_accepted_types)
2058
2059+ def update_exchange_state(self, key: str, value: str):
2060+ self._exchange_state[key] = value
2061+
2062
2063 def get_accepted_types_diff(old_types, new_types):
2064 old_types = set(old_types)
2065@@ -955,6 +968,6 @@ def get_accepted_types_diff(old_types, new_types):
2066
2067 def maybe_bytes(thing):
2068 """Return a py3 ascii string from maybe py2 bytes."""
2069- if _PY3 and isinstance(thing, bytes):
2070+ if isinstance(thing, bytes):
2071 return thing.decode("ascii")
2072 return thing
2073diff --git a/landscape/client/broker/exchangestore.py b/landscape/client/broker/exchangestore.py
2074index 4fb855a..20ac6d6 100644
2075--- a/landscape/client/broker/exchangestore.py
2076+++ b/landscape/client/broker/exchangestore.py
2077@@ -1,4 +1,5 @@
2078 """Provide access to the persistent data used by the L{MessageExchange}."""
2079+
2080 import time
2081
2082 try:
2083diff --git a/landscape/client/broker/ping.py b/landscape/client/broker/ping.py
2084index e96ab7d..9129b05 100644
2085--- a/landscape/client/broker/ping.py
2086+++ b/landscape/client/broker/ping.py
2087@@ -33,15 +33,15 @@ Diagram::
2088
2089 """
2090
2091+from logging import info
2092+
2093 try:
2094 from urllib.parse import urlencode
2095 except ImportError:
2096 from urllib import urlencode
2097
2098-from logging import info
2099-
2100-from twisted.python.failure import Failure
2101 from twisted.internet import defer
2102+from twisted.python.failure import Failure
2103
2104 from landscape.lib import bpickle
2105 from landscape.lib.fetch import fetch
2106@@ -158,8 +158,7 @@ class Pinger:
2107 def _got_result(self, exchange):
2108 if exchange:
2109 info(
2110- "Ping indicates message available. "
2111- "Scheduling an urgent exchange.",
2112+ "Ping indicates message available. Scheduling an urgent exchange.",
2113 )
2114 self._exchanger.schedule_exchange(urgent=True)
2115
2116@@ -181,8 +180,7 @@ class Pinger:
2117 self._config.ping_interval = message["ping"]
2118 self._config.write()
2119 info(
2120- f"Ping interval set to {self._config.ping_interval:d} "
2121- "seconds.",
2122+ f"Ping interval set to {self._config.ping_interval:d} seconds.",
2123 )
2124 if self._call_id is not None:
2125 self._reactor.cancel_call(self._call_id)
2126diff --git a/landscape/client/broker/registration.py b/landscape/client/broker/registration.py
2127index 3d76edc..78fe293 100644
2128--- a/landscape/client/broker/registration.py
2129+++ b/landscape/client/broker/registration.py
2130@@ -8,6 +8,7 @@ the machinery in this module will notice that we have no identification
2131 credentials yet and that the server accepts registration messages, so it
2132 will craft an appropriate one and send it out.
2133 """
2134+
2135 import json
2136 import logging
2137
2138@@ -19,8 +20,7 @@ from landscape.lib.juju import get_juju_info
2139 from landscape.lib.network import get_fqdn
2140 from landscape.lib.tag import is_valid_tag_list
2141 from landscape.lib.versioning import is_version_higher
2142-from landscape.lib.vm_info import get_container_info
2143-from landscape.lib.vm_info import get_vm_info
2144+from landscape.lib.vm_info import get_container_info, get_vm_info
2145
2146
2147 class RegistrationError(Exception):
2148@@ -80,6 +80,7 @@ class Identity:
2149 access_group = config_property("access_group")
2150 hostagent_uid = config_property("hostagent_uid")
2151 installation_request_id = config_property("installation_request_id")
2152+ authenticated_attach_code = config_property("authenticated_attach_code")
2153
2154 def __init__(self, config, persist):
2155 self._config = config
2156@@ -97,7 +98,7 @@ class RegistrationHandler:
2157 def __init__(
2158 self,
2159 config,
2160- identity,
2161+ identity: Identity,
2162 reactor,
2163 exchange,
2164 pinger,
2165@@ -196,6 +197,7 @@ class RegistrationHandler:
2166 registration_key = identity.registration_key
2167 hostagent_uid = identity.hostagent_uid
2168 installation_request_id = identity.installation_request_id
2169+ authenticated_attach_code = identity.authenticated_attach_code
2170
2171 self._message_store.delete_all_messages()
2172
2173@@ -227,6 +229,8 @@ class RegistrationHandler:
2174 message["hostagent_uid"] = hostagent_uid
2175 if installation_request_id:
2176 message["installation_request_id"] = installation_request_id
2177+ if authenticated_attach_code:
2178+ message["authenticated_attach_code"] = authenticated_attach_code
2179
2180 server_api = self._message_store.get_server_api()
2181 # If we have juju data to send and if the server is recent enough to
2182@@ -288,8 +292,7 @@ class RegistrationHandler:
2183 clone = message.get("clone-of")
2184 if clone is None:
2185 logging.info(
2186- "Client has unknown secure-id for account "
2187- f"{cid.account_name}.",
2188+ f"Client has unknown secure-id for account {cid.account_name}.",
2189 )
2190 else: # Save the secure id as the clone, and clear it so it's renewed
2191 logging.info(f"Client is clone of computer {clone}")
2192diff --git a/landscape/client/broker/server.py b/landscape/client/broker/server.py
2193index db0e7f6..cb1bcb7 100644
2194--- a/landscape/client/broker/server.py
2195+++ b/landscape/client/broker/server.py
2196@@ -42,13 +42,13 @@ Diagram::
2197 : exchange
2198
2199 """
2200+
2201 import logging
2202
2203 from twisted.internet.defer import Deferred
2204
2205 from landscape.client.amp import remote
2206 from landscape.client.manager.manager import FAILED
2207-from landscape.lib.compat import _PY3
2208 from landscape.lib.twisted_util import gather_results
2209
2210
2211@@ -202,7 +202,7 @@ class BrokerServer:
2212 during the next regularly scheduled exchange.
2213 @return: The message identifier created when queuing C{message}.
2214 """
2215- if b"type" in message and _PY3:
2216+ if b"type" in message:
2217 # XXX We are getting called by a python2 process.
2218 # This occurs in the the specific case of a landscape-driven
2219 # release upgrade to bionic, where the upgrading process is still
2220@@ -384,19 +384,16 @@ class BrokerServer:
2221 and opid is not None
2222 and message["type"] != "resynchronize"
2223 ):
2224-
2225 mtype = message["type"]
2226 logging.error(f"Nobody handled the {mtype} message.")
2227
2228- result_text = """\
2229-Landscape client failed to handle this request ({}) because the
2230+ result_text = f"""\
2231+Landscape client failed to handle this request ({mtype}) because the
2232 plugin which should handle it isn't available. This could mean that the
2233 plugin has been intentionally disabled, or that the client isn't running
2234 properly, or you may be running an older version of the client that doesn't
2235 support this feature.
2236-""".format(
2237- mtype,
2238- )
2239+"""
2240 response = {
2241 "type": "operation-result",
2242 "status": FAILED,
2243@@ -421,3 +418,8 @@ support this feature.
2244 """
2245 self._exchanger.stop()
2246 self._pinger.stop()
2247+
2248+ @remote
2249+ def update_exchange_state(self, key: str, value: str):
2250+ """Update the exchanger state for in-memory-only data."""
2251+ self._exchanger.update_exchange_state(key, value)
2252diff --git a/landscape/client/broker/service.py b/landscape/client/broker/service.py
2253index 9c86267..9c6a5de 100644
2254--- a/landscape/client/broker/service.py
2255+++ b/landscape/client/broker/service.py
2256@@ -1,4 +1,5 @@
2257 """Deployment code for the monitor."""
2258+
2259 import os
2260
2261 from landscape.client.amp import ComponentPublisher
2262@@ -6,13 +7,11 @@ from landscape.client.broker.config import BrokerConfiguration
2263 from landscape.client.broker.exchange import MessageExchange
2264 from landscape.client.broker.exchangestore import ExchangeStore
2265 from landscape.client.broker.ping import Pinger
2266-from landscape.client.broker.registration import Identity
2267-from landscape.client.broker.registration import RegistrationHandler
2268+from landscape.client.broker.registration import Identity, RegistrationHandler
2269 from landscape.client.broker.server import BrokerServer
2270 from landscape.client.broker.store import get_default_message_store
2271 from landscape.client.broker.transport import HTTPTransport
2272-from landscape.client.service import LandscapeService
2273-from landscape.client.service import run_landscape_service
2274+from landscape.client.service import LandscapeService, run_landscape_service
2275 from landscape.client.watchdog import bootstrap_list
2276
2277
2278diff --git a/landscape/client/broker/store.py b/landscape/client/broker/store.py
2279index ede2514..b56d2dc 100644
2280--- a/landscape/client/broker/store.py
2281+++ b/landscape/client/broker/store.py
2282@@ -91,6 +91,7 @@ See L{MessageStore} for details about how messages are stored on the file
2283 system and L{landscape.lib.message.got_next_expected} to check how the
2284 strategy for updating the pending offset and the sequence is implemented.
2285 """
2286+
2287 import itertools
2288 import logging
2289 import os
2290@@ -98,15 +99,10 @@ import shutil
2291 import traceback
2292 import uuid
2293
2294-from twisted.python.compat import iteritems
2295-
2296 from landscape import DEFAULT_SERVER_API
2297 from landscape.lib import bpickle
2298-from landscape.lib.fs import create_binary_file
2299-from landscape.lib.fs import read_binary_file
2300-from landscape.lib.versioning import is_version_higher
2301-from landscape.lib.versioning import sort_versions
2302-
2303+from landscape.lib.fs import create_binary_file, read_binary_file
2304+from landscape.lib.versioning import is_version_higher, sort_versions
2305
2306 HELD = "h"
2307 BROKEN = "b"
2308@@ -509,9 +505,7 @@ class MessageStore:
2309
2310 def _get_sorted_filenames(self, dir=""):
2311 message_files = [
2312- x
2313- for x in os.listdir(self._message_dir(dir))
2314- if not x.endswith(".tmp")
2315+ x for x in os.listdir(self._message_dir(dir)) if not x.endswith(".tmp")
2316 ]
2317 message_files.sort(key=lambda x: int(x.split("_")[0]))
2318 return message_files
2319@@ -578,7 +572,7 @@ class MessageStore:
2320 information, limited by scope.
2321 """
2322 session_ids = self._persist.get("session-ids", {})
2323- for session_id, stored_scope in iteritems(session_ids):
2324+ for session_id, stored_scope in session_ids.items():
2325 # This loop should be relatively short as it's intent is to limit
2326 # session-ids to one per scope. The or condition here is not
2327 # strictly necessary, but we *should* do "is" comparisons when we
2328@@ -602,7 +596,7 @@ class MessageStore:
2329 new_session_ids = {}
2330 if scopes:
2331 session_ids = self._persist.get("session-ids", {})
2332- for session_id, session_scope in iteritems(session_ids):
2333+ for session_id, session_scope in session_ids.items():
2334 if session_scope not in scopes:
2335 new_session_ids[session_id] = session_scope
2336 self._persist.set("session-ids", new_session_ids)
2337diff --git a/landscape/client/broker/tests/helpers.py b/landscape/client/broker/tests/helpers.py
2338index 2ce52b0..16aee04 100644
2339--- a/landscape/client/broker/tests/helpers.py
2340+++ b/landscape/client/broker/tests/helpers.py
2341@@ -5,6 +5,7 @@ dependencies. The lowest-level component is a L{BrokerConfiguration} instance,
2342 the highest-level ones are a full L{BrokerServer} exposed over AMP and
2343 connected to remote test L{BrokerClient}.
2344 """
2345+
2346 import os
2347
2348 from landscape.client.amp import ComponentPublisher
2349@@ -14,8 +15,7 @@ from landscape.client.broker.config import BrokerConfiguration
2350 from landscape.client.broker.exchange import MessageExchange
2351 from landscape.client.broker.exchangestore import ExchangeStore
2352 from landscape.client.broker.ping import Pinger
2353-from landscape.client.broker.registration import Identity
2354-from landscape.client.broker.registration import RegistrationHandler
2355+from landscape.client.broker.registration import Identity, RegistrationHandler
2356 from landscape.client.broker.server import BrokerServer
2357 from landscape.client.broker.store import get_default_message_store
2358 from landscape.client.broker.transport import FakeTransport
2359diff --git a/landscape/client/broker/tests/test_amp.py b/landscape/client/broker/tests/test_amp.py
2360index bc992b4..766b38d 100644
2361--- a/landscape/client/broker/tests/test_amp.py
2362+++ b/landscape/client/broker/tests/test_amp.py
2363@@ -1,14 +1,11 @@
2364 from unittest import mock
2365
2366-from landscape.client.broker.tests.helpers import RemoteBrokerHelper
2367-from landscape.client.broker.tests.helpers import RemoteClientHelper
2368-from landscape.client.tests.helpers import DEFAULT_ACCEPTED_TYPES
2369-from landscape.client.tests.helpers import LandscapeTest
2370+from landscape.client.broker.tests.helpers import RemoteBrokerHelper, RemoteClientHelper
2371+from landscape.client.tests.helpers import DEFAULT_ACCEPTED_TYPES, LandscapeTest
2372 from landscape.lib.amp import MethodCallError
2373
2374
2375 class RemoteBrokerTest(LandscapeTest):
2376-
2377 helpers = [RemoteBrokerHelper]
2378
2379 def test_ping(self):
2380@@ -216,7 +213,6 @@ class RemoteBrokerTest(LandscapeTest):
2381
2382
2383 class RemoteClientTest(LandscapeTest):
2384-
2385 helpers = [RemoteClientHelper]
2386
2387 def test_ping(self):
2388diff --git a/landscape/client/broker/tests/test_client.py b/landscape/client/broker/tests/test_client.py
2389index 219cc0c..8ae8d14 100644
2390--- a/landscape/client/broker/tests/test_client.py
2391+++ b/landscape/client/broker/tests/test_client.py
2392@@ -3,16 +3,13 @@ from unittest import mock
2393 from twisted.internet import reactor
2394 from twisted.internet.defer import Deferred
2395
2396-from landscape.client.broker.client import BrokerClientPlugin
2397-from landscape.client.broker.client import HandlerNotFoundError
2398+from landscape.client.broker.client import BrokerClientPlugin, HandlerNotFoundError
2399 from landscape.client.broker.tests.helpers import BrokerClientHelper
2400-from landscape.client.tests.helpers import DEFAULT_ACCEPTED_TYPES
2401-from landscape.client.tests.helpers import LandscapeTest
2402+from landscape.client.tests.helpers import DEFAULT_ACCEPTED_TYPES, LandscapeTest
2403 from landscape.lib.twisted_util import gather_results
2404
2405
2406 class BrokerClientTest(LandscapeTest):
2407-
2408 helpers = [BrokerClientHelper]
2409
2410 def test_ping(self):
2411@@ -166,7 +163,7 @@ class BrokerClientTest(LandscapeTest):
2412
2413 # At this point the plugin has already run once and has scheduled as
2414 # second run in plugin.run_interval seconds.
2415- self.assertEquals(runs, [True])
2416+ self.assertEqual(runs, [True])
2417
2418 # Mock out get_session_id so that it doesn't complete synchronously
2419 deferred = Deferred()
2420@@ -176,11 +173,11 @@ class BrokerClientTest(LandscapeTest):
2421 # The scheduled run has been cancelled, and even if plugin.run_interval
2422 # seconds elapse the plugin won't run again.
2423 self.client_reactor.advance(plugin.run_interval)
2424- self.assertEquals(runs, [True])
2425+ self.assertEqual(runs, [True])
2426
2427 # Finally get_session_id completes and the plugin runs again.
2428 deferred.callback(123)
2429- self.assertEquals(runs, [True, True])
2430+ self.assertEqual(runs, [True, True])
2431
2432 @mock.patch("random.random")
2433 def test_run_interval_staggered(self, mock_random):
2434@@ -238,7 +235,7 @@ class BrokerClientTest(LandscapeTest):
2435 handle_message = mock.Mock(return_value=123)
2436
2437 def dispatch_message(result):
2438- self.assertEqual(self.client.dispatch_message(message), 123)
2439+ self.assertEqual(self.client.dispatch_message(message).result, 123)
2440 handle_message.assert_called_once_with(message)
2441
2442 result = self.client.register_message("foo", handle_message)
2443@@ -254,16 +251,18 @@ class BrokerClientTest(LandscapeTest):
2444
2445 self.log_helper.ignore_errors("Error running message handler.*")
2446
2447- def dispatch_message(result):
2448- self.assertIs(self.client.dispatch_message(message), None)
2449+ def check(_):
2450 self.assertTrue(
2451 "Error running message handler for type 'foo'"
2452 in self.logfile.getvalue(),
2453 )
2454 handle_message.assert_called_once_with(message)
2455
2456- result = self.client.register_message("foo", handle_message)
2457- return result.addCallback(dispatch_message)
2458+ return (
2459+ self.client.register_message("foo", handle_message)
2460+ .addCallback(lambda _: self.client.dispatch_message(message))
2461+ .addCallback(check)
2462+ )
2463
2464 def test_dispatch_message_with_no_handler(self):
2465 """
2466diff --git a/landscape/client/broker/tests/test_config.py b/landscape/client/broker/tests/test_config.py
2467index e4d2656..f73a58e 100644
2468--- a/landscape/client/broker/tests/test_config.py
2469+++ b/landscape/client/broker/tests/test_config.py
2470@@ -6,7 +6,6 @@ from landscape.lib.testing import EnvironSaverHelper
2471
2472
2473 class ConfigurationTests(LandscapeTest):
2474-
2475 helpers = [EnvironSaverHelper]
2476
2477 def test_loading_sets_http_proxies(self):
2478@@ -188,3 +187,32 @@ class ConfigurationTests(LandscapeTest):
2479 configuration.load(["--config", filename, "--url", "whatever"])
2480
2481 self.assertIsNone(configuration.installation_request_id)
2482+
2483+ def test_authenticated_attach_code_handling(self):
2484+ """
2485+ The 'authenticated_attach_code' value specified in the configuration
2486+ file is passed through.
2487+ """
2488+ filename = self.makeFile(
2489+ "[client]\nauthenticated_attach_code = asdfqwerty1234supersecret",
2490+ )
2491+
2492+ configuration = BrokerConfiguration()
2493+ configuration.load(["--config", filename, "--url", "whatever"])
2494+
2495+ self.assertEqual(
2496+ configuration.authenticated_attach_code,
2497+ "asdfqwerty1234supersecret",
2498+ )
2499+
2500+ def test_missing_authenticated_attach_code_is_none(self):
2501+ """
2502+ Test that if we don't explicitly pass a authenticated_attach_code,
2503+ then this value is None.
2504+ """
2505+ filename = self.makeFile("[client]\n")
2506+
2507+ configuration = BrokerConfiguration()
2508+ configuration.load(["--config", filename, "--url", "whatever"])
2509+
2510+ self.assertIsNone(configuration.authenticated_attach_code)
2511diff --git a/landscape/client/broker/tests/test_exchange.py b/landscape/client/broker/tests/test_exchange.py
2512index dd396c3..9b33b7b 100644
2513--- a/landscape/client/broker/tests/test_exchange.py
2514+++ b/landscape/client/broker/tests/test_exchange.py
2515@@ -2,26 +2,23 @@ from unittest import mock
2516
2517 from landscape import CLIENT_API
2518 from landscape.client.broker.config import BrokerConfiguration
2519-from landscape.client.broker.exchange import get_accepted_types_diff
2520-from landscape.client.broker.exchange import MessageExchange
2521+from landscape.client.broker.exchange import MessageExchange, get_accepted_types_diff
2522 from landscape.client.broker.ping import Pinger
2523 from landscape.client.broker.registration import RegistrationHandler
2524 from landscape.client.broker.server import BrokerServer
2525 from landscape.client.broker.store import MessageStore
2526 from landscape.client.broker.tests.helpers import ExchangeHelper
2527 from landscape.client.broker.transport import FakeTransport
2528-from landscape.client.tests.helpers import DEFAULT_ACCEPTED_TYPES
2529-from landscape.client.tests.helpers import LandscapeTest
2530-from landscape.lib.fetch import HTTPCodeError
2531-from landscape.lib.fetch import PyCurlError
2532+from landscape.client.tests.helpers import DEFAULT_ACCEPTED_TYPES, LandscapeTest
2533+from landscape.lib.fetch import HTTPCodeError, PyCurlError
2534 from landscape.lib.hashlib import md5
2535 from landscape.lib.persist import Persist
2536 from landscape.lib.schema import Int
2537 from landscape.message_schemas.message import Message
2538+from landscape.message_schemas.server_bound import FDE_RECOVERY_KEY
2539
2540
2541 class MessageExchangeTest(LandscapeTest):
2542-
2543 helpers = [ExchangeHelper]
2544
2545 def setUp(self):
2546@@ -625,6 +622,33 @@ class MessageExchangeTest(LandscapeTest):
2547 ],
2548 )
2549
2550+ def test_count_pending_messages(self):
2551+ """
2552+ The number of pending messages is logged.
2553+ """
2554+ self.mstore.set_accepted_types(["empty", "resynchronize"])
2555+
2556+ def resynchronized(scopes=None):
2557+ self.mstore.add({"type": "empty"})
2558+
2559+ self.reactor.call_on("resynchronize-clients", resynchronized)
2560+
2561+ self.transport.responses.append(
2562+ [{"type": "resynchronize", "operation-id": 123}],
2563+ )
2564+ self.exchanger.exchange()
2565+ self.assertMessages(
2566+ self.mstore.get_pending_messages(),
2567+ [
2568+ {"type": "resynchronize", "operation-id": 123},
2569+ {"type": "empty"},
2570+ ],
2571+ )
2572+ self.assertIn(
2573+ "INFO: Pending messages remaining after the last exchange: 2",
2574+ self.logfile.getvalue(),
2575+ )
2576+
2577 def test_scopes_are_copied_from_incoming_resynchronize_messages(self):
2578 """
2579 If an incoming message of type 'reysnchronize' contains a 'scopes' key,
2580@@ -754,6 +778,93 @@ class MessageExchangeTest(LandscapeTest):
2581 self.assertEqual(payload.get("server-api"), b"1.1")
2582 self.assertEqual(self.transport.message_api, b"1.1")
2583
2584+ def test_fde_recovery_key_payload_with_recovery_key(self):
2585+ """
2586+ When sending a recovery key message to server, the exchanger should
2587+ add the recovery key from the in-memory exchange state to the message.
2588+ """
2589+ self.exchanger.update_exchange_state("recovery-key", "mykey")
2590+ self.mstore.set_accepted_types([FDE_RECOVERY_KEY.type])
2591+ self.mstore.add_schema(FDE_RECOVERY_KEY)
2592+
2593+ self.mstore.add(
2594+ {"type": FDE_RECOVERY_KEY.type, "operation-id": 1, "successful": True}
2595+ )
2596+ self.exchanger.exchange()
2597+ payload = self.transport.payloads[0]
2598+ self.assertMessages(
2599+ payload["messages"],
2600+ [
2601+ {
2602+ "type": "fde-recovery-key",
2603+ "operation-id": 1,
2604+ "successful": True,
2605+ "recovery-key": "mykey",
2606+ "api": b"3.2",
2607+ }
2608+ ],
2609+ )
2610+
2611+ def test_fde_recovery_key_payload_without_recovery_key(self):
2612+ """
2613+ When sending a recovery key message to server, if the in-memory exchange
2614+ state does not have the recovery key, the exchanger should update the message.
2615+ """
2616+ self.mstore.set_accepted_types([FDE_RECOVERY_KEY.type])
2617+ self.mstore.add_schema(FDE_RECOVERY_KEY)
2618+
2619+ self.mstore.add(
2620+ {"type": FDE_RECOVERY_KEY.type, "operation-id": 1, "successful": True}
2621+ )
2622+ self.exchanger.exchange()
2623+
2624+ payload = self.transport.payloads[0]
2625+ self.assertMessages(
2626+ payload["messages"],
2627+ [
2628+ {
2629+ "type": "fde-recovery-key",
2630+ "operation-id": 1,
2631+ "successful": False,
2632+ "result-text": "Landscape Client could not send the recovery key."
2633+ "Please regenerate it.",
2634+ "api": b"3.2",
2635+ }
2636+ ],
2637+ )
2638+
2639+ def test_fde_recovery_key_payload_with_failure(self):
2640+ """
2641+ When sending a recovery key message to server, if the attempt to update
2642+ the recovery key failed, the exchanger should not update the message.
2643+ """
2644+ self.mstore.set_accepted_types([FDE_RECOVERY_KEY.type])
2645+ self.mstore.add_schema(FDE_RECOVERY_KEY)
2646+
2647+ self.mstore.add(
2648+ {
2649+ "type": FDE_RECOVERY_KEY.type,
2650+ "operation-id": 1,
2651+ "successful": False,
2652+ "result-text": "Some Failure",
2653+ }
2654+ )
2655+ self.exchanger.exchange()
2656+
2657+ payload = self.transport.payloads[0]
2658+ self.assertMessages(
2659+ payload["messages"],
2660+ [
2661+ {
2662+ "type": "fde-recovery-key",
2663+ "operation-id": 1,
2664+ "successful": False,
2665+ "result-text": "Some Failure",
2666+ "api": b"3.2",
2667+ }
2668+ ],
2669+ )
2670+
2671 def test_exchange_token(self):
2672 """
2673 When sending messages to the server, the exchanger provides the
2674@@ -1381,7 +1492,6 @@ class MessageExchangeTest(LandscapeTest):
2675
2676
2677 class AcceptedTypesMessageExchangeTest(LandscapeTest):
2678-
2679 helpers = [ExchangeHelper]
2680
2681 def setUp(self):
2682@@ -1428,9 +1538,7 @@ class AcceptedTypesMessageExchangeTest(LandscapeTest):
2683 self.exchanger.register_client_accepted_message_type("type-B")
2684 types = sorted(["type-A", "type-B"] + DEFAULT_ACCEPTED_TYPES)
2685 accepted_types_digest = md5(";".join(types).encode("ascii")).digest()
2686- self.transport.extra[
2687- "client-accepted-types-hash"
2688- ] = accepted_types_digest
2689+ self.transport.extra["client-accepted-types-hash"] = accepted_types_digest
2690 self.exchanger.exchange()
2691 self.exchanger.exchange()
2692 self.assertNotIn("client-accepted-types", self.transport.payloads[1])
2693diff --git a/landscape/client/broker/tests/test_exchangestore.py b/landscape/client/broker/tests/test_exchangestore.py
2694index 76a7d9f..8dd56b4 100644
2695--- a/landscape/client/broker/tests/test_exchangestore.py
2696+++ b/landscape/client/broker/tests/test_exchangestore.py
2697@@ -5,9 +5,8 @@ try:
2698 except ImportError:
2699 from pysqlite2 import dbapi2 as sqlite3
2700
2701-from landscape.client.tests.helpers import LandscapeTest
2702-
2703 from landscape.client.broker.exchangestore import ExchangeStore
2704+from landscape.client.tests.helpers import LandscapeTest
2705
2706
2707 class ExchangeStoreTest(LandscapeTest):
2708diff --git a/landscape/client/broker/tests/test_ping.py b/landscape/client/broker/tests/test_ping.py
2709index fe08dee..d9ec90e 100644
2710--- a/landscape/client/broker/tests/test_ping.py
2711+++ b/landscape/client/broker/tests/test_ping.py
2712@@ -1,7 +1,6 @@
2713 from twisted.internet.defer import fail
2714
2715-from landscape.client.broker.ping import PingClient
2716-from landscape.client.broker.ping import Pinger
2717+from landscape.client.broker.ping import PingClient, Pinger
2718 from landscape.client.broker.tests.helpers import ExchangeHelper
2719 from landscape.client.tests.helpers import LandscapeTest
2720 from landscape.lib import bpickle
2721@@ -114,7 +113,6 @@ class PingClientTest(LandscapeTest):
2722
2723
2724 class PingerTest(LandscapeTest):
2725-
2726 helpers = [ExchangeHelper]
2727
2728 # Tell the Plugin helper to not add a MessageExchange plugin, to interfere
2729diff --git a/landscape/client/broker/tests/test_registration.py b/landscape/client/broker/tests/test_registration.py
2730index 06b312b..48b75fd 100644
2731--- a/landscape/client/broker/tests/test_registration.py
2732+++ b/landscape/client/broker/tests/test_registration.py
2733@@ -3,17 +3,16 @@ import logging
2734 import socket
2735 from unittest import mock
2736
2737-from landscape.client.broker.registration import Identity
2738-from landscape.client.broker.registration import RegistrationError
2739-from landscape.client.broker.tests.helpers import BrokerConfigurationHelper
2740-from landscape.client.broker.tests.helpers import RegistrationHelper
2741+from landscape.client.broker.registration import Identity, RegistrationError
2742+from landscape.client.broker.tests.helpers import (
2743+ BrokerConfigurationHelper,
2744+ RegistrationHelper,
2745+)
2746 from landscape.client.tests.helpers import LandscapeTest
2747-from landscape.lib.compat import _PY3
2748 from landscape.lib.persist import Persist
2749
2750
2751 class IdentityTest(LandscapeTest):
2752-
2753 helpers = [BrokerConfigurationHelper]
2754
2755 def setUp(self):
2756@@ -83,7 +82,6 @@ class IdentityTest(LandscapeTest):
2757
2758
2759 class RegistrationHandlerTestBase(LandscapeTest):
2760-
2761 helpers = [RegistrationHelper]
2762
2763 def setUp(self):
2764@@ -328,14 +326,8 @@ class RegistrationHandlerTest(RegistrationHandlerTestBase):
2765 self.assertEqual(expected, messages[0]["tags"])
2766
2767 logs = self.logfile.getvalue().strip()
2768- # XXX This is not nice, as it has the origin in a non-consistent way of
2769- # using logging. self.logfile is a cStringIO in Python 2 and
2770- # io.StringIO in Python 3. This results in reading bytes in Python 2
2771- # and unicode in Python 3, but a drop-in replacement of cStringIO with
2772- # io.StringIO in Python 2 is not working. However, we compare bytes
2773- # here, to circumvent that problem.
2774- if _PY3:
2775- logs = logs.encode("utf-8")
2776+ logs = logs.encode("utf-8")
2777+
2778 self.assertEqual(
2779 logs,
2780 b"INFO: Queueing message to register with account "
2781@@ -468,6 +460,47 @@ class RegistrationHandlerTest(RegistrationHandlerTestBase):
2782 messages = self.mstore.get_pending_messages()
2783 self.assertNotIn("installation_request_id", messages[0])
2784
2785+ def test_queue_message_on_exchange_with_authenticated_attach_code(self):
2786+ """
2787+ If the admin has defined a authenticated_attach_code for this
2788+ computer, we send it to the server.
2789+ """
2790+ self.mstore.set_accepted_types(["register"])
2791+ self.mstore.set_server_api(b"3.3")
2792+ self.config.account_name = "account_name"
2793+ self.config.authenticated_attach_code = "hushhushsupersecretcode"
2794+ self.config.tags = "server,london"
2795+ self.reactor.fire("pre-exchange")
2796+ messages = self.mstore.get_pending_messages()
2797+ self.assertEqual(
2798+ "hushhushsupersecretcode",
2799+ messages[0]["authenticated_attach_code"],
2800+ )
2801+
2802+ def test_queue_message_on_exchange_empty_authenticated_attach_code(self):
2803+ """
2804+ If the authenticated_attach_code is "", then the outgoing message
2805+ does not define an "authenticated_attach_code" key.
2806+ """
2807+ self.mstore.set_accepted_types(["register"])
2808+ self.mstore.set_server_api(b"3.3")
2809+ self.config.authenticated_attach_code = ""
2810+ self.reactor.fire("pre-exchange")
2811+ messages = self.mstore.get_pending_messages()
2812+ self.assertNotIn("authenticated_attach_code", messages[0])
2813+
2814+ def test_queue_message_on_exchange_none_authenticated_attach_code(self):
2815+ """
2816+ If the authenticated_attach_code is None, then the outgoing message
2817+ does not define an "authenticated_attach_code" key.
2818+ """
2819+ self.mstore.set_accepted_types(["register"])
2820+ self.mstore.set_server_api(b"3.3")
2821+ self.config.authenticated_attach_code = None
2822+ self.reactor.fire("pre-exchange")
2823+ messages = self.mstore.get_pending_messages()
2824+ self.assertNotIn("authenticated_attach_code", messages[0])
2825+
2826 def test_queueing_registration_message_resets_message_store(self):
2827 """
2828 When a registration message is queued, the store is reset
2829@@ -736,7 +769,6 @@ class RegistrationHandlerTest(RegistrationHandlerTestBase):
2830
2831
2832 class JujuRegistrationHandlerTest(RegistrationHandlerTestBase):
2833-
2834 juju_contents = json.dumps(
2835 {
2836 "environment-uuid": "DEAD-BEEF",
2837diff --git a/landscape/client/broker/tests/test_server.py b/landscape/client/broker/tests/test_server.py
2838index 13461eb..9a962a5 100644
2839--- a/landscape/client/broker/tests/test_server.py
2840+++ b/landscape/client/broker/tests/test_server.py
2841@@ -2,15 +2,12 @@ import random
2842 from unittest.mock import Mock
2843
2844 from configobj import ConfigObj
2845-from twisted.internet.defer import fail
2846-from twisted.internet.defer import succeed
2847+from twisted.internet.defer import fail, succeed
2848
2849-from landscape.client.broker.tests.helpers import BrokerServerHelper
2850-from landscape.client.broker.tests.helpers import RemoteClientHelper
2851+from landscape.client.broker.tests.helpers import BrokerServerHelper, RemoteClientHelper
2852 from landscape.client.broker.tests.test_ping import FakePageGetter
2853 from landscape.client.manager.manager import FAILED
2854-from landscape.client.tests.helpers import DEFAULT_ACCEPTED_TYPES
2855-from landscape.client.tests.helpers import LandscapeTest
2856+from landscape.client.tests.helpers import DEFAULT_ACCEPTED_TYPES, LandscapeTest
2857
2858
2859 class FakeClient:
2860@@ -26,7 +23,6 @@ class FakeCreator:
2861
2862
2863 class BrokerServerTest(LandscapeTest):
2864-
2865 helpers = [BrokerServerHelper]
2866
2867 def test_ping(self):
2868@@ -60,7 +56,6 @@ class BrokerServerTest(LandscapeTest):
2869 self.assertNotEqual(disk_session_id1, users_session_id)
2870
2871 def test_send_message(self):
2872-
2873 """
2874 The L{BrokerServer.send_message} method forwards a message to the
2875 broker's exchanger.
2876@@ -447,9 +442,19 @@ class BrokerServerTest(LandscapeTest):
2877 self.reactor.advance(self.config.exchange_interval)
2878 self.assertEqual([], page_getter.fetches)
2879
2880+ def test_update_exchange_state(self):
2881+ """
2882+ The L{BrokerServer.update_exchange_state} updates the exchanger's
2883+ in-memory data store.
2884+ """
2885+ self.assertEqual(self.exchanger._exchange_state, {})
2886+ self.broker.update_exchange_state("new_key", "new_value")
2887+
2888+ self.reactor.advance(self.config.exchange_interval)
2889+ self.assertEqual(self.exchanger._exchange_state["new_key"], "new_value")
2890
2891-class EventTest(LandscapeTest):
2892
2893+class EventTest(LandscapeTest):
2894 helpers = [RemoteClientHelper]
2895
2896 def test_resynchronize(self):
2897@@ -558,7 +563,6 @@ class EventTest(LandscapeTest):
2898
2899
2900 class HandlersTest(LandscapeTest):
2901-
2902 helpers = [BrokerServerHelper]
2903
2904 def setUp(self):
2905diff --git a/landscape/client/broker/tests/test_service.py b/landscape/client/broker/tests/test_service.py
2906index e99f9ac..04dfbc4 100644
2907--- a/landscape/client/broker/tests/test_service.py
2908+++ b/landscape/client/broker/tests/test_service.py
2909@@ -10,7 +10,6 @@ from landscape.lib.testing import FakeReactor
2910
2911
2912 class BrokerServiceTest(LandscapeTest):
2913-
2914 helpers = [BrokerConfigurationHelper]
2915
2916 def setUp(self):
2917diff --git a/landscape/client/broker/tests/test_store.py b/landscape/client/broker/tests/test_store.py
2918index 5dd2fbb..c70fbca 100644
2919--- a/landscape/client/broker/tests/test_store.py
2920+++ b/landscape/client/broker/tests/test_store.py
2921@@ -1,19 +1,18 @@
2922 import os
2923 from unittest import mock
2924
2925-from twisted.python.compat import intToBytes
2926-
2927 from landscape.client.broker.store import MessageStore
2928 from landscape.client.tests.helpers import LandscapeTest
2929 from landscape.lib.bpickle import dumps
2930 from landscape.lib.persist import Persist
2931-from landscape.lib.schema import Bytes
2932-from landscape.lib.schema import Int
2933-from landscape.lib.schema import InvalidError
2934-from landscape.lib.schema import Unicode
2935+from landscape.lib.schema import Bytes, Int, InvalidError, Unicode
2936 from landscape.message_schemas.message import Message
2937
2938
2939+def intToBytes(n):
2940+ return b"%d" % n
2941+
2942+
2943 class MessageStoreTest(LandscapeTest):
2944 def setUp(self):
2945 super().setUp()
2946@@ -194,7 +193,7 @@ class MessageStoreTest(LandscapeTest):
2947 If an exception occurs while deleting it shouldn't affect the next
2948 message sent
2949 """
2950- rmtree_mock.side_effect = IOError("Error!")
2951+ rmtree_mock.side_effect = OSError("Error!")
2952 self.store._directory_size = 1
2953 self.store._max_dirs = 1
2954 self.store.add({"type": "data", "data": b"a"})
2955@@ -385,7 +384,7 @@ class MessageStoreTest(LandscapeTest):
2956 # writing a file.
2957 mock_open = mock.mock_open()
2958 with mock.patch("landscape.lib.fs.open", mock_open):
2959- mock_open().write.side_effect = IOError("Sorry, pal!")
2960+ mock_open().write.side_effect = OSError("Sorry, pal!")
2961 # This kind of ensures that raising an exception is somewhat
2962 # similar to unplugging the power -- i.e., we're not relying
2963 # on special exception-handling in the file-writing code.
2964diff --git a/landscape/client/broker/tests/test_transport.py b/landscape/client/broker/tests/test_transport.py
2965index 3baf743..2451400 100644
2966--- a/landscape/client/broker/tests/test_transport.py
2967+++ b/landscape/client/broker/tests/test_transport.py
2968@@ -3,8 +3,7 @@ import os
2969 from twisted.internet import reactor
2970 from twisted.internet.ssl import DefaultOpenSSLContextFactory
2971 from twisted.internet.threads import deferToThread
2972-from twisted.web import resource
2973-from twisted.web import server
2974+from twisted.web import resource, server
2975
2976 from landscape import VERSION
2977 from landscape.client.broker.transport import HTTPTransport
2978@@ -25,7 +24,6 @@ BADPUBKEY = sibpath("badpublic.ssl")
2979
2980
2981 class DataCollectingResource(resource.Resource):
2982-
2983 request = content = None
2984
2985 def getChild(self, request, name): # noqa: N802
2986@@ -38,7 +36,6 @@ class DataCollectingResource(resource.Resource):
2987
2988
2989 class HTTPTransportTest(LandscapeTest):
2990-
2991 helpers = [LogKeeperHelper]
2992
2993 def setUp(self):
2994diff --git a/landscape/client/broker/transport.py b/landscape/client/broker/transport.py
2995index 05ad086..c0c5045 100644
2996--- a/landscape/client/broker/transport.py
2997+++ b/landscape/client/broker/transport.py
2998@@ -1,12 +1,10 @@
2999 """Low-level server communication."""
3000-from dataclasses import asdict
3001+
3002 import uuid
3003-from typing import Optional
3004-from typing import Union
3005+from dataclasses import asdict
3006
3007 from landscape import SERVER_API
3008 from landscape.client.exchange import exchange_messages
3009-from landscape.lib.compat import unicode
3010
3011
3012 class HTTPTransport:
3013@@ -32,10 +30,10 @@ class HTTPTransport:
3014 def exchange(
3015 self,
3016 payload: dict,
3017- computer_id: Optional[str] = None,
3018- exchange_token: Optional[bytes] = None,
3019+ computer_id: str | None = None,
3020+ exchange_token: bytes | None = None,
3021 message_api: bytes = SERVER_API,
3022- ) -> Union[dict, None]:
3023+ ) -> dict | None:
3024 """Exchange message data with the server.
3025
3026 :param payload: The object to send. It must be `bpickle`-compatible.
3027@@ -65,9 +63,7 @@ class HTTPTransport:
3028 # in landscape.client.broker.exchange.MessageExchange.
3029 return asdict(
3030 response,
3031- dict_factory=lambda data: {
3032- k.replace("_", "-"): v for k, v in data
3033- },
3034+ dict_factory=lambda data: {k.replace("_", "-"): v for k, v in data},
3035 )
3036
3037
3038@@ -117,7 +113,7 @@ class FakeTransport:
3039
3040 result = {
3041 "next-expected-sequence": self.next_expected_sequence,
3042- "next-exchange-token": unicode(uuid.uuid4()),
3043+ "next-exchange-token": str(uuid.uuid4()),
3044 "messages": response,
3045 }
3046 result.update(self.extra)
3047diff --git a/landscape/client/configuration.py b/landscape/client/configuration.py
3048index 8d3a7ce..c82dd37 100644
3049--- a/landscape/client/configuration.py
3050+++ b/landscape/client/configuration.py
3051@@ -3,8 +3,11 @@
3052 This module, and specifically L{LandscapeSetupScript}, implements the support
3053 for the C{landscape-config} script.
3054 """
3055+
3056+import base64
3057 import getpass
3058 import io
3059+import json
3060 import logging
3061 import os
3062 import pwd
3063@@ -12,32 +15,27 @@ import re
3064 import shlex
3065 import sys
3066 import textwrap
3067+from argparse import SUPPRESS
3068 from urllib.parse import urlparse
3069
3070-from landscape.client import GROUP
3071-from landscape.client import IS_SNAP
3072-from landscape.client import USER
3073+from landscape.client import GROUP, IS_SNAP, USER
3074 from landscape.client.broker.config import BrokerConfiguration
3075 from landscape.client.broker.registration import Identity
3076 from landscape.client.broker.service import BrokerService
3077-from landscape.client.registration import ClientRegistrationInfo
3078-from landscape.client.registration import register
3079-from landscape.client.registration import RegistrationException
3080-from landscape.client.serviceconfig import ServiceConfig
3081-from landscape.client.serviceconfig import ServiceConfigException
3082-from landscape.lib import base64
3083-from landscape.lib.bootstrap import BootstrapDirectory
3084-from landscape.lib.bootstrap import BootstrapList
3085-from landscape.lib.compat import input
3086-from landscape.lib.fetch import fetch
3087-from landscape.lib.fetch import FetchError
3088+from landscape.client.registration import (
3089+ ClientRegistrationInfo,
3090+ RegistrationException,
3091+ register,
3092+)
3093+from landscape.client.serviceconfig import ServiceConfig, ServiceConfigException
3094+from landscape.lib.bootstrap import BootstrapDirectory, BootstrapList
3095+from landscape.lib.fetch import FetchError, fetch
3096 from landscape.lib.fs import create_binary_file
3097 from landscape.lib.logging import init_app_logging
3098 from landscape.lib.network import get_fqdn
3099 from landscape.lib.persist import Persist
3100 from landscape.lib.tag import is_valid_tag
3101
3102-
3103 EXIT_NOT_REGISTERED = 5
3104
3105
3106@@ -104,7 +102,7 @@ def get_invalid_users(users):
3107
3108
3109 class LandscapeSetupConfiguration(BrokerConfiguration):
3110-
3111+ # Whether or not config option will be written back to config file
3112 unsaved_options = (
3113 "no_start",
3114 "disable",
3115@@ -114,6 +112,34 @@ class LandscapeSetupConfiguration(BrokerConfiguration):
3116 "skip_registration",
3117 "force_registration",
3118 "register_if_needed",
3119+ "is_registered",
3120+ "actively_registered",
3121+ "registration_sent",
3122+ "show",
3123+ "show_json",
3124+ )
3125+
3126+ # Whether or not config option will be shown in config dumps
3127+ hidden_options = (
3128+ "no_start",
3129+ "disable",
3130+ "silent",
3131+ "ok_no_register",
3132+ "import_from",
3133+ "skip_registration",
3134+ "force_registration",
3135+ "register_if_needed",
3136+ "clones",
3137+ "start_clones_over",
3138+ "hostagent_uid",
3139+ "installation_request_id",
3140+ "authenticated_attach_code",
3141+ "init",
3142+ "is_registered",
3143+ "actively_registered",
3144+ "registration_sent",
3145+ "show",
3146+ "show_json",
3147 )
3148
3149 encoding = "utf-8"
3150@@ -152,8 +178,7 @@ class LandscapeSetupConfiguration(BrokerConfiguration):
3151 )
3152 except Exception:
3153 raise ImportOptionError(
3154- "Couldn't read configuration "
3155- f"from {self.import_from}.",
3156+ f"Couldn't read configuration from {self.import_from}.",
3157 )
3158 except Exception as error:
3159 raise ImportOptionError(str(error))
3160@@ -230,8 +255,7 @@ class LandscapeSetupConfiguration(BrokerConfiguration):
3161 parser.add_argument(
3162 "--ok-no-register",
3163 action="store_true",
3164- help="Return exit code 0 instead of 2 if the client "
3165- "can't be registered.",
3166+ help="Return exit code 0 instead of 2 if the client can't be registered.",
3167 )
3168 parser.add_argument(
3169 "--silent",
3170@@ -255,8 +279,8 @@ class LandscapeSetupConfiguration(BrokerConfiguration):
3171 "--is-registered",
3172 action="store_true",
3173 help="Exit with code 0 (success) if client is "
3174- "registered else returns {}. Displays "
3175- "registration info.".format(EXIT_NOT_REGISTERED),
3176+ f"registered else returns {EXIT_NOT_REGISTERED}. Displays "
3177+ "registration info.",
3178 )
3179 parser.add_argument(
3180 "--skip-registration",
3181@@ -271,23 +295,31 @@ class LandscapeSetupConfiguration(BrokerConfiguration):
3182 parser.add_argument(
3183 "--register-if-needed",
3184 action="store_true",
3185- help=(
3186- "Send a new registration request only if one has not been sent"
3187- ),
3188+ help=("Send a new registration request only if one has not been sent"),
3189 )
3190 parser.add_argument(
3191 "--actively-registered",
3192 action="store_true",
3193 help="Exit with code 0 (success) if client is "
3194- "registered else returns {}. Displays "
3195- "registration info.".format(EXIT_NOT_REGISTERED),
3196+ f"registered else returns {EXIT_NOT_REGISTERED}. Displays "
3197+ "registration info.",
3198 )
3199 parser.add_argument(
3200 "--registration-sent",
3201 action="store_true",
3202 help="Exit with code 0 (success) if client is "
3203- "registered else returns {}. Displays "
3204- "registration info.".format(EXIT_NOT_REGISTERED),
3205+ f"registered else returns {EXIT_NOT_REGISTERED}. Displays "
3206+ "registration info.",
3207+ )
3208+ parser.add_argument(
3209+ "--show",
3210+ action="store_true",
3211+ help="Outputs all configuration data as plain text.",
3212+ )
3213+ parser.add_argument(
3214+ "--show-json",
3215+ action="store_true",
3216+ help="Outputs all configuration data as JSON.",
3217 )
3218 return parser
3219
3220@@ -468,8 +500,7 @@ class LandscapeSetupScript:
3221 return # an access group is already provided, don't ask for one
3222
3223 show_help(
3224- "You may provide an access group for this computer "
3225- "e.g. webservers.",
3226+ "You may provide an access group for this computer e.g. webservers.",
3227 )
3228 self.prompt("access_group", "Access group", False)
3229
3230@@ -497,8 +528,7 @@ class LandscapeSetupScript:
3231
3232 def query_landscape_edition(self):
3233 show_help(
3234- "Manage this machine with Landscape "
3235- "(https://ubuntu.com/landscape):\n",
3236+ "Manage this machine with Landscape (https://ubuntu.com/landscape):\n",
3237 )
3238 options = self.config.get_command_line_options()
3239 if "ping_url" in options and "url" in options:
3240@@ -525,15 +555,12 @@ class LandscapeSetupScript:
3241 self.config.url = f"https://{self.landscape_domain}/message-system"
3242 else:
3243 self.landscape_domain = ""
3244- self.config.ping_url = self.config._command_line_defaults[
3245- "ping_url"
3246- ]
3247+ self.config.ping_url = self.config._command_line_defaults["ping_url"]
3248 self.config.url = self.config._command_line_defaults["url"]
3249 if self.config.account_name == "standalone":
3250 self.config.account_name = ""
3251
3252 def show_summary(self):
3253-
3254 tx = f"""A summary of the provided information:
3255 Computer's Title: {self.config.computer_title}
3256 Account Name: {self.config.account_name}
3257@@ -665,9 +692,7 @@ def setup(config) -> Identity:
3258 bootstrap_tree(config)
3259
3260 if not config.no_start:
3261- if config.silent:
3262- ServiceConfig.set_start_on_boot(True)
3263- elif not ServiceConfig.is_configured_to_run():
3264+ if config.silent or not ServiceConfig.is_configured_to_run():
3265 ServiceConfig.set_start_on_boot(True)
3266
3267 setup_http_proxy(config)
3268@@ -796,7 +821,7 @@ def registration_sent(config):
3269 Return whether the client has sent a registration request to the server.
3270 For now does same thing as is_registered as to make function name more
3271 clear with what is performed. This is the legacy behaviour of
3272- --is-registered and the name will be changed in a future release.
3273+ --is-registered and the name will be removed in the 26.04 release.
3274 """
3275 persist_filename = os.path.join(
3276 config.data_path,
3277@@ -829,14 +854,10 @@ def registration_info_text(config, registration_status):
3278 config_path = os.path.abspath(config._config_filename)
3279
3280 text = textwrap.dedent(
3281- """
3282- Registered: {}
3283- Config Path: {}
3284- Data Path {}""".format(
3285- registration_status,
3286- config_path,
3287- config.data_path,
3288- ),
3289+ f"""
3290+ Registered: {registration_status}
3291+ Config Path: {config_path}
3292+ Data Path {config.data_path}""",
3293 )
3294 if registration_status:
3295 text += f"\nAccount Name: {config.account_name}"
3296@@ -844,6 +865,55 @@ def registration_info_text(config, registration_status):
3297 return text
3298
3299
3300+def get_configuration_dump(config):
3301+ """
3302+ Return a dict mapping all configuration options to their current value
3303+ """
3304+ conf_dump = {}
3305+
3306+ # Add option for config file separately
3307+ conf_dump["CONFIG_FILE"] = config._config_filename
3308+
3309+ for conf_option in config._command_line_defaults:
3310+ conf_value = config.get(conf_option)
3311+ if (
3312+ conf_value != SUPPRESS
3313+ and conf_option != "config"
3314+ and conf_option not in LandscapeSetupConfiguration.hidden_options
3315+ ):
3316+ conf_dump[conf_option] = conf_value
3317+
3318+ # Add config file options without a command line default
3319+ for conf_option in config._config_file_options:
3320+ if conf_option not in conf_dump:
3321+ conf_value = config.get(conf_option)
3322+ conf_dump[conf_option] = conf_value
3323+
3324+ return conf_dump
3325+
3326+
3327+def configuration_dump_text(config):
3328+ """
3329+ Return a mapping of all available configuration options
3330+ and their current values organized alphabetically in plain text
3331+ """
3332+ text = ""
3333+ conf_dump = get_configuration_dump(config)
3334+ conf_sorted = sorted(conf_dump.items())
3335+ for key, value in conf_sorted:
3336+ text += f"\n{key}: {value}"
3337+ return text
3338+
3339+
3340+def configuration_dump_json(config):
3341+ """
3342+ Return a mapping of all available configuration options
3343+ and their current values organized alphabetically in JSON
3344+ """
3345+ conf_dump = get_configuration_dump(config)
3346+ return json.dumps(conf_dump)
3347+
3348+
3349 def set_secure_id(config, new_id, insecure_id=None):
3350 """Persists a secure id in the identity data file. This is used to indicate
3351 whether we are currently in the process of registering.
3352@@ -894,14 +964,22 @@ def main(args, print=print): # noqa: C901
3353
3354 if config.skip_registration and config.force_registration:
3355 sys.exit(
3356- "Do not set both skip registration "
3357- "and force registration together.",
3358+ "Do not set both skip registration and force registration together.",
3359 )
3360
3361+ if config.show:
3362+ conf_dump = configuration_dump_text(config)
3363+ print(conf_dump)
3364+ sys.exit(0)
3365+
3366+ if config.show_json:
3367+ conf_dump = configuration_dump_json(config)
3368+ print(conf_dump)
3369+ sys.exit(0)
3370+
3371 already_registered = registration_sent(config)
3372
3373 if config.is_registered or config.registration_sent:
3374-
3375 registration_status = already_registered
3376
3377 info_text = registration_info_text(config, registration_status)
3378@@ -948,9 +1026,7 @@ def main(args, print=print): # noqa: C901
3379
3380 should_register = False
3381
3382- if config.force_registration:
3383- should_register = True
3384- elif config.silent and not config.register_if_needed:
3385+ if config.force_registration or config.silent and not config.register_if_needed:
3386 should_register = True
3387 elif config.register_if_needed:
3388 should_register = not already_registered
3389diff --git a/landscape/client/deployment.py b/landscape/client/deployment.py
3390index e46d6f2..f91e711 100644
3391--- a/landscape/client/deployment.py
3392+++ b/landscape/client/deployment.py
3393@@ -4,26 +4,20 @@ import subprocess
3394 import sys
3395 import time
3396 from argparse import SUPPRESS
3397-from datetime import datetime
3398-from datetime import timezone
3399-from logging import debug
3400-from logging import info
3401-from typing import Sequence
3402+from collections.abc import Sequence
3403+from datetime import datetime, timezone
3404+from logging import debug, info
3405
3406 from twisted.logger import globalLogBeginner
3407
3408 from landscape import VERSION
3409-from landscape.client import DEFAULT_CONFIG
3410-from landscape.client import GROUP
3411-from landscape.client import snap_http
3412-from landscape.client import USER
3413+from landscape.client import DEFAULT_CONFIG, GROUP, USER, snap_http
3414 from landscape.client.snap_utils import get_snap_info
3415 from landscape.client.upgraders import UPGRADE_MANAGERS
3416 from landscape.lib import logging
3417 from landscape.lib.config import BaseConfiguration as _BaseConfiguration
3418 from landscape.lib.format import expandvars
3419-from landscape.lib.network import get_active_device_info
3420-from landscape.lib.network import get_fqdn
3421+from landscape.lib.network import get_active_device_info, get_fqdn
3422 from landscape.lib.persist import Persist
3423
3424
3425@@ -50,7 +44,6 @@ def _is_script(filename=sys.argv[0], _scriptdir=os.path.abspath("scripts")):
3426
3427
3428 class BaseConfiguration(_BaseConfiguration):
3429-
3430 version = VERSION
3431
3432 default_config_filename = DEFAULT_CONFIG
3433@@ -122,7 +115,7 @@ class Configuration(BaseConfiguration):
3434 parser.add_argument(
3435 "-k",
3436 "--ssl-public-key",
3437- help="The public SSL key to verify the server. "
3438+ help="The ssl-public-key is a CA certificate that verifies the server."
3439 "Only used if the given URL is https.",
3440 )
3441 parser.add_argument(
3442@@ -135,14 +128,13 @@ class Configuration(BaseConfiguration):
3443 "--ignore-sigusr1",
3444 action="store_true",
3445 default=False,
3446- help="Ignore SIGUSR1 signal to " "rotate logs.",
3447+ help="Ignore SIGUSR1 signal to rotate logs.",
3448 )
3449 parser.add_argument(
3450 "--package-monitor-interval",
3451 default=30 * 60,
3452 type=int,
3453- help="The interval between package monitor runs "
3454- "(default: 1800).",
3455+ help="The interval between package monitor runs (default: 1800).",
3456 )
3457 parser.add_argument(
3458 "--apt-update-interval",
3459@@ -155,8 +147,7 @@ class Configuration(BaseConfiguration):
3460 default=5 * 60,
3461 type=int,
3462 metavar="INTERVAL",
3463- help="The number of seconds between flushes to disk "
3464- "for persistent data.",
3465+ help="The number of seconds between flushes to disk for persistent data.",
3466 )
3467 parser.add_argument(
3468 "--stagger-launch",
3469@@ -173,6 +164,13 @@ class Configuration(BaseConfiguration):
3470 type=int,
3471 help="The interval between snap monitor runs (default 1800).",
3472 )
3473+ parser.add_argument(
3474+ "--script-tempdir",
3475+ default=None,
3476+ type=str,
3477+ help="The working directory to use for script executions. "
3478+ "Must have read, write, and exec privileges for any script users.",
3479+ )
3480
3481 # Hidden options, used for load-testing to run in-process clones
3482 parser.add_argument("--clones", default=0, type=int, help=SUPPRESS)
3483diff --git a/landscape/client/exchange.py b/landscape/client/exchange.py
3484index 72421d8..266a31d 100644
3485--- a/landscape/client/exchange.py
3486+++ b/landscape/client/exchange.py
3487@@ -1,19 +1,16 @@
3488 """Utility functions for exchanging messages synchronously with a Landscape
3489 Server instance.
3490 """
3491-from dataclasses import dataclass
3492+
3493 import logging
3494-from pprint import pformat
3495 import time
3496+from dataclasses import dataclass
3497+from pprint import pformat
3498 from typing import Any
3499-from typing import Dict
3500-from typing import List
3501-from typing import Optional
3502
3503 import pycurl
3504
3505-from landscape import SERVER_API
3506-from landscape import VERSION
3507+from landscape import SERVER_API, VERSION
3508 from landscape.lib import bpickle
3509 from landscape.lib.fetch import fetch
3510 from landscape.lib.format import format_delta
3511@@ -25,19 +22,19 @@ class ServerResponse:
3512
3513 server_api: str
3514 server_uuid: bytes
3515- messages: List[Dict[str, Any]]
3516- client_accepted_types_hash: Optional[bytes] = None
3517- next_exchange_token: Optional[bytes] = None
3518- next_expected_sequence: Optional[int] = None
3519+ messages: list[dict[str, Any]]
3520+ client_accepted_types_hash: bytes | None = None
3521+ next_exchange_token: bytes | None = None
3522+ next_expected_sequence: int | None = None
3523
3524
3525 def exchange_messages(
3526 payload: dict,
3527 server_url: str,
3528 *,
3529- cainfo: Optional[str] = None,
3530- computer_id: Optional[str] = None,
3531- exchange_token: Optional[bytes] = None,
3532+ cainfo: str | None = None,
3533+ computer_id: str | None = None,
3534+ exchange_token: bytes | None = None,
3535 server_api: str = SERVER_API.decode(),
3536 ) -> ServerResponse:
3537 """Sends `payload` via HTTP(S) to `server_url`, parsing and returning the
3538diff --git a/landscape/client/manager/aptsources.py b/landscape/client/manager/aptsources.py
3539index 179fa72..e7faaf2 100644
3540--- a/landscape/client/manager/aptsources.py
3541+++ b/landscape/client/manager/aptsources.py
3542@@ -8,8 +8,7 @@ import uuid
3543
3544 from twisted.internet.defer import succeed
3545
3546-from landscape.client import GROUP
3547-from landscape.client import USER
3548+from landscape.client import GROUP, USER
3549 from landscape.client.manager.plugin import ManagerPlugin
3550 from landscape.client.package.reporter import find_reporter_command
3551 from landscape.constants import FALSE_VALUES
3552@@ -27,6 +26,11 @@ class AptSources(ManagerPlugin):
3553 SOURCES_LIST_D = "/etc/apt/sources.list.d"
3554 TRUSTED_GPG_D = "/etc/apt/trusted.gpg.d"
3555
3556+ """
3557+ Valid file patterns for one-line and Deb822-style sources, respectively.
3558+ """
3559+ SOURCES_LIST_D_FILE_PATTERNS = ["*.list", "*.sources"]
3560+
3561 def register(self, registry):
3562 super().register(registry)
3563 registry.register_message(
3564@@ -109,6 +113,12 @@ class AptSources(ManagerPlugin):
3565
3566 saved_sources = f"{self.SOURCES_LIST}.save"
3567
3568+ manage_sources_list_d = getattr(
3569+ self.registry.config,
3570+ "manage_sources_list_d",
3571+ True,
3572+ )
3573+
3574 if sources:
3575 fd, path = tempfile.mkstemp()
3576 os.close(fd)
3577@@ -130,20 +140,33 @@ class AptSources(ManagerPlugin):
3578 original_stat.st_uid,
3579 original_stat.st_gid,
3580 )
3581+
3582+ if manage_sources_list_d not in FALSE_VALUES:
3583+ for pattern in self.SOURCES_LIST_D_FILE_PATTERNS:
3584+ filenames = glob.glob(os.path.join(self.SOURCES_LIST_D, pattern))
3585+ for filename in filenames:
3586+ shutil.move(filename, f"{filename}.save")
3587 else:
3588 # Re-instate original sources
3589 if os.path.isfile(saved_sources):
3590 shutil.move(saved_sources, self.SOURCES_LIST)
3591
3592- manage_sources_list_d = getattr(
3593- self.registry.config,
3594- "manage_sources_list_d",
3595- True,
3596- )
3597- if manage_sources_list_d not in FALSE_VALUES:
3598- filenames = glob.glob(os.path.join(self.SOURCES_LIST_D, "*.list"))
3599- for filename in filenames:
3600- shutil.move(filename, f"{filename}.save")
3601+ if manage_sources_list_d not in FALSE_VALUES:
3602+ for pattern in self.SOURCES_LIST_D_FILE_PATTERNS:
3603+ filenames = glob.glob(
3604+ os.path.join(self.SOURCES_LIST_D, f"{pattern}.save")
3605+ )
3606+ for filename in filenames:
3607+ restored_filename = filename.removesuffix(".save")
3608+ shutil.move(filename, restored_filename)
3609+
3610+ # Delete Landscape source files prefixed with `landscape-`
3611+ landscape_source_filenames = glob.glob(
3612+ os.path.join(self.SOURCES_LIST_D, "landscape-*.list")
3613+ )
3614+
3615+ for source_filename in landscape_source_filenames:
3616+ os.remove(source_filename)
3617
3618 for source in sources:
3619 filename = os.path.join(
3620@@ -152,7 +175,7 @@ class AptSources(ManagerPlugin):
3621 )
3622 # Servers send unicode, but an upgrade from python2 can get bytes
3623 # from stored messages, so we need to handle both.
3624- is_unicode = isinstance(source["content"], type(""))
3625+ is_unicode = isinstance(source["content"], str)
3626 with open(filename, ("w" if is_unicode else "wb")) as sources_file:
3627 sources_file.write(source["content"])
3628 os.chmod(filename, 0o644)
3629diff --git a/landscape/client/manager/config.py b/landscape/client/manager/config.py
3630index 6b23484..249235a 100644
3631--- a/landscape/client/manager/config.py
3632+++ b/landscape/client/manager/config.py
3633@@ -3,7 +3,6 @@ import os
3634 from landscape.client.deployment import Configuration
3635 from landscape.client.manager.scriptexecution import ALL_USERS
3636
3637-
3638 ALL_PLUGINS = [
3639 "ProcessKiller",
3640 "PackageManager",
3641@@ -17,6 +16,8 @@ ALL_PLUGINS = [
3642 "UbuntuProInfo",
3643 "LivePatch",
3644 "UbuntuProRebootRequired",
3645+ "ProManagement",
3646+ "FDERecoveryKeyManager",
3647 ]
3648
3649
3650diff --git a/landscape/client/manager/customgraph.py b/landscape/client/manager/customgraph.py
3651index 5e35310..ddea186 100644
3652--- a/landscape/client/manager/customgraph.py
3653+++ b/landscape/client/manager/customgraph.py
3654@@ -2,21 +2,17 @@ import logging
3655 import os
3656 import time
3657
3658-from twisted.internet.defer import DeferredList
3659-from twisted.internet.defer import fail
3660-from twisted.internet.defer import succeed
3661-from twisted.python.compat import iteritems
3662+from twisted.internet.defer import DeferredList, fail, succeed
3663
3664 from landscape.client.accumulate import Accumulator
3665 from landscape.client.manager.plugin import ManagerPlugin
3666-from landscape.client.manager.scriptexecution import ProcessFailedError
3667 from landscape.client.manager.scriptexecution import (
3668+ ProcessFailedError,
3669 ProcessTimeLimitReachedError,
3670+ ScriptRunnerMixin,
3671 )
3672-from landscape.client.manager.scriptexecution import ScriptRunnerMixin
3673 from landscape.lib.scriptcontent import generate_script_hash
3674-from landscape.lib.user import get_user_info
3675-from landscape.lib.user import UnknownUserError
3676+from landscape.lib.user import UnknownUserError, get_user_info
3677
3678
3679 class StoreProxy:
3680@@ -152,7 +148,7 @@ class CustomGraphPlugin(ManagerPlugin, ScriptRunnerMixin):
3681 self.registry.store.add_graph(graph_id, filename, user)
3682
3683 def _format_exception(self, e):
3684- return "{}: {}".format(e.__class__.__name__, e.args[0])
3685+ return f"{e.__class__.__name__}: {e.args[0]}"
3686
3687 def exchange(self, urgent=False):
3688 self.registry.broker.call_if_accepted(
3689@@ -165,7 +161,7 @@ class CustomGraphPlugin(ManagerPlugin, ScriptRunnerMixin):
3690 if not self.do_send:
3691 return
3692 self.do_send = False
3693- graphs = list(self.registry.store.get_graphs())
3694+ graphs = self.registry.store.get_graphs()
3695 for graph_id, filename, user in graphs:
3696 if graph_id not in self._data:
3697 if os.path.isfile(filename):
3698@@ -179,7 +175,7 @@ class CustomGraphPlugin(ManagerPlugin, ScriptRunnerMixin):
3699 message = {"type": self.message_type, "data": self._data}
3700
3701 new_data = {}
3702- for graph_id, item in iteritems(self._data):
3703+ for graph_id, item in self._data.items():
3704 script_hash = item["script-hash"]
3705 new_data[graph_id] = {
3706 "values": [],
3707@@ -221,9 +217,9 @@ class CustomGraphPlugin(ManagerPlugin, ScriptRunnerMixin):
3708 )
3709 self._data[graph_id]["error"] = failure_value
3710 elif failure.check(ProcessTimeLimitReachedError):
3711- self._data[graph_id][
3712- "error"
3713- ] = f"Process exceeded the {self.time_limit:d} seconds limit"
3714+ self._data[graph_id]["error"] = (
3715+ f"Process exceeded the {self.time_limit:d} seconds limit"
3716+ )
3717 else:
3718 self._data[graph_id]["error"] = self._format_exception(
3719 failure.value,
3720@@ -240,7 +236,7 @@ class CustomGraphPlugin(ManagerPlugin, ScriptRunnerMixin):
3721 handle the output.
3722 """
3723 self.do_send = True
3724- graphs = list(self.registry.store.get_graphs())
3725+ graphs = self.registry.store.get_graphs()
3726
3727 if not graphs:
3728 # Shortcut to prevent useless call to call_if_accepted
3729diff --git a/landscape/client/manager/fakepackagemanager.py b/landscape/client/manager/fakepackagemanager.py
3730index c27f2cc..2d1feef 100644
3731--- a/landscape/client/manager/fakepackagemanager.py
3732+++ b/landscape/client/manager/fakepackagemanager.py
3733@@ -5,7 +5,6 @@ from landscape.client.manager.plugin import ManagerPlugin
3734
3735
3736 class FakePackageManager(ManagerPlugin):
3737-
3738 run_interval = 1800
3739 randint = random.randint
3740
3741diff --git a/landscape/client/manager/fderecoverykeymanager.py b/landscape/client/manager/fderecoverykeymanager.py
3742new file mode 100644
3743index 0000000..365a5a0
3744--- /dev/null
3745+++ b/landscape/client/manager/fderecoverykeymanager.py
3746@@ -0,0 +1,170 @@
3747+import logging
3748+from typing import Any, Tuple
3749+
3750+from twisted.internet import task
3751+from twisted.internet.defer import Deferred, ensureDeferred
3752+
3753+from landscape.client import snap_http
3754+from landscape.client.manager.plugin import (
3755+ ManagerPlugin,
3756+)
3757+from landscape.client.snap_http.http import SnapdHttpException
3758+from landscape.client.snap_http.types import INCOMPLETE_STATUSES
3759+
3760+KEYSLOT_NAME = "landscape-recovery-key"
3761+
3762+
3763+class FDEKeyError(Exception):
3764+ """Raised when the FDE key generation fails."""
3765+
3766+ def __init__(self, message):
3767+ self.message = message
3768+
3769+
3770+class FDERecoveryKeyManager(ManagerPlugin):
3771+ """Plugin that generates FDE recovery keys."""
3772+
3773+ def register(self, client):
3774+ super().register(client)
3775+ client.register_message(
3776+ "fde-recovery-key",
3777+ self._handle_recovery_key_message,
3778+ )
3779+
3780+ def _handle_recovery_key_message(self, message):
3781+ return ensureDeferred(self.handle_recovery_key_message(message))
3782+
3783+ async def handle_recovery_key_message(self, message: dict[str, Any]) -> None:
3784+ """Generates an FDE recovery key, then responds to `message`.
3785+
3786+ If the recovery key is successfully generated, we will store it in the
3787+ exchanger in-memory data store and attempt to add the recovery key to
3788+ the message just before sending it to server.
3789+
3790+ :message: A message of type "fde-recovery-key".
3791+ """
3792+ opid = message["operation-id"]
3793+
3794+ try:
3795+ recovery_key_exists = self._recovery_key_exists()
3796+ recovery_key, key_id = self._generate_recovery_key()
3797+ await self._update_recovery_key(key_id, recovery_key_exists)
3798+
3799+ self.registry.broker.update_exchange_state("recovery-key", recovery_key)
3800+
3801+ await self._send_fde_recovery_key(
3802+ opid, True, "Generated new FDE recovery key."
3803+ )
3804+ except FDEKeyError as e:
3805+ await self._send_fde_recovery_key(opid, False, str(e))
3806+ except Exception as e:
3807+ await self._send_fde_recovery_key(opid, False, str(e))
3808+
3809+ async def _send_fde_recovery_key(
3810+ self,
3811+ opid: int,
3812+ successful: bool,
3813+ result_text: str,
3814+ ) -> None:
3815+ """Queues a `fde-recovery-key` message to Landscape Server."""
3816+
3817+ message = {
3818+ "type": "fde-recovery-key",
3819+ "operation-id": opid,
3820+ "successful": successful,
3821+ "result-text": result_text,
3822+ }
3823+
3824+ return await self.registry.broker.send_message(
3825+ message,
3826+ self._session_id,
3827+ True,
3828+ )
3829+
3830+ def _recovery_key_exists(
3831+ self,
3832+ ) -> bool:
3833+ """Checks if the Landscape recovery key keyslot already exists.
3834+
3835+ :raises FDEKeyError: If the snapd API returns an error.
3836+ """
3837+
3838+ try:
3839+ result = snap_http.get_keyslots()
3840+ except SnapdHttpException as e:
3841+ raise FDEKeyError(f"Unable to list recovery keys: {e}")
3842+
3843+ slots = result.result["by-container-role"]["system-data"]["keyslots"]
3844+
3845+ return KEYSLOT_NAME in slots
3846+
3847+ def _generate_recovery_key(
3848+ self,
3849+ ) -> Tuple[str, str]:
3850+ """Generates the recovery key and a key-id used to update the
3851+ Landscape recovery key keyslot.
3852+
3853+ :raises FDEKeyError: If the snapd API returns an error.
3854+ """
3855+
3856+ try:
3857+ result = snap_http.generate_recovery_key()
3858+ except SnapdHttpException as e:
3859+ raise FDEKeyError(f"Unable to generate recovery key: {e}")
3860+
3861+ recovery_key = result.result["recovery-key"]
3862+ key_id = result.result["key-id"]
3863+
3864+ return recovery_key, key_id
3865+
3866+ async def _update_recovery_key(
3867+ self, key_id: str, recovery_key_exists: bool
3868+ ) -> None:
3869+ """Uses the key-id to add or replace the Landscape recovery key keyslot.
3870+
3871+ :key_id: The identifier for the generated recovery key.
3872+ :recovery_key_exists: If True, replaces the recovery key instead of adding it.
3873+
3874+ :raises FDEKeyError: If the snapd API returns an error.
3875+ """
3876+
3877+ try:
3878+ result = snap_http.update_recovery_key(
3879+ key_id, KEYSLOT_NAME, recovery_key_exists
3880+ )
3881+ except SnapdHttpException as e:
3882+ raise FDEKeyError(f"Unable to update recovery key: {e}")
3883+
3884+ await self._poll_for_completion(result.change)
3885+
3886+ def _poll_for_completion(self, change_id: str) -> Deferred:
3887+ """Waits for an async operation to be complete.
3888+
3889+ :change_id: The change id for the async operation.
3890+
3891+ :raises FDEKeyError: If the snapd API returns an error.
3892+ """
3893+ interval = getattr(self.registry.config, "snapd_poll_interval", 15)
3894+
3895+ def get_status():
3896+ logging.info("Polling snapd for status of pending recovery key update.")
3897+
3898+ try:
3899+ status = snap_http.check_change(change_id).result["status"]
3900+ except SnapdHttpException as e:
3901+ logging.error(f"Error checking status of snap changes: {e}")
3902+ loop.stop()
3903+ raise FDEKeyError(str(e))
3904+
3905+ if status in INCOMPLETE_STATUSES:
3906+ logging.debug(
3907+ "Incomplete status for recovery key update, waiting...",
3908+ )
3909+ else:
3910+ logging.debug("Complete status for recovery key update")
3911+ loop.stop()
3912+
3913+ loop = task.LoopingCall(get_status)
3914+ loopDeferred = loop.start(interval)
3915+
3916+ return loopDeferred
3917diff --git a/landscape/client/manager/keystonetoken.py b/landscape/client/manager/keystonetoken.py
3918index 14fab79..e9c941c 100644
3919--- a/landscape/client/manager/keystonetoken.py
3920+++ b/landscape/client/manager/keystonetoken.py
3921@@ -1,16 +1,12 @@
3922 import logging
3923 import os
3924+from configparser import ConfigParser, NoOptionError
3925
3926-from landscape.client import GROUP
3927-from landscape.client import USER
3928+from landscape.client import GROUP, USER
3929 from landscape.client.monitor.plugin import DataWatcher
3930-from landscape.lib.compat import _PY3
3931-from landscape.lib.compat import ConfigParser
3932-from landscape.lib.compat import NoOptionError
3933 from landscape.lib.fs import read_binary_file
3934 from landscape.lib.persist import Persist
3935
3936-
3937 KEYSTONE_CONFIG_FILE = "/etc/keystone/keystone.conf"
3938
3939
3940@@ -61,28 +57,21 @@ class KeystoneToken(DataWatcher):
3941 return None
3942
3943 config = ConfigParser()
3944- if _PY3:
3945- # We need to use the surrogateescape error handler as the
3946- # admin_token my contain arbitrary bytes. The ConfigParser in
3947- # Python 2 on the other hand does not support read_string.
3948- config_str = read_binary_file(self._keystone_config_file).decode(
3949- "utf-8",
3950- "surrogateescape",
3951- )
3952- config.read_string(config_str)
3953- else:
3954- config.read(self._keystone_config_file)
3955+ # We need to use the surrogateescape error handler as the
3956+ # admin_token my contain arbitrary bytes.
3957+ config_str = read_binary_file(self._keystone_config_file).decode(
3958+ "utf-8",
3959+ "surrogateescape",
3960+ )
3961+ config.read_string(config_str)
3962 try:
3963 admin_token = config.get("DEFAULT", "admin_token")
3964 except NoOptionError:
3965 logging.error(
3966- "KeystoneToken: No admin_token found in "
3967- f"{self._keystone_config_file}",
3968+ f"KeystoneToken: No admin_token found in {self._keystone_config_file}",
3969 )
3970 return None
3971- # There is no support for surrogateescape in Python 2, but we actually
3972- # have bytes in this case anyway.
3973- if _PY3:
3974- admin_token = admin_token.encode("utf-8", "surrogateescape")
3975+
3976+ admin_token = admin_token.encode("utf-8", "surrogateescape")
3977
3978 return admin_token
3979diff --git a/landscape/client/manager/livepatch.py b/landscape/client/manager/livepatch.py
3980index 666bb42..362f5f4 100644
3981--- a/landscape/client/manager/livepatch.py
3982+++ b/landscape/client/manager/livepatch.py
3983@@ -2,6 +2,7 @@ import json
3984 import logging
3985 import subprocess
3986 import traceback
3987+
3988 import yaml
3989
3990 from landscape.client.manager.plugin import DataWatcherManager
3991@@ -42,9 +43,7 @@ def _parse_humane(output):
3992 value = value.strip().strip('"').strip("'")
3993 data[key] = value
3994 else:
3995- raise yaml.YAMLError(
3996- "Input is not a list of key/value pairs: " + output
3997- )
3998+ raise yaml.YAMLError("Input is not a list of key/value pairs: " + output)
3999
4000 return data
4001
4002diff --git a/landscape/client/manager/packagemanager.py b/landscape/client/manager/packagemanager.py
4003index 36692a9..7a8cb64 100644
4004--- a/landscape/client/manager/packagemanager.py
4005+++ b/landscape/client/manager/packagemanager.py
4006@@ -12,7 +12,6 @@ from landscape.lib.encoding import encode_values
4007
4008
4009 class PackageManager(ManagerPlugin):
4010-
4011 run_interval = 1800
4012 _package_store = None
4013
4014diff --git a/landscape/client/manager/plugin.py b/landscape/client/manager/plugin.py
4015index 2a1e9b9..44b5e87 100644
4016--- a/landscape/client/manager/plugin.py
4017+++ b/landscape/client/manager/plugin.py
4018@@ -1,11 +1,9 @@
4019 import logging
4020 from pathlib import Path
4021-from typing import Optional
4022
4023 from twisted.internet.defer import maybeDeferred
4024
4025-from landscape.client import GROUP
4026-from landscape.client import USER
4027+from landscape.client import GROUP, USER
4028 from landscape.client.broker.client import BrokerClientPlugin
4029 from landscape.lib.format import format_object
4030 from landscape.lib.log import log_failure
4031@@ -45,7 +43,7 @@ class ManagerPlugin(BrokerClientPlugin):
4032 def failure(failure):
4033 text = f"{failure.type.__name__}: {failure.value}"
4034 msg = (
4035- "Error occured running message handler %s with " "args %r %r.",
4036+ "Error occurred running message handler %s with args %r %r.",
4037 format_object(callable),
4038 args,
4039 kwargs,
4040@@ -85,7 +83,7 @@ class DataWatcherManager(ManagerPlugin):
4041 a get_data method
4042 """
4043
4044- message_type: Optional[str] = None
4045+ message_type: str | None = None
4046
4047 def __init__(self):
4048 super().__init__()
4049@@ -95,12 +93,12 @@ class DataWatcherManager(ManagerPlugin):
4050 super().register(registry)
4051 self._persist_filename = Path(
4052 self.registry.config.data_path,
4053- self.message_type + '.manager.bpkl',
4054+ self.message_type + ".manager.bpkl",
4055 )
4056 self._persist = Persist(
4057 filename=self._persist_filename,
4058 user=USER,
4059- group=GROUP
4060+ group=GROUP,
4061 )
4062 self.call_on_accepted(self.message_type, self.send_message)
4063
4064@@ -115,10 +113,11 @@ class DataWatcherManager(ManagerPlugin):
4065 call"""
4066 result = self.get_new_data()
4067 if not result:
4068- logging.debug("{} unchanged so not sending".format(
4069- self.message_type))
4070+ logging.debug(
4071+ f"{self.message_type} unchanged so not sending",
4072+ )
4073 return
4074- logging.debug("Sending new {} data!".format(self.message_type))
4075+ logging.debug(f"Sending new {self.message_type} data!")
4076 message = {"type": self.message_type, self.message_type: result}
4077 return self.registry.broker.send_message(message, self._session_id)
4078
4079diff --git a/landscape/client/manager/promanagement.py b/landscape/client/manager/promanagement.py
4080new file mode 100644
4081index 0000000..1659731
4082--- /dev/null
4083+++ b/landscape/client/manager/promanagement.py
4084@@ -0,0 +1,83 @@
4085+import json
4086+
4087+from twisted.internet.threads import deferToThread
4088+
4089+from landscape.client.manager.plugin import (
4090+ FAILED,
4091+ SUCCEEDED,
4092+ ManagerPlugin,
4093+)
4094+from landscape.client.manager.ubuntuproinfo import get_ubuntu_pro_info
4095+from landscape.lib.uaclient import (
4096+ ProManagementError,
4097+ attach_pro,
4098+ detach_pro,
4099+)
4100+
4101+
4102+class ProManagement(ManagerPlugin):
4103+ """A plugin which allows for users to attach pro tokens."""
4104+
4105+ def register(self, registry):
4106+ super().register(registry)
4107+ registry.register_message(
4108+ "attach-pro",
4109+ self._handle_attach_pro,
4110+ )
4111+ registry.register_message(
4112+ "detach-pro",
4113+ self._handle_detach_pro,
4114+ )
4115+
4116+ def _handle_attach_pro(self, message: dict):
4117+ """
4118+ Extract data from message and create deferred for
4119+ attaching a pro token.
4120+ """
4121+ opid = message["operation-id"]
4122+ token = message["token"]
4123+ d = deferToThread(attach_pro, token)
4124+ d.addCallback(self._respond_success_attach, opid)
4125+ d.addErrback(self._respond_failure, opid)
4126+ return d
4127+
4128+ def _handle_detach_pro(self, message: dict):
4129+ """
4130+ Extract data from message and create deferred for
4131+ detaching a pro token.
4132+ """
4133+ opid = message["operation-id"]
4134+ d = deferToThread(detach_pro)
4135+ d.addCallback(self._respond_success_detach, opid)
4136+ d.addErrback(self._respond_failure, opid)
4137+ return d
4138+
4139+ def _respond_success_attach(self, data, opid):
4140+ return self._respond(SUCCEEDED, json.dumps(get_ubuntu_pro_info()), opid)
4141+
4142+ def _respond_success_detach(self, data, opid):
4143+ return self._respond(SUCCEEDED, "Succeeded in detaching pro token.", opid)
4144+
4145+ def _respond_failure(self, failure, opid):
4146+ try:
4147+ failure.raiseException()
4148+ except ProManagementError as e:
4149+ return self._respond(FAILED, str(e), opid)
4150+ except Exception:
4151+ return self._respond(FAILED, str(failure), opid)
4152+
4153+ def _respond(self, status, data, opid, result_code=None):
4154+ message = {
4155+ "type": "operation-result",
4156+ "status": status,
4157+ "result-text": data,
4158+ "operation-id": opid,
4159+ }
4160+ if result_code:
4161+ message["result-code"] = result_code
4162+
4163+ return self.registry.broker.send_message(
4164+ message,
4165+ self._session_id,
4166+ True,
4167+ )
4168diff --git a/landscape/client/manager/scriptexecution.py b/landscape/client/manager/scriptexecution.py
4169index fe39d39..a1c61f4 100644
4170--- a/landscape/client/manager/scriptexecution.py
4171+++ b/landscape/client/manager/scriptexecution.py
4172@@ -3,34 +3,28 @@ Functionality for running arbitrary shell scripts.
4173
4174 @var ALL_USERS: A token indicating all users should be allowed.
4175 """
4176+
4177 import os.path
4178 import shutil
4179 import sys
4180 import tempfile
4181+from typing import TYPE_CHECKING
4182
4183-from twisted.internet.defer import Deferred
4184-from twisted.internet.defer import fail
4185-from twisted.internet.defer import inlineCallbacks
4186-from twisted.internet.defer import returnValue
4187-from twisted.internet.defer import succeed
4188+from twisted.internet.defer import Deferred, ensureDeferred, fail, succeed
4189 from twisted.internet.error import ProcessDone
4190 from twisted.internet.protocol import ProcessProtocol
4191-from twisted.python.compat import unicode
4192
4193-from landscape import VERSION
4194-from landscape.client import GROUP
4195 from landscape.client import IS_SNAP
4196-from landscape.client import USER
4197-from landscape.client.manager.plugin import FAILED
4198-from landscape.client.manager.plugin import ManagerPlugin
4199-from landscape.client.manager.plugin import SUCCEEDED
4200+from landscape.client.attachments import save_attachments
4201+from landscape.client.manager.plugin import FAILED, SUCCEEDED, ManagerPlugin
4202 from landscape.constants import UBUNTU_PATH
4203-from landscape.lib.fetch import fetch_async
4204 from landscape.lib.fetch import HTTPCodeError
4205-from landscape.lib.persist import Persist
4206 from landscape.lib.scriptcontent import build_script
4207 from landscape.lib.user import get_user_info
4208
4209+if TYPE_CHECKING:
4210+ from landscape.client.broker.client import BrokerClient
4211+
4212
4213 ALL_USERS = object()
4214 TIMEOUT_RESULT = 102
4215@@ -110,20 +104,27 @@ class ScriptRunnerMixin:
4216 script_file.write(script)
4217 script_file.close()
4218
4219- def _run_script(self, filename, uid, gid, path, env, time_limit):
4220- if uid == os.getuid():
4221- uid = None
4222- if gid == os.getgid():
4223- gid = None
4224- env = {
4225+ def _sanitize_env(self, env: dict) -> dict:
4226+ """
4227+ Guard against unrecognized characters in the environment.
4228+ """
4229+ return {
4230 key: (
4231 value.encode(sys.getfilesystemencoding(), errors="replace")
4232- if isinstance(value, unicode)
4233+ if isinstance(value, str)
4234 else value
4235 )
4236 for key, value in env.items()
4237 }
4238
4239+ def _run_script(self, filename, uid, gid, path, env, time_limit):
4240+ if uid == os.getuid():
4241+ uid = None
4242+ if gid == os.getgid():
4243+ gid = None
4244+
4245+ env = self._sanitize_env(env)
4246+
4247 pp = ProcessAccumulationProtocol(
4248 self.registry.reactor,
4249 self.registry.config.script_output_limit,
4250@@ -147,6 +148,15 @@ class ScriptRunnerMixin:
4251 class ScriptExecutionPlugin(ManagerPlugin, ScriptRunnerMixin):
4252 """A plugin which allows execution of arbitrary shell scripts."""
4253
4254+ def __init__(
4255+ self,
4256+ process_factory=None,
4257+ script_tempdir: str | None = None,
4258+ ):
4259+ ScriptRunnerMixin.__init__(self, process_factory=process_factory)
4260+ ManagerPlugin.__init__(self)
4261+ self.script_tempdir = script_tempdir
4262+
4263 def register(self, registry):
4264 super().register(registry)
4265 registry.register_message(
4266@@ -155,7 +165,7 @@ class ScriptExecutionPlugin(ManagerPlugin, ScriptRunnerMixin):
4267 )
4268
4269 def _respond(self, status, data, opid, result_code=None):
4270- if not isinstance(data, unicode):
4271+ if not isinstance(data, str):
4272 # Let's decode result-text, replacing non-printable
4273 # characters
4274 data = data.decode("utf-8", "replace")
4275@@ -199,10 +209,9 @@ class ScriptExecutionPlugin(ManagerPlugin, ScriptRunnerMixin):
4276 return d
4277 except Exception as e:
4278 self._respond(FAILED, self._format_exception(e), opid)
4279- raise
4280
4281 def _format_exception(self, e):
4282- return "{}: {}".format(e.__class__.__name__, e.args[0])
4283+ return f"{e.__class__.__name__}: {e.args[0]}"
4284
4285 def _respond_success(self, data, opid):
4286 return self._respond(SUCCEEDED, data, opid)
4287@@ -227,36 +236,23 @@ class ScriptExecutionPlugin(ManagerPlugin, ScriptRunnerMixin):
4288 else:
4289 return self._respond(FAILED, str(failure), opid)
4290
4291- @inlineCallbacks
4292- def _save_attachments(self, attachments, uid, gid, computer_id, env):
4293- root_path = self.registry.config.url.rsplit("/", 1)[0] + "/attachment/"
4294- env["LANDSCAPE_ATTACHMENTS"] = attachment_dir = tempfile.mkdtemp()
4295- headers = {
4296- "User-Agent": f"landscape-client/{VERSION}",
4297- "Content-Type": "application/octet-stream",
4298- "X-Computer-ID": computer_id,
4299- }
4300- for filename, attachment_id in attachments.items():
4301- if isinstance(attachment_id, str):
4302- # Backward compatible behavior
4303- data = attachment_id.encode("utf-8")
4304- yield succeed(None)
4305- else:
4306- data = yield fetch_async(
4307- f"{root_path}{attachment_id:d}",
4308- cainfo=self.registry.config.ssl_public_key,
4309- headers=headers,
4310- )
4311- full_filename = os.path.join(attachment_dir, filename)
4312- with open(full_filename, "wb") as attachment:
4313- os.chmod(full_filename, 0o600)
4314- if not self.IS_SNAP and uid is not None:
4315- os.chown(full_filename, uid, gid)
4316- attachment.write(data)
4317+ async def _save_attachments(self, attachments, uid, gid, env):
4318+ attachment_dir = tempfile.mkdtemp(dir=self.script_tempdir)
4319+ env["LANDSCAPE_ATTACHMENTS"] = attachment_dir
4320 os.chmod(attachment_dir, 0o700)
4321+
4322+ await save_attachments(
4323+ self.registry.config,
4324+ attachments.items(),
4325+ attachment_dir,
4326+ uid,
4327+ gid,
4328+ )
4329+
4330 if not self.IS_SNAP and uid is not None:
4331 os.chown(attachment_dir, uid, gid)
4332- returnValue(attachment_dir)
4333+
4334+ return attachment_dir
4335
4336 def run_script(
4337 self,
4338@@ -292,7 +288,7 @@ class ScriptExecutionPlugin(ManagerPlugin, ScriptRunnerMixin):
4339
4340 uid, gid, path = get_user_info(user)
4341
4342- fd, filename = tempfile.mkstemp()
4343+ fd, filename = tempfile.mkstemp(dir=self.script_tempdir)
4344 script_file = os.fdopen(fd, "wb")
4345 self.write_script_file(script_file, filename, shell, code, uid, gid)
4346
4347@@ -315,26 +311,18 @@ class ScriptExecutionPlugin(ManagerPlugin, ScriptRunnerMixin):
4348 old_umask = os.umask(0o022)
4349
4350 if attachments:
4351- persist = Persist(
4352- filename=os.path.join(
4353- self.registry.config.data_path,
4354- "broker.bpickle",
4355+ d = ensureDeferred(
4356+ self._save_attachments(
4357+ attachments,
4358+ uid,
4359+ gid,
4360+ env,
4361 ),
4362- user=USER,
4363- group=GROUP,
4364 )
4365- persist = persist.root_at("registration")
4366- computer_id = persist.get("secure-id")
4367- try:
4368- computer_id = computer_id.decode("ascii")
4369- except AttributeError:
4370- pass
4371- d = self._save_attachments(attachments, uid, gid, computer_id, env)
4372 else:
4373 d = succeed(None)
4374
4375 def prepare_script(attachment_dir):
4376-
4377 return self._run_script(filename, uid, gid, path, env, time_limit)
4378
4379 d.addCallback(prepare_script)
4380@@ -443,16 +431,17 @@ class ScriptExecution(ManagerPlugin):
4381 Meta-plugin wrapping ScriptExecutionPlugin and CustomGraphPlugin.
4382 """
4383
4384- def __init__(self):
4385+ def register(self, client: "BrokerClient"):
4386 from landscape.client.manager.customgraph import CustomGraphPlugin
4387
4388- self._script_execution = ScriptExecutionPlugin()
4389+ super().register(client)
4390+ self._script_execution = ScriptExecutionPlugin(
4391+ script_tempdir=self.manager.config.script_tempdir,
4392+ )
4393 self._custom_graph = CustomGraphPlugin()
4394
4395- def register(self, registry):
4396- super().register(registry)
4397- self._script_execution.register(registry)
4398- self._custom_graph.register(registry)
4399+ self._script_execution.register(client)
4400+ self._custom_graph.register(client)
4401
4402 def exchange(self, urgent=False):
4403 self._custom_graph.exchange(urgent)
4404diff --git a/landscape/client/manager/service.py b/landscape/client/manager/service.py
4405index c289685..5b1a366 100644
4406--- a/landscape/client/manager/service.py
4407+++ b/landscape/client/manager/service.py
4408@@ -6,8 +6,7 @@ from landscape.client.amp import ComponentPublisher
4409 from landscape.client.broker.amp import RemoteBrokerConnector
4410 from landscape.client.manager.config import ManagerConfiguration
4411 from landscape.client.manager.manager import Manager
4412-from landscape.client.service import LandscapeService
4413-from landscape.client.service import run_landscape_service
4414+from landscape.client.service import LandscapeService, run_landscape_service
4415
4416
4417 class ManagerService(LandscapeService):
4418@@ -35,8 +34,7 @@ class ManagerService(LandscapeService):
4419 for plugin_name in self.config.plugin_factories:
4420 try:
4421 plugin = namedClass(
4422- "landscape.client.manager."
4423- f"{plugin_name.lower()}.{plugin_name}",
4424+ f"landscape.client.manager.{plugin_name.lower()}.{plugin_name}",
4425 )
4426 plugins.append(plugin())
4427 except ModuleNotFoundError:
4428diff --git a/landscape/client/manager/shutdownmanager.py b/landscape/client/manager/shutdownmanager.py
4429index bd38533..d80836c 100644
4430--- a/landscape/client/manager/shutdownmanager.py
4431+++ b/landscape/client/manager/shutdownmanager.py
4432@@ -1,12 +1,9 @@
4433 import logging
4434
4435 import dbus
4436-from twisted.internet import reactor
4437-from twisted.internet import task
4438+from twisted.internet import reactor, task
4439
4440-from landscape.client.manager.plugin import FAILED
4441-from landscape.client.manager.plugin import ManagerPlugin
4442-from landscape.client.manager.plugin import SUCCEEDED
4443+from landscape.client.manager.plugin import FAILED, SUCCEEDED, ManagerPlugin
4444
4445
4446 class ShutdownManager(ManagerPlugin):
4447diff --git a/landscape/client/manager/snapmanager.py b/landscape/client/manager/snapmanager.py
4448index ce6c2d4..9eb9a87 100644
4449--- a/landscape/client/manager/snapmanager.py
4450+++ b/landscape/client/manager/snapmanager.py
4451@@ -5,15 +5,13 @@ from pathlib import Path
4452
4453 from twisted.internet import task
4454
4455-from landscape.client import GROUP
4456-from landscape.client import snap_http
4457-from landscape.client import USER
4458-from landscape.client.manager.plugin import FAILED
4459-from landscape.client.manager.plugin import ManagerPlugin
4460-from landscape.client.manager.plugin import SUCCEEDED
4461-from landscape.client.snap_http import INCOMPLETE_STATUSES
4462-from landscape.client.snap_http import SnapdHttpException
4463-from landscape.client.snap_http import SUCCESS_STATUSES
4464+from landscape.client import GROUP, USER, snap_http
4465+from landscape.client.manager.plugin import FAILED, SUCCEEDED, ManagerPlugin
4466+from landscape.client.snap_http import (
4467+ INCOMPLETE_STATUSES,
4468+ SUCCESS_STATUSES,
4469+ SnapdHttpException,
4470+)
4471 from landscape.lib.persist import Persist
4472 from landscape.message_schemas.server_bound import SNAPS
4473
4474@@ -292,6 +290,28 @@ class SnapManager(BaseSnapManager):
4475 self._send_snap_update,
4476 )
4477
4478+ def _get_serial(self):
4479+ """
4480+ Returns device's serial number by parsing the serial assertion.
4481+
4482+ If the serial assertion cannot be retrieved, returns "no-serial".
4483+ """
4484+ serial = "no-serial"
4485+
4486+ try:
4487+ serial_bytes = snap_http.get_assertions("serial").result
4488+ serial_assert = serial_bytes.decode("utf-8")
4489+ except SnapdHttpException as e:
4490+ logging.error(f"Unable to get serial assertion: {e}")
4491+ return serial
4492+
4493+ for line in serial_assert.splitlines():
4494+ if line.startswith("serial:"):
4495+ serial = line.split("serial:", 1)[1].strip()
4496+ break
4497+
4498+ return serial or "no-serial"
4499+
4500 def get_data(self):
4501 try:
4502 snaps = snap_http.list().result
4503@@ -301,6 +321,10 @@ class SnapManager(BaseSnapManager):
4504
4505 for i in range(len(snaps)):
4506 snap_name = snaps[i]["name"]
4507+ # devmode snaps have no ID so we need to add one for server
4508+ # indexing.
4509+ if snaps[i]["id"] == "":
4510+ snaps[i]["id"] = self._get_serial() + "-" + snap_name
4511 try:
4512 config = snap_http.get_conf(snap_name).result
4513 except SnapdHttpException as e:
4514diff --git a/landscape/client/manager/store.py b/landscape/client/manager/store.py
4515index 1971b5e..57484d5 100644
4516--- a/landscape/client/manager/store.py
4517+++ b/landscape/client/manager/store.py
4518@@ -37,8 +37,7 @@ class ManagerStore:
4519 )
4520 else:
4521 cursor.execute(
4522- "INSERT INTO graph (graph_id, filename, user) "
4523- "VALUES (?, ?, ?)",
4524+ "INSERT INTO graph (graph_id, filename, user) VALUES (?, ?, ?)",
4525 (graph_id, filename, user),
4526 )
4527
4528diff --git a/landscape/client/manager/tests/test_aptsources.py b/landscape/client/manager/tests/test_aptsources.py
4529index aa648be..0a87032 100644
4530--- a/landscape/client/manager/tests/test_aptsources.py
4531+++ b/landscape/client/manager/tests/test_aptsources.py
4532@@ -1,15 +1,12 @@
4533 import os
4534 from unittest import mock
4535
4536-from twisted.internet.defer import Deferred
4537-from twisted.internet.defer import succeed
4538+from twisted.internet.defer import Deferred, succeed
4539
4540 from landscape.client.manager.aptsources import AptSources
4541-from landscape.client.manager.plugin import FAILED
4542-from landscape.client.manager.plugin import SUCCEEDED
4543+from landscape.client.manager.plugin import FAILED, SUCCEEDED
4544 from landscape.client.package.reporter import find_reporter_command
4545-from landscape.client.tests.helpers import LandscapeTest
4546-from landscape.client.tests.helpers import ManagerHelper
4547+from landscape.client.tests.helpers import LandscapeTest, ManagerHelper
4548
4549
4550 class FakeStatResult:
4551@@ -165,6 +162,129 @@ class AptSourcesTests(LandscapeTest):
4552 with open(self.sourceslist.SOURCES_LIST) as sources:
4553 self.assertEqual("original content\n", sources.read())
4554
4555+ def test_restore_sources_list_d(self):
4556+ """
4557+ When getting a repository message without sources, AptSources
4558+ restores the previous files in sources.list.d.
4559+ """
4560+ FILE_1_LIST = os.path.join(self.sourceslist.SOURCES_LIST_D, "file1.list")
4561+
4562+ FILE_2_SOURCES = os.path.join(self.sourceslist.SOURCES_LIST_D, "file2.sources")
4563+ with open(
4564+ FILE_1_LIST,
4565+ "w",
4566+ ) as source1:
4567+ source1.write("ok\n")
4568+
4569+ with open(
4570+ FILE_2_SOURCES,
4571+ "w",
4572+ ) as source2:
4573+ source2.write("ok\n")
4574+
4575+ self.manager.dispatch_message(
4576+ {
4577+ "type": "apt-sources-replace",
4578+ "sources": [{"name": "bla", "content": b""}],
4579+ "gpg-keys": [],
4580+ "operation-id": 1,
4581+ },
4582+ )
4583+
4584+ self.assertFalse(
4585+ os.path.exists(FILE_1_LIST),
4586+ )
4587+
4588+ self.assertFalse(os.path.exists(FILE_2_SOURCES))
4589+
4590+ self.assertTrue(
4591+ os.path.exists(f"{FILE_1_LIST}.save"),
4592+ )
4593+
4594+ self.assertTrue(
4595+ os.path.exists(f"{FILE_2_SOURCES}.save"),
4596+ )
4597+
4598+ self.manager.dispatch_message(
4599+ {
4600+ "type": "apt-sources-replace",
4601+ "sources": [],
4602+ "gpg-keys": [],
4603+ "operation-id": 2,
4604+ },
4605+ )
4606+
4607+ self.assertTrue(
4608+ os.path.exists(FILE_1_LIST),
4609+ )
4610+
4611+ self.assertTrue(os.path.exists(FILE_2_SOURCES))
4612+
4613+ self.assertFalse(
4614+ os.path.exists(f"{FILE_1_LIST}.save"),
4615+ )
4616+
4617+ self.assertFalse(
4618+ os.path.exists(f"{FILE_2_SOURCES}.save"),
4619+ )
4620+
4621+ def test_restore_sources_list_d_removes_old_profile_files(self):
4622+ """
4623+ When getting a repository message without sources, old
4624+ source files in `/etc/apt/sources.list.d` prefixed with
4625+ `landscape-` will be removed.
4626+ """
4627+ first_source_name = "ginger"
4628+
4629+ self.manager.dispatch_message(
4630+ {
4631+ "type": "apt-sources-replace",
4632+ "sources": [{"name": first_source_name, "content": b""}],
4633+ "gpg-keys": [],
4634+ "operation-id": 1,
4635+ },
4636+ )
4637+
4638+ first_sources_path = os.path.join(
4639+ self.sourceslist.SOURCES_LIST_D,
4640+ f"landscape-{first_source_name}.list",
4641+ )
4642+
4643+ self.assertTrue(os.path.exists(first_sources_path))
4644+
4645+ second_source_name = "ace rothstein"
4646+
4647+ self.manager.dispatch_message(
4648+ {
4649+ "type": "apt-sources-replace",
4650+ "sources": [{"name": second_source_name, "content": b""}],
4651+ "gpg-keys": [],
4652+ "operation-id": 2,
4653+ },
4654+ )
4655+
4656+ second_sources_path = os.path.join(
4657+ self.sourceslist.SOURCES_LIST_D,
4658+ f"landscape-{second_source_name}.list",
4659+ )
4660+
4661+ self.assertTrue(os.path.exists(f"{first_sources_path}.save"))
4662+
4663+ self.assertTrue(os.path.exists(second_sources_path))
4664+
4665+ self.manager.dispatch_message(
4666+ {
4667+ "type": "apt-sources-replace",
4668+ "sources": [],
4669+ "gpg-keys": [],
4670+ "operation-id": 3,
4671+ },
4672+ )
4673+
4674+ self.assertFalse(os.path.exists(first_sources_path))
4675+
4676+ self.assertFalse(os.path.exists(second_sources_path))
4677+
4678 def test_sources_list_permissions(self):
4679 """
4680 When getting a repository message, L{AptSources} keeps sources.list
4681@@ -260,70 +380,71 @@ class AptSourcesTests(LandscapeTest):
4682
4683 def test_renames_sources_list_d(self):
4684 """
4685- The sources files in sources.list.d are renamed to .save when a message
4686- is received if config says to manage them, which is the default.
4687+ The sources files (.list, .sources) in sources.list.d
4688+ are renamed to .save when a message is received
4689+ if config says to manage them, which is the default.
4690 """
4691+ FILE_1_LIST = os.path.join(self.sourceslist.SOURCES_LIST_D, "file1.list")
4692+
4693+ FILE_2_SOURCES = os.path.join(self.sourceslist.SOURCES_LIST_D, "file2.sources")
4694 with open(
4695- os.path.join(self.sourceslist.SOURCES_LIST_D, "file1.list"),
4696+ FILE_1_LIST,
4697 "w",
4698- ) as sources1:
4699- sources1.write("ok\n")
4700+ ) as source1:
4701+ source1.write("ok\n")
4702
4703 with open(
4704- os.path.join(self.sourceslist.SOURCES_LIST_D, "file2.list.save"),
4705+ FILE_2_SOURCES,
4706 "w",
4707- ) as sources2:
4708- sources2.write("ok\n")
4709+ ) as source2:
4710+ source2.write("ok\n")
4711
4712 self.manager.dispatch_message(
4713 {
4714 "type": "apt-sources-replace",
4715- "sources": [],
4716+ "sources": [{"name": "bla", "content": b""}],
4717 "gpg-keys": [],
4718 "operation-id": 1,
4719 },
4720 )
4721
4722+ self.assertTrue(
4723+ os.path.exists(self.sourceslist.SOURCES_LIST),
4724+ )
4725+
4726 self.assertFalse(
4727- os.path.exists(
4728- os.path.join(self.sourceslist.SOURCES_LIST_D, "file1.list"),
4729- ),
4730+ os.path.exists(FILE_1_LIST),
4731 )
4732
4733+ self.assertFalse(os.path.exists(FILE_2_SOURCES))
4734+
4735 self.assertTrue(
4736- os.path.exists(
4737- os.path.join(
4738- self.sourceslist.SOURCES_LIST_D,
4739- "file1.list.save",
4740- ),
4741- ),
4742+ os.path.exists(f"{FILE_1_LIST}.save"),
4743 )
4744
4745 self.assertTrue(
4746- os.path.exists(
4747- os.path.join(
4748- self.sourceslist.SOURCES_LIST_D,
4749- "file2.list.save",
4750- ),
4751- ),
4752+ os.path.exists(f"{FILE_2_SOURCES}.save"),
4753 )
4754
4755 def test_does_not_rename_sources_list_d(self):
4756 """
4757- The sources files in sources.list.d are not renamed to .save when a
4758- message is received if config says not to manage them.
4759+ The sources files (.list, .sources) in sources.list.d
4760+ are not renamed to .save when a message is received
4761+ if config says not to manage them
4762 """
4763+ FILE_3_LIST = os.path.join(self.sourceslist.SOURCES_LIST_D, "file3.list")
4764+
4765+ FILE_4_SOURCES = os.path.join(self.sourceslist.SOURCES_LIST_D, "file4.sources")
4766 with open(
4767- os.path.join(self.sourceslist.SOURCES_LIST_D, "file1.list"),
4768+ FILE_3_LIST,
4769 "w",
4770- ) as sources1:
4771- sources1.write("ok\n")
4772-
4773+ ) as source3:
4774+ source3.write("ok\n")
4775 with open(
4776- os.path.join(self.sourceslist.SOURCES_LIST_D, "file2.list.save"),
4777+ FILE_4_SOURCES,
4778 "w",
4779- ) as sources2:
4780- sources2.write("ok\n")
4781+ ) as source4:
4782+ source4.write("ok\n")
4783
4784 self.manager.config.manage_sources_list_d = False
4785 self.manager.dispatch_message(
4786@@ -336,27 +457,19 @@ class AptSourcesTests(LandscapeTest):
4787 )
4788
4789 self.assertTrue(
4790- os.path.exists(
4791- os.path.join(self.sourceslist.SOURCES_LIST_D, "file1.list"),
4792- ),
4793+ os.path.exists(FILE_3_LIST),
4794+ )
4795+
4796+ self.assertTrue(
4797+ os.path.exists(FILE_4_SOURCES),
4798 )
4799
4800 self.assertFalse(
4801- os.path.exists(
4802- os.path.join(
4803- self.sourceslist.SOURCES_LIST_D,
4804- "file1.list.save",
4805- ),
4806- ),
4807+ os.path.exists(f"{FILE_3_LIST}.save"),
4808 )
4809
4810- self.assertTrue(
4811- os.path.exists(
4812- os.path.join(
4813- self.sourceslist.SOURCES_LIST_D,
4814- "file2.list.save",
4815- ),
4816- ),
4817+ self.assertFalse(
4818+ os.path.exists(f"{FILE_4_SOURCES}.save"),
4819 )
4820
4821 def test_create_landscape_sources(self):
4822@@ -417,7 +530,7 @@ class AptSourcesTests(LandscapeTest):
4823 gpg_dirpath = self.sourceslist.TRUSTED_GPG_D
4824 for filename in os.listdir(gpg_dirpath):
4825 filepath = os.path.join(gpg_dirpath, filename)
4826- with open(filepath, "r") as fh:
4827+ with open(filepath) as fh:
4828 keys.append(fh.read())
4829
4830 self.assertCountEqual(keys, gpg_keys)
4831@@ -482,20 +595,22 @@ class AptSourcesTests(LandscapeTest):
4832
4833 self.sourceslist._run_process = _run_process
4834
4835- with mock.patch.multiple(
4836- "landscape.client.manager.aptsources",
4837- USER="root",
4838- GROUP="root",
4839+ with (
4840+ mock.patch.multiple(
4841+ "landscape.client.manager.aptsources",
4842+ USER="root",
4843+ GROUP="root",
4844+ ),
4845+ mock.patch("os.getuid") as getuid,
4846 ):
4847- with mock.patch("os.getuid") as getuid:
4848- getuid.return_value = 0
4849- self.manager.dispatch_message(
4850- {
4851- "type": "apt-sources-replace",
4852- "sources": [],
4853- "gpg-keys": [],
4854- "operation-id": 1,
4855- },
4856- )
4857+ getuid.return_value = 0
4858+ self.manager.dispatch_message(
4859+ {
4860+ "type": "apt-sources-replace",
4861+ "sources": [],
4862+ "gpg-keys": [],
4863+ "operation-id": 1,
4864+ },
4865+ )
4866
4867 return deferred
4868diff --git a/landscape/client/manager/tests/test_config.py b/landscape/client/manager/tests/test_config.py
4869index 1fdd57a..cd90e27 100644
4870--- a/landscape/client/manager/tests/test_config.py
4871+++ b/landscape/client/manager/tests/test_config.py
4872@@ -1,5 +1,4 @@
4873-from landscape.client.manager.config import ALL_PLUGINS
4874-from landscape.client.manager.config import ManagerConfiguration
4875+from landscape.client.manager.config import ALL_PLUGINS, ManagerConfiguration
4876 from landscape.client.manager.scriptexecution import ALL_USERS
4877 from landscape.client.tests.helpers import LandscapeTest
4878
4879@@ -25,6 +24,8 @@ class ManagerConfigurationTest(LandscapeTest):
4880 "UbuntuProInfo",
4881 "LivePatch",
4882 "UbuntuProRebootRequired",
4883+ "ProManagement",
4884+ "FDERecoveryKeyManager",
4885 ],
4886 ALL_PLUGINS,
4887 )
4888diff --git a/landscape/client/manager/tests/test_customgraph.py b/landscape/client/manager/tests/test_customgraph.py
4889index b712a75..5f6e3fb 100644
4890--- a/landscape/client/manager/tests/test_customgraph.py
4891+++ b/landscape/client/manager/tests/test_customgraph.py
4892@@ -1,22 +1,18 @@
4893 import logging
4894 import os
4895 import pwd
4896-from unittest import mock
4897-from unittest import skipIf
4898+from unittest import mock, skipIf
4899
4900 from twisted.internet.error import ProcessDone
4901 from twisted.python.failure import Failure
4902
4903 from landscape.client.manager.customgraph import CustomGraphPlugin
4904 from landscape.client.manager.store import ManagerStore
4905-from landscape.client.tests.helpers import LandscapeTest
4906-from landscape.client.tests.helpers import ManagerHelper
4907-from landscape.lib.testing import DummyProcess
4908-from landscape.lib.testing import StubProcessFactory
4909+from landscape.client.tests.helpers import LandscapeTest, ManagerHelper
4910+from landscape.lib.testing import DummyProcess, StubProcessFactory
4911
4912
4913 class CustomGraphManagerTests(LandscapeTest):
4914-
4915 helpers = [ManagerHelper]
4916
4917 def setUp(self):
4918@@ -192,16 +188,12 @@ class CustomGraphManagerTests(LandscapeTest):
4919 123: {
4920 "error": "",
4921 "values": [(300, 1.0)],
4922- "script-hash": (
4923- b"483f2304b49063680c75e3c9e09cf6d0"
4924- ),
4925+ "script-hash": (b"483f2304b49063680c75e3c9e09cf6d0"),
4926 },
4927 124: {
4928 "error": "",
4929 "values": [(300, 2.0)],
4930- "script-hash": (
4931- b"73a74b1530b2256db7edacb9b9cc385e"
4932- ),
4933+ "script-hash": (b"73a74b1530b2256db7edacb9b9cc385e"),
4934 },
4935 },
4936 "type": "custom-graph",
4937@@ -226,9 +218,7 @@ class CustomGraphManagerTests(LandscapeTest):
4938 123: {
4939 "error": " (process exited with code 1)",
4940 "values": [],
4941- "script-hash": (
4942- b"eaca3ba1a3bf1948876eba320148c5e9"
4943- ),
4944+ "script-hash": (b"eaca3ba1a3bf1948876eba320148c5e9"),
4945 },
4946 },
4947 "type": "custom-graph",
4948@@ -264,9 +254,7 @@ class CustomGraphManagerTests(LandscapeTest):
4949 "number: 'foobar'"
4950 ),
4951 "values": [],
4952- "script-hash": (
4953- b"baab6c16d9143523b7865d46896e4596"
4954- ),
4955+ "script-hash": (b"baab6c16d9143523b7865d46896e4596"),
4956 },
4957 },
4958 "type": "custom-graph",
4959@@ -298,13 +286,10 @@ class CustomGraphManagerTests(LandscapeTest):
4960 "data": {
4961 123: {
4962 "error": (
4963- "NoOutputError: Script did not output "
4964- "any value"
4965+ "NoOutputError: Script did not output any value"
4966 ),
4967 "values": [],
4968- "script-hash": (
4969- b"baab6c16d9143523b7865d46896e4596"
4970- ),
4971+ "script-hash": (b"baab6c16d9143523b7865d46896e4596"),
4972 },
4973 },
4974 "type": "custom-graph",
4975@@ -338,19 +323,14 @@ class CustomGraphManagerTests(LandscapeTest):
4976 "data": {
4977 123: {
4978 "error": (
4979- "NoOutputError: Script did not output "
4980- "any value"
4981- ),
4982- "script-hash": (
4983- b"baab6c16d9143523b7865d46896e4596"
4984+ "NoOutputError: Script did not output any value"
4985 ),
4986+ "script-hash": (b"baab6c16d9143523b7865d46896e4596"),
4987 "values": [],
4988 },
4989 124: {
4990 "error": "",
4991- "script-hash": (
4992- b"baab6c16d9143523b7865d46896e4596"
4993- ),
4994+ "script-hash": (b"baab6c16d9143523b7865d46896e4596"),
4995 "values": [(300, 0.5)],
4996 },
4997 },
4998@@ -388,19 +368,14 @@ class CustomGraphManagerTests(LandscapeTest):
4999 "InvalidFormatError: Failed to convert "
5000 "to number: 'foo'"
The diff has been truncated for viewing.

Subscribers

People subscribed via source and target branches