Merge ~mitchburton/ubuntu/+source/landscape-client:ubuntu/noble-devel into ubuntu/+source/landscape-client:ubuntu/noble-devel

Proposed by Mitch Burton
Status: Merged
Approved by: Andreas Hasenack
Approved revision: 6a9c0e4a9ac1cd2c01ebd93dca12f862c0d5b694
Merge reported by: Andreas Hasenack
Merged at revision: 6a9c0e4a9ac1cd2c01ebd93dca12f862c0d5b694
Proposed branch: ~mitchburton/ubuntu/+source/landscape-client:ubuntu/noble-devel
Merge into: ubuntu/+source/landscape-client:ubuntu/noble-devel
Diff against target: 14522 lines (+8965/-1267)
133 files modified
.github/workflows/ci.yml (+8/-16)
.github/workflows/codecov.yml (+19/-0)
.gitmodules (+3/-0)
Makefile (+16/-38)
Makefile.packaging (+2/-2)
README (+14/-3)
debian/changelog (+31/-0)
debian/control (+4/-3)
debian/landscape-common.postinst (+2/-4)
debian/landscape-common.prerm (+0/-1)
debian/rules (+1/-1)
debian/watch (+3/-2)
dev/null (+0/-92)
example.conf (+20/-4)
landscape/__init__.py (+1/-1)
landscape/client/__init__.py (+11/-0)
landscape/client/broker/client.py (+5/-1)
landscape/client/broker/config.py (+10/-3)
landscape/client/broker/ping.py (+8/-4)
landscape/client/broker/registration.py (+4/-4)
landscape/client/broker/store.py (+9/-3)
landscape/client/broker/tests/test_config.py (+27/-3)
landscape/client/broker/tests/test_exchange.py (+4/-4)
landscape/client/broker/tests/test_ping.py (+8/-4)
landscape/client/broker/tests/test_registration.py (+45/-1)
landscape/client/broker/tests/test_store.py (+3/-3)
landscape/client/configuration.py (+41/-17)
landscape/client/deployment.py (+79/-1)
landscape/client/manager/aptsources.py (+4/-2)
landscape/client/manager/config.py (+2/-0)
landscape/client/manager/hardwareinfo.py (+1/-1)
landscape/client/manager/scriptexecution.py (+17/-7)
landscape/client/manager/service.py (+22/-7)
landscape/client/manager/shutdownmanager.py (+79/-157)
landscape/client/manager/snapmanager.py (+56/-43)
landscape/client/manager/snapservicesmanager.py (+60/-0)
landscape/client/manager/tests/test_aptsources.py (+43/-0)
landscape/client/manager/tests/test_config.py (+2/-0)
landscape/client/manager/tests/test_processkiller.py (+1/-1)
landscape/client/manager/tests/test_scriptexecution.py (+44/-7)
landscape/client/manager/tests/test_service.py (+45/-6)
landscape/client/manager/tests/test_shutdownmanager.py (+25/-169)
landscape/client/manager/tests/test_snapmanager.py (+164/-40)
landscape/client/manager/tests/test_snapservicesmanager.py (+333/-0)
landscape/client/manager/tests/test_ubuntuproinfo.py (+151/-0)
landscape/client/manager/tests/test_usermanager.py (+132/-2)
landscape/client/manager/ubuntuproinfo.py (+102/-0)
landscape/client/manager/usermanager.py (+12/-15)
landscape/client/monitor/computerinfo.py (+37/-6)
landscape/client/monitor/config.py (+1/-1)
landscape/client/monitor/processorinfo.py (+28/-13)
landscape/client/monitor/service.py (+6/-3)
landscape/client/monitor/snapmonitor.py (+21/-10)
landscape/client/monitor/snapservicesmonitor.py (+28/-0)
landscape/client/monitor/tests/test_computerinfo.py (+84/-35)
landscape/client/monitor/tests/test_processorinfo.py (+74/-2)
landscape/client/monitor/tests/test_service.py (+29/-0)
landscape/client/monitor/tests/test_snapmonitor.py (+75/-12)
landscape/client/monitor/tests/test_snapservicesmonitor.py (+127/-0)
landscape/client/monitor/tests/test_temperature.py (+2/-2)
landscape/client/monitor/tests/test_usermonitor.py (+45/-0)
landscape/client/monitor/updatemanager.py (+1/-1)
landscape/client/monitor/usermonitor.py (+9/-2)
landscape/client/package/changer.py (+19/-22)
landscape/client/package/releaseupgrader.py (+10/-8)
landscape/client/package/reporter.py (+9/-10)
landscape/client/package/taskhandler.py (+6/-10)
landscape/client/package/tests/test_changer.py (+10/-141)
landscape/client/package/tests/test_releaseupgrader.py (+10/-13)
landscape/client/package/tests/test_reporter.py (+42/-33)
landscape/client/package/tests/test_taskhandler.py (+12/-25)
landscape/client/serviceconfig.py (+2/-2)
landscape/client/snap_http (+1/-0)
landscape/client/snap_utils.py (+86/-0)
landscape/client/tests/test_configuration.py (+86/-9)
landscape/client/tests/test_deployment.py (+283/-0)
landscape/client/tests/test_serviceconfig.py (+2/-0)
landscape/client/tests/test_snap_utils.py (+175/-0)
landscape/client/tests/test_watchdog.py (+21/-1)
landscape/client/user/management.py (+107/-12)
landscape/client/user/tests/helpers.py (+60/-12)
landscape/client/user/tests/test_management.py (+251/-36)
landscape/client/watchdog.py (+19/-29)
landscape/lib/bpickle.py (+12/-2)
landscape/lib/format.py (+22/-0)
landscape/lib/os_release.py (+64/-0)
landscape/lib/sysstats.py (+28/-34)
landscape/lib/testing.py (+2/-2)
landscape/lib/tests/test_bpickle.py (+8/-2)
landscape/lib/tests/test_format.py (+89/-0)
landscape/lib/tests/test_os_release.py (+137/-0)
landscape/lib/tests/test_reactor.py (+3/-3)
landscape/lib/tests/test_schema.py (+2/-1)
landscape/lib/tests/test_sysstats.py (+128/-16)
landscape/message_schemas/server_bound.py (+59/-1)
landscape/sysinfo/tests/test_temperature.py (+9/-9)
setup_client.py (+2/-1)
setup_lib.py (+0/-1)
snap-http/.github/workflows/daily.yml (+11/-0)
snap-http/.github/workflows/integration-test.yml (+34/-0)
snap-http/.github/workflows/test.yml (+46/-0)
snap-http/.gitignore (+15/-0)
snap-http/CHANGELOG.md (+17/-0)
snap-http/LICENSE (+339/-0)
snap-http/Makefile (+6/-0)
snap-http/README.md (+50/-0)
snap-http/poetry.lock (+411/-0)
snap-http/pyproject.toml (+40/-0)
snap-http/snap_http/__init__.py (+53/-0)
snap-http/snap_http/api.py (+443/-0)
snap-http/snap_http/http.py (+111/-0)
snap-http/snap_http/types.py (+164/-0)
snap-http/tests/integration/conftest.py (+61/-0)
snap-http/tests/integration/test_api.py (+590/-0)
snap-http/tests/integration/test_snap/setup.py (+16/-0)
snap-http/tests/integration/test_snap/snap/hooks/configure (+24/-0)
snap-http/tests/integration/test_snap/snap/hooks/default-configure (+1/-0)
snap-http/tests/integration/test_snap/snap/snapcraft.yaml (+28/-0)
snap-http/tests/integration/test_snap/test_snap/__init__.py (+0/-0)
snap-http/tests/integration/test_snap/test_snap/__main__.py (+3/-0)
snap-http/tests/integration/test_snap/test_snap/bye-svc (+11/-0)
snap-http/tests/integration/test_snap/test_snap/hello-svc (+11/-0)
snap-http/tests/integration/test_snap/test_snap/main.py (+2/-0)
snap-http/tests/unit/__init__.py (+0/-0)
snap-http/tests/unit/test_api.py (+1841/-0)
snap-http/tests/unit/test_http.py (+292/-0)
snap-http/tests/unit/test_types.py (+110/-0)
snap-http/tests/utils.py (+69/-0)
snap-http/tox.ini (+18/-0)
snap/hooks/configure (+3/-0)
snap/hooks/default-configure (+33/-0)
snap/hooks/install (+5/-0)
snap/snapcraft.yaml (+91/-73)
Reviewer Review Type Date Requested Status
Andreas Hasenack Approve
git-ubuntu import Pending
Review via email: mp+461404@code.launchpad.net

Commit message

release landscape-client 24.02 for noble

Description of the change

This change imports the latest release of landscape-client, 24.02 and releases it for noble.

A build of this release can be found in this PPA: https://launchpad.net/~mitchburton/+archive/ubuntu/landscape-client-uploads

To post a comment you must log in.
Revision history for this message
Andreas Hasenack (ahasenack) wrote :

Grabbing a review slot

Revision history for this message
Andreas Hasenack (ahasenack) wrote :

Please use ubuntu/devel as the MP target in the future, for devel releases. Right now it won't matter, but it's customary to use ubuntu/devel in these cases, and <release-name>/devel for SRUs.

Revision history for this message
Andreas Hasenack (ahasenack) wrote :
Download full text (4.2 KiB)

I downloaded the landscape-client tarball via uscan, which uses the debian/watch file:

$ uscan --download-current-version
Newest version of landscape-client on remote site is 24.02, specified download version is 24.02
Successfully symlinked ../landscape-client-24.02.tar.gz to ../landscape-client_24.02.orig.tar.gz.

This tarball however does not match the contents of the branch here:

$ dpkg-buildpackage -S -I -i -nc -d
dpkg-buildpackage: info: source package landscape-client
dpkg-buildpackage: info: source version 24.02-0ubuntu0
dpkg-buildpackage: info: source distribution noble
dpkg-buildpackage: info: source changed by Mitch Burton <email address hidden>
 dpkg-source -I -i --before-build .
dpkg-buildpackage: warning: building a source package without cleaning up as you asked; it might contain undesired files
 dpkg-source -I -i -b .
dpkg-source: info: using source format '3.0 (quilt)'
dpkg-source: info: building landscape-client using existing ./landscape-client_24.02.orig.tar.gz
dpkg-source: error: cannot represent change to snap-http/tests/integration/test_snap/snap/hooks/default-configure:
dpkg-source: error: new version is symlink to configure
dpkg-source: error: old version is nonexistent
dpkg-source: warning: newly created empty file 'snap-http/tests/__init__.py' will not be represented in diff
dpkg-source: warning: newly created empty file 'snap-http/tests/integration/__init__.py' will not be represented in diff
dpkg-source: warning: newly created empty file 'snap-http/tests/integration/test_snap/test_snap/__init__.py' will not be represented in diff
dpkg-source: warning: newly created empty file 'snap-http/tests/unit/__init__.py' will not be represented in diff
dpkg-source: error: unrepresentable changes to source
dpkg-buildpackage: error: dpkg-source -I -i -b . subprocess returned exit status 1

Indeed, there are differences:
$ diff -uNr landscape-client-24.02/ landscape-client/ --exclude debian --exclude .git|diffstat
diff: landscape-client-24.02/landscape/client/snap_http: No such file or directory
 .github/workflows/daily.yml | 11
 .github/workflows/integration-test.yml | 34 +
 .github/workflows/test.yml | 46 ++
 .gitignore | 15
 CHANGELOG.md | 17
 LICENSE | 339 +++++++++++++++++++
 Makefile | 6
 README.md | 50 ++
 poetry.lock | 411 ++++++++++++++++++++++++
 pyproject.toml | 40 ++
 snap_http/__init__.py | 53 +++
 snap_http/api.py | 443 +++++++++++++++++++++++++
 snap_http/http.py | 111 ++++++
 snap_http/types.py | 164 +++++++++
 tests/integration/conftest.py | 61 +++
 tests/integration/test_api.py ...

Read more...

review: Needs Fixing
Revision history for this message
Andreas Hasenack (ahasenack) wrote :

I remember you mentioning the snap work, and that a lot of it does not "make sense" for debs. But the source tarball that you, upstream, release, must match what is used by the deb to build the package. So you either make separate releases (snap-only, and everything else), or include the snap bits in the tarball used by the deb source package, or something else.

Revision history for this message
Andreas Hasenack (ahasenack) :
Revision history for this message
Andreas Hasenack (ahasenack) :
Revision history for this message
Andreas Hasenack (ahasenack) wrote :

Ah, disregard the test errors from above. That was another version I was building by mistake.

Revision history for this message
Mitch Burton (mitchburton) wrote :

The uscan difference is because the watch file is pointing at the tags page in Github and not the releases page. We've moved to using the latter for release artifacts. I will correct shortly.

58a9f27... by Mitch Burton

d/watch: use github releases tarball instead of tag tarball

Revision history for this message
Mitch Burton (mitchburton) wrote :

$ uscan --download-current-version
Newest version of landscape-client on remote site is 24.02, specified download version is 24.02
Leaving ../landscape-client_24.02.orig.tar.gz where it is.

$ diff -uNr landscape-client-24.02/ landscape-client/ --exclude debian --exclude .git|diffstat
 0 files changed

Revision history for this message
Andreas Hasenack (ahasenack) :
Revision history for this message
Mitch Burton (mitchburton) wrote :

changed version to -0ubuntu1 and fixed changelog indentation

Revision history for this message
Andreas Hasenack (ahasenack) wrote :

Inline question about expandvars().

review: Needs Information
Revision history for this message
Andreas Hasenack (ahasenack) wrote :

--- a/snap/snapcraft.yaml
+++ b/snap/snapcraft.yaml
@@ -1,98 +1,116 @@
 name: landscape-client
 base: core22
-version: '0.1'
+version: '23.08'

Does this version need to be 24.02?

Revision history for this message
Mitch Burton (mitchburton) wrote :

> --- a/snap/snapcraft.yaml
> +++ b/snap/snapcraft.yaml
> @@ -1,98 +1,116 @@
> name: landscape-client
> base: core22
> -version: '0.1'
> +version: '23.08'
>
> Does this version need to be 24.02?
Yes, this should be updated. It only affects the snap so I don't think it's impactful for the deb upload. Should I patch it here, anyways?

Revision history for this message
Andreas Hasenack (ahasenack) wrote :

> > --- a/snap/snapcraft.yaml
> > +++ b/snap/snapcraft.yaml
> > @@ -1,98 +1,116 @@
> > name: landscape-client
> > base: core22
> > -version: '0.1'
> > +version: '23.08'
> >
> > Does this version need to be 24.02?
> Yes, this should be updated. It only affects the snap so I don't think it's
> impactful for the deb upload. Should I patch it here, anyways?

If it's a no-op for the deb, then no need.

Revision history for this message
Mitch Burton (mitchburton) wrote :

inline response for expandvars

Revision history for this message
Andreas Hasenack (ahasenack) :
Revision history for this message
Mitch Burton (mitchburton) :
Revision history for this message
Mitch Burton (mitchburton) :
Revision history for this message
Andreas Hasenack (ahasenack) wrote :

d/changelog:

This new upstream version is adding snap-http/, which has its own LICENSE file. It is the same license as landscape-client itself (debian/copyright), so that's ok. SRU reviewers might want an explicit update to d/copyright about it, though. I'm unsure.

The new snap-http code should be called out in d/changelog, together with other debian/* changes:
- the build-dependency changes (python3-dbus)
- the runtime dependency changes (lsb-base dropped, python3-setuptools added, python3-dbus added)
- dropped patches
- d/watch update

The amount of detail you want in d/changelog about the *upstream* changes is up to you. You should definitely highlight important changes in upstream code. But packaging changes, in general, we like to have described. This will receive much more scrutiny in an SRU. In fact, it's grounds for a rejection if a packaging change (anything in debian/*) is not described in the changelog.

Revision history for this message
Mitch Burton (mitchburton) wrote :

I've updated the changelog to call out the specific changes to d/control, d/watch, and the addition of snap-http. Hopefully I've added enough detail in there.

Thanks for the additional feedback on how this might look for SRU review.

Revision history for this message
Andreas Hasenack (ahasenack) wrote :

+1. And let's get #2055348 closed soon.

review: Approve
Revision history for this message
Andreas Hasenack (ahasenack) wrote :

Sponsored:
Uploading landscape-client_24.02-0ubuntu1.dsc
Uploading landscape-client_24.02.orig.tar.gz
Uploading landscape-client_24.02-0ubuntu1.debian.tar.xz
Uploading landscape-client_24.02-0ubuntu1_source.buildinfo
Uploading landscape-client_24.02-0ubuntu1_source.changes

Revision history for this message
Andreas Hasenack (ahasenack) wrote :

This migrated already, closing MP.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
2index 54fd142..38efb55 100644
3--- a/.github/workflows/ci.yml
4+++ b/.github/workflows/ci.yml
5@@ -1,34 +1,26 @@
6 name: ci
7 on: [pull_request, workflow_dispatch]
8 jobs:
9- check3:
10+ check:
11 runs-on: ${{ matrix.os }}
12 strategy:
13 matrix:
14 os: ["ubuntu-22.04", "ubuntu-20.04"]
15 steps:
16- - uses: actions/checkout@v2
17+ - uses: actions/checkout@v4
18+ with:
19+ submodules: true
20 - run: |
21 make depends
22 # -common seems a catch-22, but this is just a shortcut to
23 # initialize user and dirs, some used through tests.
24 sudo apt-get -y install landscape-common
25- - run: make check3 TRIAL=/usr/bin/trial3
26+ - run: make check TRIAL=/usr/bin/trial3
27 lint:
28 runs-on: ubuntu-latest
29 steps:
30- - uses: actions/checkout@v2
31+ - uses: actions/checkout@v4
32+ with:
33+ submodules: true
34 - run: make depends
35 - run: make lint
36- coverage:
37- runs-on: ubuntu-latest
38- steps:
39- - uses: actions/checkout@v2
40- - run: |
41- make depends
42- # -common seems a catch-22, but this is just a shortcut to
43- # initialize user and dirs, some used through tests.
44- sudo apt-get -y install landscape-common
45- - run: make coverage TRIAL=/usr/bin/trial3
46- - name: upload
47- uses: codecov/codecov-action@v1
48diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml
49new file mode 100644
50index 0000000..74ec228
51--- /dev/null
52+++ b/.github/workflows/codecov.yml
53@@ -0,0 +1,19 @@
54+name: Codecov upload
55+
56+on: [push, pull_request]
57+
58+jobs:
59+ coverage:
60+ runs-on: ubuntu-latest
61+ steps:
62+ - uses: actions/checkout@v4
63+ with:
64+ submodules: true
65+ - run: |
66+ make depends
67+ # -common seems a catch-22, but this is just a shortcut to
68+ # initialize user and dirs, some used through tests.
69+ sudo apt-get -y install landscape-common
70+ - run: make coverage TRIAL=/usr/bin/trial3
71+ - name: upload
72+ uses: codecov/codecov-action@v3
73diff --git a/.gitmodules b/.gitmodules
74new file mode 100644
75index 0000000..ab50d1c
76--- /dev/null
77+++ b/.gitmodules
78@@ -0,0 +1,3 @@
79+[submodule "snap-http"]
80+ path = snap-http
81+ url = https://github.com/Perfect5th/snap-http
82diff --git a/Makefile b/Makefile
83index 09ade0e..9626920 100644
84--- a/Makefile
85+++ b/Makefile
86@@ -1,10 +1,10 @@
87 PYDOCTOR ?= pydoctor
88 TXT2MAN ?= txt2man
89-PYTHON2 ?= python2
90-PYTHON3 ?= python3
91+PYTHON ?= python3
92 SNAPCRAFT = SNAPCRAFT_BUILD_INFO=1 snapcraft
93 TRIAL ?= -m twisted.trial
94 TRIAL_ARGS ?=
95+PRE_COMMIT ?= $(HOME)/.local/bin/pre-commit
96
97 # PEP8 rules ignored:
98 # W503 https://www.flake8rules.com/rules/W503.html
99@@ -16,57 +16,35 @@ help: ## Print help about available targets
100 @grep -h -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
101
102 .PHONY: depends
103-depends: depends3 ## py2 is deprecated
104- sudo apt-get -y install python3-flake8 python3-coverage
105-
106-.PHONY: depends2
107-depends2:
108- sudo apt-get -y install python-twisted-core python-distutils-extra python-mock python-configobj python-netifaces python-pycurl python-pip
109+depends:
110+ sudo apt-get -y install python3-configobj python3-coverage python3-distutils-extra\
111+ python3-flake8 python3-mock python3-netifaces python3-pip python3-pycurl python3-twisted\
112+ net-tools
113 pip install pre-commit
114- pre-commit install
115-
116-.PHONY: depends3
117-depends3:
118- sudo apt-get -y install python3-twisted python3-distutils-extra python3-mock python3-configobj python3-netifaces python3-pycurl python3-pip
119- pip3 install pre-commit
120- pre-commit install
121+ $(PRE_COMMIT) install
122
123 all: build
124
125 .PHONY: build
126-build: build2 build3 ## Build.
127-
128-.PHONY: build2
129-build2:
130- $(PYTHON2) setup.py build_ext -i
131-
132-.PHONY: build3
133-build3:
134- $(PYTHON3) setup.py build_ext -i
135-
136-.PHONY: check
137-check: check2 check3 ## Run all the tests.
138-
139-.PHONY: check2
140-check2: build2
141- PYTHONPATH=$(PYTHONPATH):$(CURDIR) LC_ALL=C $(PYTHON2) $(TRIAL) --unclean-warnings $(TRIAL_ARGS) landscape
142+build:
143+ $(PYTHON) setup.py build_ext -i
144
145 # trial3 does not support threading via `-j` at the moment
146 # so we ignore TRIAL_ARGS.
147 # TODO: Respect $TRIAL_ARGS once trial3 is fixed.
148-.PHONY: check3
149-check3: TRIAL_ARGS=
150-check3: build3
151- PYTHONPATH=$(PYTHONPATH):$(CURDIR) LC_ALL=C $(PYTHON3) $(TRIAL) --unclean-warnings $(TRIAL_ARGS) landscape
152+.PHONY: check
153+check: TRIAL_ARGS=
154+check: build
155+ PYTHONPATH=$(PYTHONPATH):$(CURDIR) LC_ALL=C $(PYTHON) $(TRIAL) --unclean-warnings $(TRIAL_ARGS) landscape
156
157 .PHONY: coverage
158 coverage:
159- PYTHONPATH=$(PYTHONPATH):$(CURDIR) LC_ALL=C $(PYTHON3) -m coverage run $(TRIAL) --unclean-warnings landscape
160- PYTHONPATH=$(PYTHONPATH):$(CURDIR) LC_ALL=C $(PYTHON3) -m coverage xml
161+ PYTHONPATH=$(PYTHONPATH):$(CURDIR) LC_ALL=C $(PYTHON) -m coverage run $(TRIAL) --unclean-warnings landscape
162+ PYTHONPATH=$(PYTHONPATH):$(CURDIR) LC_ALL=C $(PYTHON) -m coverage xml
163
164 .PHONY: lint
165 lint:
166- $(PYTHON3) -m flake8 --ignore $(PEP8_IGNORED) `find landscape -name \*.py`
167+ $(PYTHON) -m flake8 --ignore $(PEP8_IGNORED) `find landscape -name \*.py`
168
169 .PHONY: pyflakes
170 pyflakes:
171diff --git a/Makefile.packaging b/Makefile.packaging
172index 5a43ce7..d3c90ad 100644
173--- a/Makefile.packaging
174+++ b/Makefile.packaging
175@@ -60,8 +60,8 @@ releasetarball:
176
177 .PHONY: sdist
178 sdist: clean
179- mkdir -p sdist
180- git archive --prefix landscape-client-$(TARBALL_VERSION)/ HEAD | tar -x -C sdist
181+ mkdir -p sdist/landscape-client-$(TARBALL_VERSION)
182+ git ls-files --recurse-submodules | xargs -I {} cp -r --parents {} sdist/landscape-client-$(TARBALL_VERSION)
183 rm -rf sdist/landscape-client-$(TARBALL_VERSION)/debian
184 sed -i -e "s/^UPSTREAM_VERSION.*/UPSTREAM_VERSION = \"$(TARBALL_VERSION)\"/g" \
185 sdist/landscape-client-$(TARBALL_VERSION)/landscape/__init__.py
186diff --git a/README b/README
187index 8751ef3..c8a4737 100644
188--- a/README
189+++ b/README
190@@ -58,6 +58,12 @@ Landscape service. There are two ways to do this:
191
192 ## Developing
193
194+After cloning the repository, make sure you run the following command to pull the `snap-http` submodule:
195+
196+```shell
197+git submodule update --init
198+```
199+
200 To run the full test suite, run the following command:
201
202 ```
203@@ -81,12 +87,17 @@ system bus.
204 $ sudo ./scripts/landscape-client -c root-client.conf
205 ```
206
207-Before opening a PR, make sure to run the full testsuite and lint
208+Before opening a PR, make sure to run the full test suite and lint:
209 ```
210-make check3
211+make check
212 make lint
213 ```
214
215+You can run a specific test by running the following (for example):
216+```
217+python3 -m twisted.trial landscape.client.broker.tests.test_client.BrokerClientTest.test_ping
218+```
219+
220 ### Building the Landscape Client snap
221
222 First, you need to ensure that you have the appropriate tools installed:
223@@ -158,7 +169,7 @@ $ gnome-keyring-daemon --unlock
224 The gnome-keyring-daemon will prompt you (without a prompt) to type in
225 the initial unlock password (typically the same password for the account
226 you are using - if you are using the default multipass or lxc "ubuntu"
227-login, use sudo passwd ubuntu to set it to a known value before doing
228+login, use sudo passwd ubuntu to set it to a known value before doing
229 the above step).
230
231 Type the login password and hit <ENTER> followed by <CTRL>+D to end
232diff --git a/debian/changelog b/debian/changelog
233index 5643e19..6aa9e2c 100644
234--- a/debian/changelog
235+++ b/debian/changelog
236@@ -1,3 +1,34 @@
237+landscape-client (24.02-0ubuntu1) noble; urgency=medium
238+
239+ * New upstream release 24.02
240+ - d/control: added python3-dbus to Build-Depends
241+ - d/control: added python3-dbus to landscape-client Depends. Added
242+ python3-setuptools to landscape-common Depends. Removed lsb-base from
243+ landscape-common Depends.
244+ - d/control: use dephelper-compat 12, removed d/compat
245+ - d/patches: removed patches that have been applied upstream
246+ - d/watch: watch Github releases page instead of tags
247+ - snap-http: included new submodule under identical license
248+ - use dbus in shutdown manager instead of subprocess
249+ - improve messages about manager plugin configuration
250+ - add getting and settings snap configurations
251+ - read temperature from hwmon devices
252+ - add snapd device information to ComputerInfo message
253+ - add snap services management
254+ - d/landscape-common.postinst d/landscape-common.prerm: do not call
255+ update-motd when installing/removing landscape-sysinfo.wrapper
256+ (LP: #1855544)
257+ - Livepatch status reporting bug
258+ - bpickle: guard against negative string/bytestring lengths
259+ - Makefile: remove check2, depends2, and references to python2
260+ - add path for os-release inside a snap
261+ - computer title generation for zero-touch deployment on Core
262+ - user management on Core
263+ - deploy system user assertion to device
264+ - remote script execution for snaps
265+
266+ -- Mitch Burton <mitch.burton@canonical.com> Thu, 27 Feb 2024 16:46:56 -0800
267+
268 landscape-client (23.08-0ubuntu4) noble; urgency=medium
269
270 * d/p/0003-fix-cpuinfo-and-tests.patch: fix ARM and RISCV cpuinfo parsing;
271diff --git a/debian/control b/debian/control
272index c70ceae..3a859a6 100644
273--- a/debian/control
274+++ b/debian/control
275@@ -8,7 +8,7 @@ Build-Depends: debhelper-compat (= 12), po-debconf, libdistro-info-perl,
276 lsb-release, gawk, net-tools,
277 python3-apt, python3-twisted, python3-configobj,
278 python3-pycurl, python3-netifaces, python3-yaml,
279- ubuntu-advantage-tools, locales-all
280+ ubuntu-advantage-tools, locales-all, python3-dbus
281 Standards-Version: 4.4.0
282 Homepage: https://github.com/CanonicalLtd/landscape-client
283
284@@ -22,11 +22,11 @@ Depends: ${python3:Depends}, ${misc:Depends}, ${extra:Depends},
285 python3-gdbm,
286 python3-netifaces,
287 lsb-release,
288- lsb-base,
289 adduser,
290 bc,
291 lshw,
292- libpam-modules
293+ libpam-modules,
294+ python3-setuptools
295 Description: Landscape administration system client - Common files
296 Landscape is a web-based tool for managing Ubuntu systems. This
297 package is necessary if you want your machine to be managed in a
298@@ -42,6 +42,7 @@ Depends: ${python3:Depends}, ${misc:Depends}, ${extra:Depends},
299 ${shlibs:Depends},
300 landscape-common (= ${binary:Version}),
301 python3-pycurl,
302+ python3-dbus
303 Description: Landscape administration system client
304 Landscape is a web-based tool for managing Ubuntu systems. This
305 package is necessary if you want your machine to be managed in a
306diff --git a/debian/landscape-common.postinst b/debian/landscape-common.postinst
307index e78c193..09826f5 100755
308--- a/debian/landscape-common.postinst
309+++ b/debian/landscape-common.postinst
310@@ -90,18 +90,16 @@ case "$1" in
311 WRAPPER=/usr/share/landscape/landscape-sysinfo.wrapper
312 PROFILE_LOCATION=/etc/profile.d/50-landscape-sysinfo.sh
313 UPDATE_MOTD_LOCATION=/etc/update-motd.d/50-landscape-sysinfo
314-
315 if [ "$RET" = "Cache sysinfo in /etc/motd" ]; then
316 rm -f $PROFILE_LOCATION 2>/dev/null || true
317 ln -sf $WRAPPER $UPDATE_MOTD_LOCATION
318- update-motd 2>/dev/null || true
319+ $WRAPPER >/dev/null 2>&1 || true
320 elif [ "$RET" = "Run sysinfo on every login" ]; then
321 rm -f $UPDATE_MOTD_LOCATION 2>/dev/null || true
322- update-motd 2>/dev/null || true
323 ln -sf $WRAPPER $PROFILE_LOCATION
324+ $WRAPPER >/dev/null 2>&1 || true
325 else
326 rm -f $UPDATE_MOTD_LOCATION 2>/dev/null || true
327- update-motd 2>/dev/null || true
328 rm -f $PROFILE_LOCATION || true
329 fi
330
331diff --git a/debian/landscape-common.prerm b/debian/landscape-common.prerm
332index 049aa63..14e35dc 100644
333--- a/debian/landscape-common.prerm
334+++ b/debian/landscape-common.prerm
335@@ -20,7 +20,6 @@ set -e
336 case "$1" in
337 remove|upgrade|deconfigure)
338 rm -f /etc/update-motd.d/50-landscape-sysinfo 2>/dev/null || true
339- update-motd 2>/dev/null || true
340 rm -f /etc/profile.d/landscape-sysinfo.sh 2>/dev/null || true
341 ;;
342
343diff --git a/debian/patches/0001-start-service-during-config.patch b/debian/patches/0001-start-service-during-config.patch
344deleted file mode 100644
345index d3fb5f9..0000000
346--- a/debian/patches/0001-start-service-during-config.patch
347+++ /dev/null
348@@ -1,269 +0,0 @@
349-Description: Allow landscape-config to start landscape-client systemd service
350-Bug-Ubuntu: https://bugs.launchpad.net/ubuntu/+source/landscape-client/+bug/2040189
351-Author: Mitch Burton <mitch.burton@canonical.com>
352-Origin: upstream, https://github.com/canonical/landscape-client/commit/0da6b4b64c7ca50c109279bd42633c537458fcf4
353-Reviewed-by: Kevin Nasto <kevin.nasto@canonical.com>
354-Applied-Upstream: 23.10
355-Last-Update: 2023-11-07
356----
357-This patch header follows DEP-3: http://dep.debian.net/deps/dep3/
358---- a/landscape/client/configuration.py
359-+++ b/landscape/client/configuration.py
360-@@ -624,8 +624,10 @@
361- decode_base64_ssl_public_certificate(config)
362- config.write()
363- # Restart the client to ensure that it's using the new configuration.
364-+
365- if not config.no_start:
366- try:
367-+ set_secure_id(config, "registering")
368- ServiceConfig.restart_landscape()
369- except ServiceConfigException as exc:
370- print_text(str(exc), error=True)
371-@@ -796,13 +798,14 @@
372- # Results will be things like "success" or "ssl-error".
373- result = results[0]
374-
375-- if isinstance(result, SystemExit):
376-- raise result
377--
378- # If there was an error and the caller requested that errors be reported
379- # to the on_error callable, then do so.
380- if result != "success" and on_error is not None:
381- on_error(1)
382-+
383-+ if isinstance(result, SystemExit):
384-+ raise result
385-+
386- return result
387-
388-
389-@@ -883,6 +886,21 @@
390- return text
391-
392-
393-+def set_secure_id(config, new_id):
394-+ """Persists a secure id in the identity data file. This is used to indicate
395-+ whether we are currently in the process of registering.
396-+ """
397-+ persist = Persist(
398-+ filename=os.path.join(
399-+ config.data_path,
400-+ f"{BrokerService.service_name}.bpickle",
401-+ ),
402-+ )
403-+ identity = Identity(config, persist)
404-+ identity.secure_id = new_id
405-+ persist.save()
406-+
407-+
408- def main(args, print=print):
409- """Interact with the user and the server to set up client configuration."""
410-
411-@@ -927,7 +945,11 @@
412- # Attempt to register the client.
413- reactor = LandscapeReactor()
414- if config.silent:
415-- result = register(config, reactor)
416-+ result = register(
417-+ config,
418-+ reactor,
419-+ on_error=lambda _: set_secure_id(config, None),
420-+ )
421- report_registration_outcome(result, print=print)
422- sys.exit(determine_exit_code(result))
423- else:
424-@@ -937,6 +959,10 @@
425- default=default_answer,
426- )
427- if answer:
428-- result = register(config, reactor)
429-+ result = register(
430-+ config,
431-+ reactor,
432-+ on_error=lambda _: set_secure_id(config, None),
433-+ )
434- report_registration_outcome(result, print=print)
435- sys.exit(determine_exit_code(result))
436---- a/landscape/client/tests/test_configuration.py
437-+++ b/landscape/client/tests/test_configuration.py
438-@@ -34,6 +34,7 @@
439- from landscape.client.configuration import register
440- from landscape.client.configuration import registration_info_text
441- from landscape.client.configuration import report_registration_outcome
442-+from landscape.client.configuration import set_secure_id
443- from landscape.client.configuration import setup
444- from landscape.client.configuration import show_help
445- from landscape.client.configuration import store_public_key_data
446-@@ -738,12 +739,17 @@
447- bootstrap_tree_patcher = mock.patch(
448- "landscape.client.configuration.bootstrap_tree",
449- )
450-+ set_secure_id_patch = mock.patch(
451-+ "landscape.client.configuration.set_secure_id",
452-+ )
453- self.mock_getuid = getuid_patcher.start()
454- self.mock_bootstrap_tree = bootstrap_tree_patcher.start()
455-+ set_secure_id_patch.start()
456-
457- def cleanup():
458- getuid_patcher.stop()
459- bootstrap_tree_patcher.stop()
460-+ set_secure_id_patch.stop()
461-
462- self.addCleanup(cleanup)
463-
464-@@ -1191,7 +1197,11 @@
465- )
466- self.assertEqual(0, exception.code)
467- mock_setup.assert_called_once_with(mock.ANY)
468-- mock_register.assert_called_once_with(mock.ANY, mock.ANY)
469-+ mock_register.assert_called_once_with(
470-+ mock.ANY,
471-+ mock.ANY,
472-+ on_error=mock.ANY,
473-+ )
474-
475- @mock.patch("landscape.client.configuration.input", return_value="y")
476- @mock.patch(
477-@@ -1219,7 +1229,11 @@
478- )
479- self.assertEqual(0, exception.code)
480- mock_setup.assert_called_once_with(mock.ANY)
481-- mock_register.assert_called_once_with(mock.ANY, mock.ANY)
482-+ mock_register.assert_called_once_with(
483-+ mock.ANY,
484-+ mock.ANY,
485-+ on_error=mock.ANY,
486-+ )
487- mock_input.assert_called_once_with(
488- "\nRequest a new registration for this computer now? [Y/n]: ",
489- )
490-@@ -1256,7 +1270,11 @@
491- )
492- self.assertEqual(2, exception.code)
493- mock_setup.assert_called_once_with(mock.ANY)
494-- mock_register.assert_called_once_with(mock.ANY, mock.ANY)
495-+ mock_register.assert_called_once_with(
496-+ mock.ANY,
497-+ mock.ANY,
498-+ on_error=mock.ANY,
499-+ )
500- mock_input.assert_called_once_with(
501- "\nRequest a new registration for this computer now? [Y/n]: ",
502- )
503-@@ -1295,7 +1313,11 @@
504- )
505- self.assertEqual(0, exception.code)
506- mock_setup.assert_called_once_with(mock.ANY)
507-- mock_register.assert_called_once_with(mock.ANY, mock.ANY)
508-+ mock_register.assert_called_once_with(
509-+ mock.ANY,
510-+ mock.ANY,
511-+ on_error=mock.ANY,
512-+ )
513- mock_input.assert_not_called()
514-
515- self.assertEqual(
516-@@ -1333,7 +1355,11 @@
517- )
518- self.assertEqual(2, exception.code)
519- mock_setup.assert_called_once_with(mock.ANY)
520-- mock_register.assert_called_once_with(mock.ANY, mock.ANY)
521-+ mock_register.assert_called_once_with(
522-+ mock.ANY,
523-+ mock.ANY,
524-+ on_error=mock.ANY,
525-+ )
526- mock_input.assert_not_called()
527- # Note that the error is output via sys.stderr.
528- self.assertEqual(
529-@@ -1378,7 +1404,11 @@
530- mock_serviceconfig.set_start_on_boot.assert_called_once_with(True)
531- mock_serviceconfig.restart_landscape.assert_called_once_with()
532- mock_setup_script().run.assert_called_once_with()
533-- mock_register.assert_called_once_with(mock.ANY, mock.ANY)
534-+ mock_register.assert_called_once_with(
535-+ mock.ANY,
536-+ mock.ANY,
537-+ on_error=mock.ANY,
538-+ )
539- mock_input.assert_called_with(
540- "\nRequest a new registration for this computer now? [Y/n]: ",
541- )
542-@@ -1457,7 +1487,11 @@
543- print=noop_print,
544- )
545- mock_setup.assert_called_once_with(mock.ANY)
546-- mock_register.assert_called_once_with(mock.ANY, mock.ANY)
547-+ mock_register.assert_called_once_with(
548-+ mock.ANY,
549-+ mock.ANY,
550-+ on_error=mock.ANY,
551-+ )
552- mock_input.assert_called_once_with(
553- "\nRequest a new registration for this computer now? [Y/n]: ",
554- )
555-@@ -1477,7 +1511,11 @@
556- print=noop_print,
557- )
558- mock_setup.assert_called_once_with(mock.ANY)
559-- mock_register.assert_called_once_with(mock.ANY, mock.ANY)
560-+ mock_register.assert_called_once_with(
561-+ mock.ANY,
562-+ mock.ANY,
563-+ on_error=mock.ANY,
564-+ )
565- mock_input.assert_not_called()
566-
567- @mock.patch("landscape.client.configuration.input")
568-@@ -2290,6 +2328,27 @@
569- # We ask for retries because networks aren't reliable.
570- self.assertEqual(99, connector.max_retries)
571-
572-+ def test_register_got_error(self):
573-+ """If there is an error from the connection, raises `SystemExit`."""
574-+ reactor = mock.Mock()
575-+ connector_factory = mock.Mock()
576-+ results = []
577-+
578-+ def add_error():
579-+ results.append(SystemExit())
580-+
581-+ reactor.run.side_effect = add_error
582-+
583-+ self.assertRaises(
584-+ SystemExit,
585-+ register,
586-+ self.config,
587-+ reactor,
588-+ connector_factory,
589-+ max_retries=2,
590-+ results=results,
591-+ )
592-+
593- @mock.patch("landscape.client.configuration.LandscapeReactor")
594- def test_register_without_reactor(self, mock_reactor):
595- """If no reactor is passed, a LandscapeReactor will be instantiated.
596-@@ -2683,3 +2742,21 @@
597- print=noop_print,
598- )
599- self.assertEqual(EXIT_NOT_REGISTERED, exception.code)
600-+
601-+
602-+class SetSecureIdTest(LandscapeTest):
603-+ """Tests for the `set_secure_id` function."""
604-+
605-+ @mock.patch("landscape.client.configuration.Persist")
606-+ @mock.patch("landscape.client.configuration.Identity")
607-+ def test_function(self, Identity, Persist):
608-+ config = mock.Mock(data_path="/tmp/landscape")
609-+
610-+ set_secure_id(config, "fancysecureid")
611-+
612-+ Persist.assert_called_once_with(
613-+ filename="/tmp/landscape/broker.bpickle",
614-+ )
615-+ Persist().save.assert_called_once_with()
616-+ Identity.assert_called_once_with(config, Persist())
617-+ self.assertEqual(Identity().secure_id, "fancysecureid")
618diff --git a/debian/patches/0002-fix-broken-build-tests.patch b/debian/patches/0002-fix-broken-build-tests.patch
619deleted file mode 100644
620index 8a118b3..0000000
621--- a/debian/patches/0002-fix-broken-build-tests.patch
622+++ /dev/null
623@@ -1,54 +0,0 @@
624-Description: Fix tests that do not pass in debian build environment
625- In environments where the run-as user's home directory does not exist, such
626- as sbuild, fall back to expecting the script path to be the root directory.
627- Mock snapd, as it does not run in an accessible way in some environments.
628-Bug-Ubuntu: https://bugs.launchpad.net/ubuntu/+source/landscape-client/+bug/2044181
629-Author: Mitch Burton <mitch.burton@canonical.com>
630-Last-Update: 2023-11-22
631----
632-This patch header follows DEP-3: http://dep.debian.net/deps/dep3/
633---- a/landscape/client/manager/tests/test_scriptexecution.py
634-+++ b/landscape/client/manager/tests/test_scriptexecution.py
635-@@ -463,6 +463,9 @@
636- gid = info.pw_gid
637- path = info.pw_dir
638-
639-+ if not os.path.exists(path):
640-+ path = "/"
641-+
642- return self._run_script(username, uid, gid, path)
643-
644- def test_user_no_home(self):
645---- a/landscape/client/monitor/tests/test_snapmonitor.py
646-+++ b/landscape/client/monitor/tests/test_snapmonitor.py
647-@@ -16,7 +16,12 @@
648-
649- def test_get_data(self):
650- """Tests getting installed snap data."""
651-+ snap_http_mock = Mock(
652-+ spec=SnapHttp,
653-+ get_snaps=Mock(return_value={"result": []}),
654-+ )
655- plugin = SnapMonitor()
656-+ plugin._snap_http = snap_http_mock
657- self.monitor.add(plugin)
658-
659- plugin.exchange()
660---- a/landscape/client/snap/tests/test_http.py
661-+++ b/landscape/client/snap/tests/test_http.py
662-@@ -19,6 +19,15 @@
663- def test_get_snaps(self):
664- """get_snaps() returns a dict with a list of installed snaps."""
665- http = SnapHttp()
666-+
667-+ def fill_buff(curl, buff, **kwargs):
668-+ buff.write(
669-+ b'{"result": [{"id": "foo", "name": "bar", '
670-+ b'"publisher": "baz"}]}'
671-+ )
672-+
673-+ http._perform = Mock(side_effect=fill_buff)
674-+
675- result = http.get_snaps()["result"]
676-
677- self.assertTrue(isinstance(result, list))
678diff --git a/debian/patches/0003-fix-cpuinfo-and-tests.patch b/debian/patches/0003-fix-cpuinfo-and-tests.patch
679deleted file mode 100644
680index 341a89e..0000000
681--- a/debian/patches/0003-fix-cpuinfo-and-tests.patch
682+++ /dev/null
683@@ -1,134 +0,0 @@
684-Description: Fix parsing processor IDs on ARM64 systems.
685-Author: Mitch Burton <mitch.burton@canonical.com>
686-Bug-Ubuntu: https://bugs.launchpad.net/ubuntu/+source/landscape-client/+bug/2046620
687-Last-Update: 2024-01-02
688----
689-This patch header follows DEP-3: http://dep.debian.net/deps/dep3/
690---- a/landscape/client/monitor/processorinfo.py
691-+++ b/landscape/client/monitor/processorinfo.py
692-@@ -181,30 +181,43 @@
693- def create_message(self):
694- """Returns a list containing information about each processor."""
695- processors = []
696-- file = open(self._source_filename)
697-+ regexp = re.compile(r"(?P<key>.*?)\s*:\s*(?P<value>.*)")
698-+ current = {}
699-
700-- try:
701-- regexp = re.compile(r"(?P<key>.*?)\s*:\s*(?P<value>.*)")
702-- current = {}
703-+ with open(self._source_filename) as fp:
704-+ for line in fp:
705-+ line = line.strip()
706-+
707-+ if not line:
708-+ if current:
709-+ processors.append(current.copy())
710-+ current = {}
711-+
712-+ continue
713-
714-- for line in file:
715- match = regexp.match(line.strip())
716-+
717- if match:
718- key = match.group("key")
719- value = match.group("value")
720-
721-- if key == "Processor":
722-- # ARM doesn't support SMP, thus no processor-id in
723-- # the cpuinfo
724-- current["processor-id"] = 0
725-+ if key == "processor":
726-+ current["processor-id"] = int(value)
727-+ if "model" not in current:
728-+ current["model"] = "arm"
729-+ elif key == "Processor":
730- current["model"] = value
731- elif key == "Cache size":
732- current["cache-size"] = int(value)
733-
734-- if current:
735-- processors.append(current)
736-- finally:
737-- file.close()
738-+ if current:
739-+ processors.append(current)
740-+
741-+ # Older ARM machines may not have processor-ids, but we need them, so
742-+ # we set missing ones to 0.
743-+ for processor in processors:
744-+ if "processor-id" not in processor:
745-+ processor["processor-id"] = 0
746-
747- return processors
748-
749-@@ -357,6 +370,8 @@
750-
751- if key == "processor":
752- current = {"processor-id": int(parts[1].strip())}
753-+ # A placeholder in case there is no model provided.
754-+ current["model"] = "riscv"
755- processors.append(current)
756- elif key == "isa":
757- current["vendor"] = parts[1].strip()
758---- a/landscape/client/monitor/tests/test_processorinfo.py
759-+++ b/landscape/client/monitor/tests/test_processorinfo.py
760-@@ -30,8 +30,7 @@
761- self.assertTrue(len(message["processors"]) > 0)
762-
763- for processor in message["processors"]:
764-- self.assertTrue("processor-id" in processor)
765-- self.assertTrue("model" in processor)
766-+ self.assertIn("processor-id", processor)
767-
768- def test_call_on_accepted(self):
769- """
770---- a/landscape/client/package/reporter.py
771-+++ b/landscape/client/package/reporter.py
772-@@ -381,7 +381,11 @@
773- env["http_proxy"] = self._config.http_proxy
774- if self._config.https_proxy:
775- env["https_proxy"] = self._config.https_proxy
776-- result = spawn_process(self.apt_update_filename, env=env)
777-+
778-+ try:
779-+ result = spawn_process(self.apt_update_filename, env=env)
780-+ except Exception as e:
781-+ return deferred.callback((b"", str(e).encode(), e.errno))
782-
783- def callback(args, deferred):
784- return deferred.callback(args)
785---- a/landscape/client/package/tests/test_releaseupgrader.py
786-+++ b/landscape/client/package/tests/test_releaseupgrader.py
787-@@ -391,7 +391,8 @@
788- reactor.callWhenRunning(do_test)
789-
790- def cleanup(ignored):
791-- os.environ = env_backup
792-+ os.environ.clear()
793-+ os.environ.update(env_backup)
794- return ignored
795-
796- return deferred.addBoth(cleanup)
797-@@ -451,7 +452,8 @@
798- reactor.callWhenRunning(do_test)
799-
800- def cleanup(ignored):
801-- os.environ = env_backup
802-+ os.environ.clear()
803-+ os.environ.update(env_backup)
804- return ignored
805-
806- return deferred.addBoth(cleanup)
807---- a/landscape/client/tests/test_watchdog.py
808-+++ b/landscape/client/tests/test_watchdog.py
809-@@ -792,7 +792,7 @@
810- "GRACEFUL_WAIT_PERIOD",
811- landscape.client.watchdog.GRACEFUL_WAIT_PERIOD,
812- )
813-- landscape.client.watchdog.GRACEFUL_WAIT_PERIOD = 0.2
814-+ landscape.client.watchdog.GRACEFUL_WAIT_PERIOD = 1
815- self.daemon.start()
816-
817- def got_result(result):
818diff --git a/debian/patches/series b/debian/patches/series
819deleted file mode 100644
820index e28a21f..0000000
821--- a/debian/patches/series
822+++ /dev/null
823@@ -1,3 +0,0 @@
824-0001-start-service-during-config.patch
825-0002-fix-broken-build-tests.patch
826-0003-fix-cpuinfo-and-tests.patch
827diff --git a/debian/rules b/debian/rules
828index cc680cd..3be827c 100755
829--- a/debian/rules
830+++ b/debian/rules
831@@ -26,4 +26,4 @@ override_dh_installsystemd:
832 dh_installsystemd
833
834 override_dh_auto_test:
835- HOME=$(shell mktemp -d) && make check3
836+ HOME=$(shell mktemp -d) && make check
837diff --git a/debian/watch b/debian/watch
838index ba7ea91..a8377cc 100644
839--- a/debian/watch
840+++ b/debian/watch
841@@ -1,3 +1,4 @@
842 version=4
843-opts=filenamemangle=s/.+\/v?(\d\S+)\.tar\.gz/landscape-client-$1\.tar\.gz/ \
844- https://github.com/CanonicalLtd/landscape-client/tags .*/?(\d{2}\.\d{2})\.tar\.gz
845+opts="searchmode=plain" \
846+https://api.github.com/repos/canonical/landscape-client/releases?per_page=50 \
847+https://github.com/canonical/landscape-client/releases/download/[^/]+/landscape-client_@ANY_VERSION@.orig\.tar\.gz
848diff --git a/example.conf b/example.conf
849index 16b710e..c7e71b8 100644
850--- a/example.conf
851+++ b/example.conf
852@@ -96,6 +96,8 @@ ignore_sigusr1 = False
853 # UbuntuProInfo - Ubuntu Pro registration information
854 # LivePatch - Livepath status information
855 # UbuntuProRebootRequired - informs if the system needs to be rebooted
856+# SnapMonitor - manage installed snaps
857+# SnapServicesMonitor - manage snap services
858 #
859 # The special value "ALL" is an alias for the full list of plugins.
860 monitor_plugins = ALL
861@@ -166,9 +168,9 @@ cloud = True
862
863 # MANAGER OPTIONS
864
865-# A comma-separated list of monitor plugins to use.
866+# A comma-separated list of manager plugins to use.
867 #
868-# Currently available monitor plugins are:
869+# Currently available manager plugins are:
870 #
871 # ProcessKiller
872 # PackageManager
873@@ -176,14 +178,23 @@ cloud = True
874 # ShutdownManager
875 # AptSources
876 # HardwareInfo
877+# KeystoneToken
878+# SnapManager
879+# SnapServicesManager
880 #
881-# The special vale "ALL" is an alias for the full list of plugins.
882+# The special value "ALL" is an alias for the entire list of plugins above and is the default.
883 manager_plugins = ALL
884
885+# A comma-separated list of manager plugins to use in addition to the default ones.
886+#
887+# The ScriptExecution manager plugin is not enabled by default.
888+# The following example would enable it.
889+#include_manager_plugins = ScriptExecution
890+
891 # A comma-separated list of usernames that scripts can run as.
892 #
893 # By default, all usernames are allowed.
894-script_users = ALL
895+#script_users = ALL
896
897
898 # The maximum script output length transmitted to landscape
899@@ -198,3 +209,8 @@ script_users = ALL
900 #
901 # The default is True
902 #manage_sources_list_d = True
903+
904+# Set this for WSL instances managed by Landscape. The value
905+# should match the uid assigned to the host machine.
906+# For all other computers, do not set this parameter.
907+#hostagent_uid = the-uid-of-the-host-machine
908diff --git a/landscape/__init__.py b/landscape/__init__.py
909index 1077067..6e296ac 100644
910--- a/landscape/__init__.py
911+++ b/landscape/__init__.py
912@@ -1,5 +1,5 @@
913 DEBIAN_REVISION = ""
914-UPSTREAM_VERSION = "23.08"
915+UPSTREAM_VERSION = "24.02"
916 VERSION = f"{UPSTREAM_VERSION}{DEBIAN_REVISION}"
917
918 # The minimum server API version that all Landscape servers are known to speak
919diff --git a/landscape/client/__init__.py b/landscape/client/__init__.py
920index e69de29..ccb5857 100644
921--- a/landscape/client/__init__.py
922+++ b/landscape/client/__init__.py
923@@ -0,0 +1,11 @@
924+import os
925+
926+IS_SNAP = os.getenv("LANDSCAPE_CLIENT_SNAP")
927+IS_CORE = os.getenv("SNAP_SAVE_DATA") is not None
928+
929+USER = "root" if IS_SNAP else "landscape"
930+GROUP = "root" if IS_SNAP else "landscape"
931+
932+DEFAULT_CONFIG = (
933+ "/etc/landscape-client.conf" if IS_SNAP else "/etc/landscape/client.conf"
934+)
935diff --git a/landscape/client/broker/client.py b/landscape/client/broker/client.py
936index edbc2c2..5d3dd4a 100644
937--- a/landscape/client/broker/client.py
938+++ b/landscape/client/broker/client.py
939@@ -1,5 +1,6 @@
940 import random
941 import sys
942+import traceback
943 from logging import debug
944 from logging import error
945 from logging import exception
946@@ -142,11 +143,14 @@ class BrokerClientPlugin:
947
948 def _error_log(self, failure):
949 """Errback to log and reraise uncaught run errors."""
950- msg = "{} raised an uncaught exception".format(type(self).__name__)
951+ cls = type(self).__name__
952+ msg = f"{cls} raised an uncaught exception"
953 if sys.exc_info() == (None, None, None):
954 error(msg)
955 else:
956 exception(msg)
957+ debug(traceback.format_exc(limit=15))
958+
959 return failure
960
961
962diff --git a/landscape/client/broker/config.py b/landscape/client/broker/config.py
963index 6b222fb..c050c22 100644
964--- a/landscape/client/broker/config.py
965+++ b/landscape/client/broker/config.py
966@@ -31,6 +31,7 @@ class BrokerConfiguration(Configuration):
967 - C{urgent_exchange_interval} (C{1*60})
968 - C{http_proxy}
969 - C{https_proxy}
970+ - C{hostagent_uid}
971 """
972 parser = super().make_parser()
973
974@@ -44,7 +45,7 @@ class BrokerConfiguration(Configuration):
975 "-p",
976 "--registration-key",
977 metavar="KEY",
978- help="The account-wide key used for " "registering clients.",
979+ help="The account-wide key used for registering clients.",
980 )
981 parser.add_option(
982 "-t",
983@@ -57,14 +58,14 @@ class BrokerConfiguration(Configuration):
984 default=15 * 60,
985 type="int",
986 metavar="INTERVAL",
987- help="The number of seconds between server " "exchanges.",
988+ help="The number of seconds between server exchanges.",
989 )
990 parser.add_option(
991 "--urgent-exchange-interval",
992 default=1 * 60,
993 type="int",
994 metavar="INTERVAL",
995- help="The number of seconds between urgent server " "exchanges.",
996+ help="The number of seconds between urgent server exchanges.",
997 )
998 parser.add_option(
999 "--ping-interval",
1000@@ -93,6 +94,12 @@ class BrokerConfiguration(Configuration):
1001 help="Comma separated list of tag names to be sent "
1002 "to the server.",
1003 )
1004+ parser.add_option(
1005+ "--hostagent-uid",
1006+ help="Only set this value if this computer is a WSL instance "
1007+ "managed by Landscape, in which case set it to be the uid that "
1008+ "Landscape assigned to the host machine.",
1009+ )
1010
1011 return parser
1012
1013diff --git a/landscape/client/broker/ping.py b/landscape/client/broker/ping.py
1014index 153be3c..e96ab7d 100644
1015--- a/landscape/client/broker/ping.py
1016+++ b/landscape/client/broker/ping.py
1017@@ -51,11 +51,12 @@ from landscape.lib.log import log_failure
1018 class PingClient:
1019 """An HTTP client which knows how to talk to the ping server."""
1020
1021- def __init__(self, reactor, get_page=None):
1022+ def __init__(self, reactor, get_page=None, cainfo=None):
1023 if get_page is None:
1024 get_page = fetch
1025 self._reactor = reactor
1026 self.get_page = get_page
1027+ self._cainfo = cainfo
1028
1029 def ping(self, url, insecure_id):
1030 """Ask the question: are there messages for this computer ID?
1031@@ -83,6 +84,7 @@ class PingClient:
1032 post=True,
1033 data=data,
1034 headers=headers,
1035+ cainfo=self._cainfo,
1036 )
1037 page_deferred.addCallback(self._got_result)
1038 return page_deferred
1039@@ -94,8 +96,7 @@ class PingClient:
1040 the response indicates that their are messages waiting for
1041 this computer, False otherwise.
1042 """
1043- if bpickle.loads(webtext) == {"messages": True}:
1044- return True
1045+ return bpickle.loads(webtext) == {"messages": True}
1046
1047
1048 class Pinger:
1049@@ -138,7 +139,10 @@ class Pinger:
1050
1051 def start(self):
1052 """Start pinging."""
1053- self._ping_client = self.ping_client_factory(self._reactor)
1054+ self._ping_client = self.ping_client_factory(
1055+ self._reactor,
1056+ cainfo=self._config.ssl_public_key,
1057+ )
1058 self._schedule()
1059
1060 def ping(self):
1061diff --git a/landscape/client/broker/registration.py b/landscape/client/broker/registration.py
1062index c21186e..060a16b 100644
1063--- a/landscape/client/broker/registration.py
1064+++ b/landscape/client/broker/registration.py
1065@@ -8,13 +8,11 @@ the machinery in this module will notice that we have no identification
1066 credentials yet and that the server accepts registration messages, so it
1067 will craft an appropriate one and send it out.
1068 """
1069-import json
1070 import logging
1071
1072 from twisted.internet.defer import Deferred
1073
1074 from landscape.client.broker.exchange import maybe_bytes
1075-from landscape.client.monitor.ubuntuproinfo import get_ubuntu_pro_info
1076 from landscape.lib.juju import get_juju_info
1077 from landscape.lib.network import get_fqdn
1078 from landscape.lib.tag import is_valid_tag_list
1079@@ -76,6 +74,7 @@ class Identity:
1080 registration_key = config_property("registration_key")
1081 tags = config_property("tags")
1082 access_group = config_property("access_group")
1083+ hostagent_uid = config_property("hostagent_uid")
1084
1085 def __init__(self, config, persist):
1086 self._config = config
1087@@ -190,6 +189,7 @@ class RegistrationHandler:
1088 tags = identity.tags
1089 group = identity.access_group
1090 registration_key = identity.registration_key
1091+ hostagent_uid = identity.hostagent_uid
1092
1093 self._message_store.delete_all_messages()
1094
1095@@ -217,6 +217,8 @@ class RegistrationHandler:
1096
1097 if group:
1098 message["access_group"] = group
1099+ if hostagent_uid:
1100+ message["hostagent_uid"] = hostagent_uid
1101
1102 server_api = self._message_store.get_server_api()
1103 # If we have juju data to send and if the server is recent enough to
1104@@ -237,8 +239,6 @@ class RegistrationHandler:
1105 with_tags = f"and tags {tags} " if tags else ""
1106 with_group = f"in access group '{group}' " if group else ""
1107
1108- message["ubuntu_pro_info"] = json.dumps(get_ubuntu_pro_info())
1109-
1110 logging.info(
1111 f"Queueing message to register with account {account_name!r} "
1112 f"{with_group}{with_tags}{with_word} a password.",
1113diff --git a/landscape/client/broker/store.py b/landscape/client/broker/store.py
1114index 7557d45..ede2514 100644
1115--- a/landscape/client/broker/store.py
1116+++ b/landscape/client/broker/store.py
1117@@ -137,8 +137,14 @@ class MessageStore:
1118 # in case the server supports it.
1119 _api = DEFAULT_SERVER_API
1120
1121- def __init__(self, persist, directory, directory_size=1000, max_dirs=4,
1122- max_size_mb=400):
1123+ def __init__(
1124+ self,
1125+ persist,
1126+ directory,
1127+ directory_size=1000,
1128+ max_dirs=4,
1129+ max_size_mb=400,
1130+ ):
1131 self._directory = directory
1132 self._directory_size = directory_size
1133 self._max_dirs = max_dirs # Maximum number of directories in store
1134@@ -407,7 +413,7 @@ class MessageStore:
1135 self.add({"type": "resynchronize"})
1136 self._persist.set("blackhole-messages", True)
1137 logging.warning(
1138- "Unable to succesfully communicate with Landscape server "
1139+ "Unable to successfully communicate with Landscape server "
1140 "for more than a week. Waiting for resync.",
1141 )
1142
1143diff --git a/landscape/client/broker/tests/test_config.py b/landscape/client/broker/tests/test_config.py
1144index b5236ae..db03701 100644
1145--- a/landscape/client/broker/tests/test_config.py
1146+++ b/landscape/client/broker/tests/test_config.py
1147@@ -12,7 +12,7 @@ class ConfigurationTests(LandscapeTest):
1148 def test_loading_sets_http_proxies(self):
1149 """
1150 The L{BrokerConfiguration.load} method sets the 'http_proxy' and
1151- 'https_proxy' enviroment variables to the provided values.
1152+ 'https_proxy' environment variables to the provided values.
1153 """
1154 if "http_proxy" in os.environ:
1155 del os.environ["http_proxy"]
1156@@ -36,7 +36,7 @@ class ConfigurationTests(LandscapeTest):
1157 def test_loading_without_http_proxies_does_not_touch_environment(self):
1158 """
1159 The L{BrokerConfiguration.load} method doesn't override the
1160- 'http_proxy' and 'https_proxy' enviroment variables if they
1161+ 'http_proxy' and 'https_proxy' environment variables if they
1162 are already set and no new value was specified.
1163 """
1164 os.environ["http_proxy"] = "heyo"
1165@@ -71,7 +71,7 @@ class ConfigurationTests(LandscapeTest):
1166 self.assertEqual(os.environ["https_proxy"], "originals")
1167
1168 def test_default_exchange_intervals(self):
1169- """Exchange intervales are set to sane defaults."""
1170+ """Exchange intervals are set to sane defaults."""
1171 configuration = BrokerConfiguration()
1172 self.assertEqual(60, configuration.urgent_exchange_interval)
1173 self.assertEqual(900, configuration.exchange_interval)
1174@@ -135,3 +135,27 @@ class ConfigurationTests(LandscapeTest):
1175 configuration.url,
1176 "https://landscape.canonical.com/message-system",
1177 )
1178+
1179+ def test_hostagent_uid_handling(self):
1180+ """
1181+ The 'hostagent_uid' value specified in the configuration file is
1182+ passed through.
1183+ """
1184+ filename = self.makeFile("[client]\nhostagent_uid = AWESOME COMPUTER")
1185+
1186+ configuration = BrokerConfiguration()
1187+ configuration.load(["--config", filename, "--url", "whatever"])
1188+
1189+ self.assertEqual(configuration.hostagent_uid, "AWESOME COMPUTER")
1190+
1191+ def test_missing_hostagent_uid_is_none(self):
1192+ """
1193+ Test that if we don't explicitly pass a hostagent_uid, then this value
1194+ is None.
1195+ """
1196+ filename = self.makeFile("[client]\n")
1197+
1198+ configuration = BrokerConfiguration()
1199+ configuration.load(["--config", filename, "--url", "whatever"])
1200+
1201+ self.assertIsNone(configuration.hostagent_uid)
1202diff --git a/landscape/client/broker/tests/test_exchange.py b/landscape/client/broker/tests/test_exchange.py
1203index bc7fb5f..dd396c3 100644
1204--- a/landscape/client/broker/tests/test_exchange.py
1205+++ b/landscape/client/broker/tests/test_exchange.py
1206@@ -565,7 +565,7 @@ class MessageExchangeTest(LandscapeTest):
1207 """
1208 If the server asks for messages that we no longer have, the message
1209 exchange plugin should send a message to the server indicating that a
1210- resynchronization is occuring and then fire a "resynchronize-clients"
1211+ resynchronization is occurring and then fire a "resynchronize-clients"
1212 reactor message, so that plugins can generate new data -- if the server
1213 got out of synch with the client, then we're best off synchronizing
1214 everything back to it.
1215@@ -859,7 +859,7 @@ class MessageExchangeTest(LandscapeTest):
1216 self.reactor.advance(1)
1217 self.assertEqual(events, [True])
1218
1219- def test_impending_exchange_gets_reschudeled_with_urgent_reschedule(self):
1220+ def test_impending_exchange_gets_rescheduled_with_urgent_reschedule(self):
1221 """
1222 When an urgent exchange is scheduled after a regular exchange was
1223 scheduled but before it executed, the old C{impending-exchange} event
1224@@ -955,7 +955,7 @@ class MessageExchangeTest(LandscapeTest):
1225 self.wait_for_exchange()
1226 self.assertFalse(self.transport.payloads)
1227
1228- def test_stop_twice_doesnt_break(self):
1229+ def test_stop_twice_does_not_break(self):
1230 self.exchanger.schedule_exchange()
1231 self.exchanger.stop()
1232 self.exchanger.stop()
1233@@ -1014,7 +1014,7 @@ class MessageExchangeTest(LandscapeTest):
1234
1235 def test_register_message(self):
1236 """
1237- The exchanger expsoses a mechanism for subscribing to messages
1238+ The exchanger exposes a mechanism for subscribing to messages
1239 of a particular type.
1240 """
1241 messages = []
1242diff --git a/landscape/client/broker/tests/test_ping.py b/landscape/client/broker/tests/test_ping.py
1243index 843b984..fe08dee 100644
1244--- a/landscape/client/broker/tests/test_ping.py
1245+++ b/landscape/client/broker/tests/test_ping.py
1246@@ -16,7 +16,7 @@ class FakePageGetter:
1247 self.response = response
1248 self.fetches = []
1249
1250- def get_page(self, url, post, headers, data):
1251+ def get_page(self, url, post, headers, data, cainfo=None):
1252 """
1253 A method which is supposed to act like a limited version of
1254 L{landscape.lib.fetch.fetch}.
1255@@ -27,7 +27,7 @@ class FakePageGetter:
1256 self.fetches.append((url, post, headers, data))
1257 return bpickle.dumps(self.response)
1258
1259- def failing_get_page(self, url, post, headers, data):
1260+ def failing_get_page(self, url, post, headers, data, cainfo=None):
1261 """
1262 A method which is supposed to act like a limited version of
1263 L{landscape.lib.fetch.fetch}.
1264@@ -126,8 +126,12 @@ class PingerTest(LandscapeTest):
1265 super().setUp()
1266 self.page_getter = FakePageGetter(None)
1267
1268- def factory(reactor):
1269- return PingClient(reactor, get_page=self.page_getter.get_page)
1270+ def factory(reactor, **kwargs):
1271+ return PingClient(
1272+ reactor,
1273+ get_page=self.page_getter.get_page,
1274+ **kwargs,
1275+ )
1276
1277 self.config.ping_url = "http://localhost:8081/whatever"
1278 self.config.ping_interval = 10
1279diff --git a/landscape/client/broker/tests/test_registration.py b/landscape/client/broker/tests/test_registration.py
1280index fe1d256..1e236fe 100644
1281--- a/landscape/client/broker/tests/test_registration.py
1282+++ b/landscape/client/broker/tests/test_registration.py
1283@@ -78,6 +78,9 @@ class IdentityTest(LandscapeTest):
1284 def test_access_group(self):
1285 self.check_config_property("access_group")
1286
1287+ def test_hostagent_uid(self):
1288+ self.check_config_property("hostagent_uid")
1289+
1290
1291 class RegistrationHandlerTestBase(LandscapeTest):
1292
1293@@ -383,6 +386,47 @@ class RegistrationHandlerTest(RegistrationHandlerTestBase):
1294 # Make sure the key does not appear in the outgoing message.
1295 self.assertNotIn("access_group", messages[0])
1296
1297+ def test_queue_message_on_exchange_with_hostagent_uid(self):
1298+ """
1299+ If the admin has defined a hostagent_uid for this computer, we send
1300+ it to the server.
1301+ """
1302+ self.mstore.set_accepted_types(["register"])
1303+ # hostagent_uid is introduced in the 3.3 message schema
1304+ self.mstore.set_server_api(b"3.3")
1305+ self.config.account_name = "account_name"
1306+ self.config.hostagent_uid = "dinosaur computer"
1307+ self.config.tags = "server,london"
1308+ self.reactor.fire("pre-exchange")
1309+ messages = self.mstore.get_pending_messages()
1310+ self.assertEqual("dinosaur computer", messages[0]["hostagent_uid"])
1311+
1312+ def test_queue_message_on_exchange_with_empty_hostagent_uid(self):
1313+ """
1314+ If the hostagent_uid is "", then the outgoing message does not define
1315+ a "hostagent_uid" key.
1316+ """
1317+ self.mstore.set_accepted_types(["register"])
1318+ # hostagent_uid is introduced in the 3.3 message schema
1319+ self.mstore.set_server_api(b"3.3")
1320+ self.config.hostagent_uid = ""
1321+ self.reactor.fire("pre-exchange")
1322+ messages = self.mstore.get_pending_messages()
1323+ self.assertNotIn("hostagent_uid", messages[0])
1324+
1325+ def test_queue_message_on_exchange_with_none_hostagent_uid(self):
1326+ """
1327+ If the hostagent_uid is None, then the outgoing message does not define
1328+ a "hostagent_uid" key.
1329+ """
1330+ self.mstore.set_accepted_types(["register"])
1331+ # hostagent_uid is introduced in the 3.3 message schema
1332+ self.mstore.set_server_api(b"3.3")
1333+ self.config.hostagent_uid = None
1334+ self.reactor.fire("pre-exchange")
1335+ messages = self.mstore.get_pending_messages()
1336+ self.assertNotIn("hostagent_uid", messages[0])
1337+
1338 def test_queueing_registration_message_resets_message_store(self):
1339 """
1340 When a registration message is queued, the store is reset
1341@@ -457,7 +501,7 @@ class RegistrationHandlerTest(RegistrationHandlerTestBase):
1342 {"type": b"registration", "info": b"blah-blah"},
1343 )
1344 for name, args, kwargs in reactor_fire_mock.mock_calls:
1345- self.assertNotEquals("registration-failed", args[0])
1346+ self.assertNotEqual("registration-failed", args[0])
1347
1348 def test_register_resets_ids(self):
1349 self.identity.secure_id = "foo"
1350diff --git a/landscape/client/broker/tests/test_store.py b/landscape/client/broker/tests/test_store.py
1351index b61edf3..5dd2fbb 100644
1352--- a/landscape/client/broker/tests/test_store.py
1353+++ b/landscape/client/broker/tests/test_store.py
1354@@ -700,11 +700,11 @@ class MessageStoreTest(LandscapeTest):
1355 """Messages stop accumulating after one week of not being sent."""
1356 self.store.record_failure(0)
1357 self.store.record_failure(7 * 24 * 60 * 60)
1358- self.assertIsNot(None, self.store.add({"type": "empty"}))
1359+ self.assertIsNotNone(self.store.add({"type": "empty"}))
1360 self.store.record_failure((7 * 24 * 60 * 60) + 1)
1361- self.assertIs(None, self.store.add({"type": "empty"}))
1362+ self.assertIsNone(self.store.add({"type": "empty"}))
1363 self.assertIn(
1364- "WARNING: Unable to succesfully communicate with "
1365+ "WARNING: Unable to successfully communicate with "
1366 "Landscape server for more than a week. Waiting for "
1367 "resync.",
1368 self.logfile.getvalue(),
1369diff --git a/landscape/client/configuration.py b/landscape/client/configuration.py
1370index 621e797..759b45f 100644
1371--- a/landscape/client/configuration.py
1372+++ b/landscape/client/configuration.py
1373@@ -13,6 +13,8 @@ import textwrap
1374 from functools import partial
1375 from urllib.parse import urlparse
1376
1377+from landscape.client import GROUP
1378+from landscape.client import USER
1379 from landscape.client.broker.amp import RemoteBrokerConnector
1380 from landscape.client.broker.config import BrokerConfiguration
1381 from landscape.client.broker.registration import Identity
1382@@ -197,14 +199,15 @@ class LandscapeSetupConfiguration(BrokerConfiguration):
1383 "--script-users",
1384 metavar="USERS",
1385 help="A comma-separated list of users to allow "
1386- "scripts to run. To allow scripts to be run "
1387+ "scripts to run. To allow scripts to be run "
1388 "by any user, enter: ALL",
1389 )
1390 parser.add_option(
1391 "--include-manager-plugins",
1392 metavar="PLUGINS",
1393 default="",
1394- help="A comma-separated list of manager plugins to " "load.",
1395+ help="A comma-separated list of manager plugins "
1396+ "to enable in addition to the defaults.",
1397 )
1398 parser.add_option(
1399 "-n",
1400@@ -228,13 +231,13 @@ class LandscapeSetupConfiguration(BrokerConfiguration):
1401 "--disable",
1402 action="store_true",
1403 default=False,
1404- help="Stop running clients and disable start at " "boot.",
1405+ help="Stop running clients and disable start at boot.",
1406 )
1407 parser.add_option(
1408 "--init",
1409 action="store_true",
1410 default=False,
1411- help="Set up the client directories structure " "and exit.",
1412+ help="Set up the client directories structure and exit.",
1413 )
1414 parser.add_option(
1415 "--is-registered",
1416@@ -565,7 +568,7 @@ def check_account_name_and_password(config):
1417 if config.silent and not config.no_start:
1418 if not (config.get("account_name") and config.get("computer_title")):
1419 raise ConfigurationError(
1420- "An account name and computer title are " "required.",
1421+ "An account name and computer title are required.",
1422 )
1423
1424
1425@@ -624,8 +627,10 @@ def setup(config):
1426 decode_base64_ssl_public_certificate(config)
1427 config.write()
1428 # Restart the client to ensure that it's using the new configuration.
1429+
1430 if not config.no_start:
1431 try:
1432+ set_secure_id(config, "registering")
1433 ServiceConfig.restart_landscape()
1434 except ServiceConfigException as exc:
1435 print_text(str(exc), error=True)
1436@@ -643,13 +648,8 @@ def setup(config):
1437 def bootstrap_tree(config):
1438 """Create the client directories tree."""
1439 bootstrap_list = [
1440- BootstrapDirectory("$data_path", "landscape", "root", 0o755),
1441- BootstrapDirectory(
1442- "$annotations_path",
1443- "landscape",
1444- "landscape",
1445- 0o755,
1446- ),
1447+ BootstrapDirectory("$data_path", USER, "root", 0o755),
1448+ BootstrapDirectory("$annotations_path", USER, GROUP, 0o755),
1449 ]
1450 BootstrapList(bootstrap_list).bootstrap(
1451 data_path=config.data_path,
1452@@ -796,13 +796,14 @@ def register(
1453 # Results will be things like "success" or "ssl-error".
1454 result = results[0]
1455
1456- if isinstance(result, SystemExit):
1457- raise result
1458-
1459 # If there was an error and the caller requested that errors be reported
1460 # to the on_error callable, then do so.
1461 if result != "success" and on_error is not None:
1462 on_error(1)
1463+
1464+ if isinstance(result, SystemExit):
1465+ raise result
1466+
1467 return result
1468
1469
1470@@ -883,6 +884,21 @@ def registration_info_text(config, registration_status):
1471 return text
1472
1473
1474+def set_secure_id(config, new_id):
1475+ """Persists a secure id in the identity data file. This is used to indicate
1476+ whether we are currently in the process of registering.
1477+ """
1478+ persist = Persist(
1479+ filename=os.path.join(
1480+ config.data_path,
1481+ f"{BrokerService.service_name}.bpickle",
1482+ ),
1483+ )
1484+ identity = Identity(config, persist)
1485+ identity.secure_id = new_id
1486+ persist.save()
1487+
1488+
1489 def main(args, print=print):
1490 """Interact with the user and the server to set up client configuration."""
1491
1492@@ -927,7 +943,11 @@ def main(args, print=print):
1493 # Attempt to register the client.
1494 reactor = LandscapeReactor()
1495 if config.silent:
1496- result = register(config, reactor)
1497+ result = register(
1498+ config,
1499+ reactor,
1500+ on_error=lambda _: set_secure_id(config, None),
1501+ )
1502 report_registration_outcome(result, print=print)
1503 sys.exit(determine_exit_code(result))
1504 else:
1505@@ -937,6 +957,10 @@ def main(args, print=print):
1506 default=default_answer,
1507 )
1508 if answer:
1509- result = register(config, reactor)
1510+ result = register(
1511+ config,
1512+ reactor,
1513+ on_error=lambda _: set_secure_id(config, None),
1514+ )
1515 report_registration_outcome(result, print=print)
1516 sys.exit(determine_exit_code(result))
1517diff --git a/landscape/client/deployment.py b/landscape/client/deployment.py
1518index db1f110..28bc830 100644
1519--- a/landscape/client/deployment.py
1520+++ b/landscape/client/deployment.py
1521@@ -1,13 +1,23 @@
1522+import json
1523 import os.path
1524+import subprocess
1525 import sys
1526+from datetime import datetime
1527+from datetime import timezone
1528 from optparse import SUPPRESS_HELP
1529
1530 from twisted.logger import globalLogBeginner
1531
1532 from landscape import VERSION
1533+from landscape.client import DEFAULT_CONFIG
1534+from landscape.client import snap_http
1535+from landscape.client.snap_utils import get_snap_info
1536 from landscape.client.upgraders import UPGRADE_MANAGERS
1537 from landscape.lib import logging
1538 from landscape.lib.config import BaseConfiguration as _BaseConfiguration
1539+from landscape.lib.format import expandvars
1540+from landscape.lib.network import get_active_device_info
1541+from landscape.lib.network import get_fqdn
1542 from landscape.lib.persist import Persist
1543
1544
1545@@ -37,7 +47,7 @@ class BaseConfiguration(_BaseConfiguration):
1546
1547 version = VERSION
1548
1549- default_config_filename = "/etc/landscape/client.conf"
1550+ default_config_filename = DEFAULT_CONFIG
1551 if _is_script():
1552 default_config_filenames = (
1553 "landscape-client.conf",
1554@@ -188,6 +198,25 @@ class Configuration(BaseConfiguration):
1555 backwards-compatibility."""
1556 return os.path.join(self.data_path, "juju-info.json")
1557
1558+ def auto_configure(self):
1559+ """Automatically configure the client snap."""
1560+ client_conf = snap_http.get_conf("landscape-client").result
1561+ auto_enroll_conf = client_conf.get("auto-register", {})
1562+
1563+ enabled = auto_enroll_conf.get("enabled", False)
1564+ configured = auto_enroll_conf.get("configured", False)
1565+ if not enabled or configured:
1566+ return
1567+
1568+ title = generate_computer_title(auto_enroll_conf)
1569+ if title:
1570+ self.computer_title = title
1571+ self.write()
1572+
1573+ auto_enroll_conf["configured"] = True
1574+ client_conf["auto-register"] = auto_enroll_conf
1575+ snap_http.set_conf("landscape-client", client_conf)
1576+
1577
1578 def get_versioned_persist(service):
1579 """Get a L{Persist} database with upgrade rules applied.
1580@@ -203,3 +232,52 @@ def get_versioned_persist(service):
1581 upgrade_manager.initialize(persist)
1582 persist.save(service.persist_filename)
1583 return persist
1584+
1585+
1586+def generate_computer_title(auto_enroll_config):
1587+ """Generate the computer title.
1588+
1589+ This follows the LA017 specification and falls back to `hostname`
1590+ if generating the title fails due to missing data.
1591+ """
1592+ snap_info = get_snap_info()
1593+ wait_for_serial = auto_enroll_config.get("wait-for-serial-as", True)
1594+ if "serial" not in snap_info and wait_for_serial:
1595+ return
1596+
1597+ hostname = get_fqdn()
1598+ wait_for_hostname = auto_enroll_config.get("wait-for-hostname", False)
1599+ if hostname == "localhost" and wait_for_hostname:
1600+ return
1601+
1602+ nics = get_active_device_info(default_only=True)
1603+ nic = nics[0] if nics else {}
1604+
1605+ lshw = subprocess.run(
1606+ ["lshw", "-json", "-quiet", "-c", "system"],
1607+ capture_output=True,
1608+ text=True,
1609+ )
1610+ hardware = json.loads(lshw.stdout)[0]
1611+
1612+ computer_title_pattern = auto_enroll_config.get(
1613+ "computer-title-pattern",
1614+ "${hostname}",
1615+ )
1616+ title = expandvars(
1617+ computer_title_pattern,
1618+ serial=snap_info.get("serial", ""),
1619+ model=snap_info.get("model", ""),
1620+ brand=snap_info.get("brand", ""),
1621+ hostname=hostname,
1622+ ip=nic.get("ip_address", ""),
1623+ mac=nic.get("mac_address", ""),
1624+ prodiden=hardware.get("product", ""),
1625+ serialno=hardware.get("serial", ""),
1626+ datetime=datetime.now(timezone.utc),
1627+ )
1628+
1629+ if title == "": # on the off-chance substitute values are missing
1630+ title = hostname
1631+
1632+ return title
1633diff --git a/landscape/client/manager/aptsources.py b/landscape/client/manager/aptsources.py
1634index baa641f..179fa72 100644
1635--- a/landscape/client/manager/aptsources.py
1636+++ b/landscape/client/manager/aptsources.py
1637@@ -8,6 +8,8 @@ import uuid
1638
1639 from twisted.internet.defer import succeed
1640
1641+from landscape.client import GROUP
1642+from landscape.client import USER
1643 from landscape.client.manager.plugin import ManagerPlugin
1644 from landscape.client.package.reporter import find_reporter_command
1645 from landscape.constants import FALSE_VALUES
1646@@ -167,8 +169,8 @@ class AptSources(ManagerPlugin):
1647 args.append(f"--config={self.registry.config.config}")
1648
1649 if os.getuid() == 0:
1650- uid = pwd.getpwnam("landscape").pw_uid
1651- gid = grp.getgrnam("landscape").gr_gid
1652+ uid = pwd.getpwnam(USER).pw_uid
1653+ gid = grp.getgrnam(GROUP).gr_gid
1654 else:
1655 uid = None
1656 gid = None
1657diff --git a/landscape/client/manager/config.py b/landscape/client/manager/config.py
1658index 2f8df8b..f89ebbc 100644
1659--- a/landscape/client/manager/config.py
1660+++ b/landscape/client/manager/config.py
1661@@ -13,6 +13,8 @@ ALL_PLUGINS = [
1662 "HardwareInfo",
1663 "KeystoneToken",
1664 "SnapManager",
1665+ "SnapServicesManager",
1666+ "UbuntuProInfo",
1667 ]
1668
1669
1670diff --git a/landscape/client/manager/hardwareinfo.py b/landscape/client/manager/hardwareinfo.py
1671index b475ee5..35b504d 100644
1672--- a/landscape/client/manager/hardwareinfo.py
1673+++ b/landscape/client/manager/hardwareinfo.py
1674@@ -12,7 +12,7 @@ class HardwareInfo(ManagerPlugin):
1675 message_type = "hardware-info"
1676 run_interval = 60 * 60 * 24
1677 run_immediately = True
1678- command = "/usr/bin/lshw"
1679+ command = "lshw"
1680
1681 def register(self, registry):
1682 super().register(registry)
1683diff --git a/landscape/client/manager/scriptexecution.py b/landscape/client/manager/scriptexecution.py
1684index ac591d7..6856185 100644
1685--- a/landscape/client/manager/scriptexecution.py
1686+++ b/landscape/client/manager/scriptexecution.py
1687@@ -18,6 +18,7 @@ from twisted.internet.protocol import ProcessProtocol
1688 from twisted.python.compat import unicode
1689
1690 from landscape import VERSION
1691+from landscape.client import IS_SNAP
1692 from landscape.client.manager.plugin import FAILED
1693 from landscape.client.manager.plugin import ManagerPlugin
1694 from landscape.client.manager.plugin import SUCCEEDED
1695@@ -85,6 +86,7 @@ class ScriptRunnerMixin:
1696 if process_factory is None:
1697 from twisted.internet import reactor as process_factory
1698 self.process_factory = process_factory
1699+ self.IS_SNAP = IS_SNAP
1700
1701 def is_user_allowed(self, user):
1702 allowed_users = self.registry.config.get_allowed_script_users()
1703@@ -96,8 +98,9 @@ class ScriptRunnerMixin:
1704 # It would be nice to use fchown(2) and fchmod(2), but they're not
1705 # available in python and using it with ctypes is pretty tedious, not
1706 # to mention we can't get errno.
1707+ # Don't attempt to change file owner if the client is a snap
1708 os.chmod(filename, 0o700)
1709- if uid is not None:
1710+ if not self.IS_SNAP and uid is not None:
1711 os.chown(filename, uid, gid)
1712
1713 script = build_script(shell, code)
1714@@ -172,7 +175,8 @@ class ScriptExecutionPlugin(ManagerPlugin, ScriptRunnerMixin):
1715 def _handle_execute_script(self, message):
1716 opid = message["operation-id"]
1717 try:
1718- user = message["username"]
1719+ user = message["username"] if not self.IS_SNAP else "root"
1720+
1721 if not self.is_user_allowed(user):
1722 return self._respond(
1723 FAILED,
1724@@ -245,11 +249,11 @@ class ScriptExecutionPlugin(ManagerPlugin, ScriptRunnerMixin):
1725 full_filename = os.path.join(attachment_dir, filename)
1726 with open(full_filename, "wb") as attachment:
1727 os.chmod(full_filename, 0o600)
1728- if uid is not None:
1729+ if not self.IS_SNAP and uid is not None:
1730 os.chown(full_filename, uid, gid)
1731 attachment.write(data)
1732 os.chmod(attachment_dir, 0o700)
1733- if uid is not None:
1734+ if not self.IS_SNAP and uid is not None:
1735 os.chown(attachment_dir, uid, gid)
1736 returnValue(attachment_dir)
1737
1738@@ -296,9 +300,15 @@ class ScriptExecutionPlugin(ManagerPlugin, ScriptRunnerMixin):
1739 "USER": user or "",
1740 "HOME": path or "",
1741 }
1742- for locale_var in ("LANG", "LC_ALL", "LC_CTYPE"):
1743- if locale_var in os.environ:
1744- env[locale_var] = os.environ[locale_var]
1745+ for env_var in (
1746+ "LANG",
1747+ "LC_ALL",
1748+ "LC_CTYPE",
1749+ "LD_LIBRARY_PATH",
1750+ "PYTHONPATH",
1751+ ):
1752+ if env_var in os.environ:
1753+ env[env_var] = os.environ[env_var]
1754 if server_supplied_env:
1755 env.update(server_supplied_env)
1756 old_umask = os.umask(0o022)
1757diff --git a/landscape/client/manager/service.py b/landscape/client/manager/service.py
1758index 7690442..c289685 100644
1759--- a/landscape/client/manager/service.py
1760+++ b/landscape/client/manager/service.py
1761@@ -1,3 +1,5 @@
1762+import logging
1763+
1764 from twisted.python.reflect import namedClass
1765
1766 from landscape.client.amp import ComponentPublisher
1767@@ -28,13 +30,26 @@ class ManagerService(LandscapeService):
1768
1769 def get_plugins(self):
1770 """Return instances of all the plugins enabled in the configuration."""
1771- return [
1772- namedClass(
1773- "landscape.client.manager."
1774- f"{plugin_name.lower()}.{plugin_name}",
1775- )()
1776- for plugin_name in self.config.plugin_factories
1777- ]
1778+ plugins = []
1779+
1780+ for plugin_name in self.config.plugin_factories:
1781+ try:
1782+ plugin = namedClass(
1783+ "landscape.client.manager."
1784+ f"{plugin_name.lower()}.{plugin_name}",
1785+ )
1786+ plugins.append(plugin())
1787+ except ModuleNotFoundError:
1788+ logging.warning(
1789+ f"Invalid manager plugin specified: '{plugin_name}'"
1790+ "See `example.conf` for a full list of monitor plugins.",
1791+ )
1792+ except Exception as exc:
1793+ logging.warning(
1794+ f"Unable to load manager plugin '{plugin_name}': {exc}",
1795+ )
1796+
1797+ return plugins
1798
1799 def startService(self): # noqa: N802
1800 """Start the manager service.
1801diff --git a/landscape/client/manager/shutdownmanager.py b/landscape/client/manager/shutdownmanager.py
1802index eba2be7..bd38533 100644
1803--- a/landscape/client/manager/shutdownmanager.py
1804+++ b/landscape/client/manager/shutdownmanager.py
1805@@ -1,94 +1,106 @@
1806 import logging
1807
1808-from twisted.internet.defer import Deferred
1809-from twisted.internet.error import ProcessDone
1810-from twisted.internet.protocol import ProcessProtocol
1811+import dbus
1812+from twisted.internet import reactor
1813+from twisted.internet import task
1814
1815 from landscape.client.manager.plugin import FAILED
1816 from landscape.client.manager.plugin import ManagerPlugin
1817 from landscape.client.manager.plugin import SUCCEEDED
1818
1819
1820-class ShutdownFailedError(Exception):
1821- """Raised when a call to C{/sbin/shutdown} fails.
1822-
1823- @ivar data: The data that the process printed before failing.
1824+class ShutdownManager(ManagerPlugin):
1825 """
1826+ Plugin that either shuts down or reboots the device.
1827
1828- def __init__(self, data):
1829- self.data = data
1830+ In both cases, the manager sends the success command
1831+ before attempting the shutdown/reboot.
1832+ With reboot - the call is instanteous but the success
1833+ message will be send as soon as the device comes back up.
1834
1835+ For shutdown there is a 120 second delay between
1836+ sending the success and firing the shutdown.
1837+ This is usually sufficent.
1838+ """
1839
1840-class ShutdownManager(ManagerPlugin):
1841- def __init__(self, process_factory=None):
1842- if process_factory is None:
1843- from twisted.internet import reactor as process_factory
1844- self._process_factory = process_factory
1845+ def __init__(self, dbus_provider=None, shutdown_delay=120):
1846+ if dbus_provider is None:
1847+ self.dbus_sysbus = dbus.SystemBus()
1848+ else:
1849+ self.dbus_sysbus = dbus_provider.SystemBus()
1850
1851- def register(self, registry):
1852- """Add this plugin to C{registry}.
1853+ self.shutdown_delay = shutdown_delay
1854
1855- The shutdown manager handles C{shutdown} activity messages broadcast
1856- from the server.
1857- """
1858+ def register(self, registry):
1859 super().register(registry)
1860- registry.register_message("shutdown", self.perform_shutdown)
1861+ self.config = registry.config
1862
1863- def perform_shutdown(self, message):
1864- """Request a system restart or shutdown.
1865+ registry.register_message("shutdown", self._handle_shutdown)
1866
1867- If the call to C{/sbin/shutdown} runs without errors the activity
1868- specified in the message will be responded as succeeded. Otherwise,
1869- it will be responded as failed.
1870+ def _handle_shutdown(self, message, DBus_System_Bus=None):
1871+ """
1872+ Choose shutdown or reboot
1873 """
1874 operation_id = message["operation-id"]
1875 reboot = message["reboot"]
1876- protocol = ShutdownProcessProtocol()
1877- protocol.set_timeout(self.registry.reactor)
1878- protocol.result.addCallback(self._respond_success, operation_id)
1879- protocol.result.addErrback(self._respond_failure, operation_id, reboot)
1880- command, args = self._get_command_and_args(protocol, reboot)
1881- self._process_factory.spawnProcess(protocol, command, args=args)
1882-
1883- def _respond_success(self, data, operation_id):
1884- logging.info("Shutdown request succeeded.")
1885+
1886+ if reboot:
1887+ logging.info("Reboot Requested")
1888+ deferred = self._respond_reboot_success(
1889+ "Reboot requested of the system",
1890+ operation_id,
1891+ )
1892+ return deferred
1893+ else:
1894+ logging.info("Shutdown Requested")
1895+ deferred = self._respond_shutdown_success(
1896+ "Shutdown requested of the system",
1897+ operation_id,
1898+ )
1899+ return deferred
1900+
1901+ def _Reboot(self, _, Dbus_System_bus=None):
1902+ logging.info("Sending Reboot Command")
1903+
1904+ bus_object = self.dbus_sysbus.get_object(
1905+ "org.freedesktop.login1",
1906+ "/org/freedesktop/login1",
1907+ )
1908+ bus_object.Reboot(
1909+ True,
1910+ dbus_interface="org.freedesktop.login1.Manager",
1911+ )
1912+
1913+ def _Shutdown(self):
1914+ logging.info("Sending Shutdown Command")
1915+ bus_object = self.dbus_sysbus.get_object(
1916+ "org.freedesktop.login1",
1917+ "/org/freedesktop/login1",
1918+ )
1919+ bus_object.PowerOff(
1920+ True,
1921+ dbus_interface="org.freedesktop.login1.Manager",
1922+ )
1923+
1924+ def _respond_reboot_success(self, data, operation_id):
1925 deferred = self._respond(SUCCEEDED, data, operation_id)
1926- # After sending the result to the server, stop accepting messages and
1927- # wait for the reboot/shutdown.
1928- deferred.addCallback(lambda _: self.registry.broker.stop_exchanger())
1929+ deferred.addCallback(self._Reboot)
1930+ deferred.addErrback(self._respond_fail)
1931 return deferred
1932
1933- def _respond_failure(self, failure, operation_id, reboot):
1934- logging.info("Shutdown request failed.")
1935- failure_report = "\n".join(
1936- [
1937- failure.value.data,
1938- "",
1939- "Attempting to force {operation}. Please note that if this "
1940- "succeeds, Landscape will have no way of knowing and will "
1941- "still mark this activity as having failed. It is recommended "
1942- "you check the state of the machine manually to determine "
1943- "whether {operation} succeeded.".format(
1944- operation="reboot" if reboot else "shutdown",
1945- ),
1946- ],
1947- )
1948- deferred = self._respond(FAILED, failure_report, operation_id)
1949- # Add another callback spawning the poweroff or reboot command (which
1950- # seem more reliable in aberrant situations like a post-trusty release
1951- # upgrade where upstart has been replaced with systemd). If this
1952- # succeeds, we won't have any opportunity to report it and if it fails
1953- # we'll already have responded indicating we're attempting to force
1954- # the operation so either way there's no sense capturing output
1955- protocol = ProcessProtocol()
1956- command, args = self._get_command_and_args(protocol, reboot, True)
1957- deferred.addCallback(
1958- lambda _: self._process_factory.spawnProcess(
1959- protocol,
1960- command,
1961- args=args,
1962- ),
1963+ def _respond_shutdown_success(self, data, operation_id):
1964+ deferred = self._respond(SUCCEEDED, data, operation_id)
1965+ self.shutdown_deferred = task.deferLater(
1966+ reactor,
1967+ self.shutdown_delay,
1968+ self._Shutdown,
1969 )
1970+ deferred.addErrback(self._respond_fail)
1971+ return deferred
1972+
1973+ def _respond_fail(self, data, operation_id):
1974+ logging.info("Shutdown/Reboot request failed.")
1975+ deferred = self._respond(FAILED, data, operation_id)
1976 return deferred
1977
1978 def _respond(self, status, data, operation_id):
1979@@ -103,93 +115,3 @@ class ShutdownManager(ManagerPlugin):
1980 self._session_id,
1981 True,
1982 )
1983-
1984- def _get_command_and_args(self, protocol, reboot, force=False):
1985- """
1986- Returns a C{command, args} 2-tuple suitable for use with
1987- L{IReactorProcess.spawnProcess}.
1988- """
1989- minutes = None if force else f"+{protocol.delay//60:d}"
1990- args = {
1991- (False, False): [
1992- "/sbin/shutdown",
1993- "-h",
1994- minutes,
1995- "Landscape is shutting down the system",
1996- ],
1997- (False, True): [
1998- "/sbin/shutdown",
1999- "-r",
2000- minutes,
2001- "Landscape is rebooting the system",
2002- ],
2003- (True, False): ["/sbin/poweroff"],
2004- (True, True): ["/sbin/reboot"],
2005- }[force, reboot]
2006- return args[0], args
2007-
2008-
2009-class ShutdownProcessProtocol(ProcessProtocol):
2010- """A ProcessProtocol for calling C{/sbin/shutdown}.
2011-
2012- C{shutdown} doesn't return immediately when a time specification is
2013- provided. Failures are reported immediately after it starts and return a
2014- non-zero exit code. The process protocol calls C{shutdown} and waits for
2015- failures for C{timeout} seconds. If no failures are reported it fires
2016- C{result}'s callback with whatever output was received from the process.
2017- If failures are reported C{result}'s errback is fired.
2018-
2019- @ivar result: A L{Deferred} fired when C{shutdown} fails or
2020- succeeds.
2021- @ivar reboot: A flag indicating whether a shutdown or reboot should be
2022- performed. Default is C{False}.
2023- @ivar delay: The time in seconds from now to schedule the shutdown.
2024- Default is 240 seconds. The time will be converted to minutes using
2025- integer division when passed to C{shutdown}.
2026- """
2027-
2028- def __init__(self, reboot=False, delay=240):
2029- self.result = Deferred()
2030- self.reboot = reboot
2031- self.delay = delay
2032- self._data = []
2033- self._waiting = True
2034-
2035- def get_data(self):
2036- """Get the data printed by the subprocess."""
2037- return b"".join(self._data).decode("utf-8", "replace")
2038-
2039- def set_timeout(self, reactor, timeout=10):
2040- """
2041- Set the error checking timeout, after which C{result}'s callback will
2042- be fired.
2043- """
2044- reactor.call_later(timeout, self._succeed)
2045-
2046- def childDataReceived(self, fd, data): # noqa: N802
2047- """Some data was received from the child.
2048-
2049- Add it to our buffer to pass to C{result} when it's fired.
2050- """
2051- if self._waiting:
2052- self._data.append(data)
2053-
2054- def processEnded(self, reason): # noqa: N802
2055- """Fire back the C{result} L{Deferred}.
2056-
2057- C{result}'s callback will be fired with the string of data received
2058- from the subprocess, or if the subprocess failed C{result}'s errback
2059- will be fired with the string of data received from the subprocess.
2060- """
2061- if self._waiting:
2062- if reason.check(ProcessDone):
2063- self._succeed()
2064- else:
2065- self.result.errback(ShutdownFailedError(self.get_data()))
2066- self._waiting = False
2067-
2068- def _succeed(self):
2069- """Fire C{result}'s callback with data accumulated from the process."""
2070- if self._waiting:
2071- self.result.callback(self.get_data())
2072- self._waiting = False
2073diff --git a/landscape/client/manager/snapmanager.py b/landscape/client/manager/snapmanager.py
2074index d34bafa..b4214c4 100644
2075--- a/landscape/client/manager/snapmanager.py
2076+++ b/landscape/client/manager/snapmanager.py
2077@@ -3,49 +3,22 @@ from collections import deque
2078
2079 from twisted.internet import task
2080
2081+from landscape.client import snap_http
2082 from landscape.client.manager.plugin import FAILED
2083 from landscape.client.manager.plugin import ManagerPlugin
2084 from landscape.client.manager.plugin import SUCCEEDED
2085-from landscape.client.snap.http import INCOMPLETE_STATUSES
2086-from landscape.client.snap.http import SnapdHttpException
2087-from landscape.client.snap.http import SnapHttp
2088-from landscape.client.snap.http import SUCCESS_STATUSES
2089+from landscape.client.snap_http import INCOMPLETE_STATUSES
2090+from landscape.client.snap_http import SnapdHttpException
2091+from landscape.client.snap_http import SUCCESS_STATUSES
2092
2093
2094-class SnapManager(ManagerPlugin):
2095- """
2096- Plugin that updates the state of snaps on this machine, installing,
2097- removing, refreshing, enabling, and disabling them in response to messages.
2098-
2099- Changes trigger SnapMonitor to send an updated state message immediately.
2100- """
2101+class BaseSnapManager(ManagerPlugin):
2102+ """Base class that provides machinery for snap manager tasks."""
2103
2104 def __init__(self):
2105 super().__init__()
2106
2107- self._snap_http = SnapHttp()
2108- self.SNAP_METHODS = {
2109- "install-snaps": self._snap_http.install_snap,
2110- "install-snaps-batch": self._snap_http.install_snaps,
2111- "remove-snaps": self._snap_http.remove_snap,
2112- "remove-snaps-batch": self._snap_http.remove_snaps,
2113- "refresh-snaps": self._snap_http.refresh_snap,
2114- "refresh-snaps-batch": self._snap_http.refresh_snaps,
2115- "hold-snaps": self._snap_http.hold_snap,
2116- "hold-snaps-batch": self._snap_http.hold_snaps,
2117- "unhold-snaps": self._snap_http.unhold_snap,
2118- "unhold-snaps-batch": self._snap_http.unhold_snaps,
2119- }
2120-
2121- def register(self, registry):
2122- super().register(registry)
2123- self.config = registry.config
2124-
2125- registry.register_message("install-snaps", self._handle_snap_task)
2126- registry.register_message("remove-snaps", self._handle_snap_task)
2127- registry.register_message("refresh-snaps", self._handle_snap_task)
2128- registry.register_message("hold-snaps", self._handle_snap_task)
2129- registry.register_message("unhold-snaps", self._handle_snap_task)
2130+ self.SNAP_METHODS = {}
2131
2132 def _handle_snap_task(self, message):
2133 """
2134@@ -84,7 +57,7 @@ class SnapManager(ManagerPlugin):
2135 snaps,
2136 **snap_args,
2137 )
2138- queue.append((response["change"], "BATCH"))
2139+ queue.append((response.change, "BATCH"))
2140 except SnapdHttpException as e:
2141 result = e.json["result"]
2142 logging.error(
2143@@ -128,7 +101,7 @@ class SnapManager(ManagerPlugin):
2144 name,
2145 **snap_args,
2146 )
2147- queue.append((response["change"], name))
2148+ queue.append((response.change, name))
2149 except SnapdHttpException as e:
2150 result = e.json["result"]
2151 logging.error(
2152@@ -161,7 +134,7 @@ class SnapManager(ManagerPlugin):
2153 logging.info("Polling snapd for status of pending snap changes")
2154
2155 try:
2156- result = self._snap_http.check_changes().get("result", [])
2157+ result = snap_http.check_changes().result
2158 result_dict = {c["id"]: c for c in result}
2159 except SnapdHttpException as e:
2160 logging.error(f"Error checking status of snap changes: {e}")
2161@@ -206,7 +179,7 @@ class SnapManager(ManagerPlugin):
2162
2163 response = snap_method(*args, **kwargs)
2164
2165- if "change" not in response:
2166+ if response.change is None:
2167 raise SnapdHttpException(response)
2168
2169 return response
2170@@ -243,17 +216,57 @@ class SnapManager(ManagerPlugin):
2171
2172 logging.debug("Sending snap-action-done response")
2173
2174- # Kick off an immediate SnapMonitor message as well.
2175- self._send_installed_snap_update()
2176+ # Kick off an immediate monitor message as well.
2177+ self._send_snap_update()
2178 return self.registry.broker.send_message(
2179 message,
2180 self._session_id,
2181 True,
2182 )
2183
2184- def _send_installed_snap_update(self):
2185+ def _send_snap_update(self):
2186+ """Kick off an immediate monitor message."""
2187+
2188+
2189+class SnapManager(BaseSnapManager):
2190+ """
2191+ Plugin that updates the state of snaps on this machine, installing,
2192+ removing, refreshing, enabling, and disabling them in response to messages.
2193+
2194+ Changes trigger SnapMonitor to send an updated state message immediately.
2195+ """
2196+
2197+ def __init__(self):
2198+ super().__init__()
2199+
2200+ self.SNAP_METHODS = {
2201+ "install-snaps": snap_http.install,
2202+ "install-snaps-batch": snap_http.install_all,
2203+ "remove-snaps": snap_http.remove,
2204+ "remove-snaps-batch": snap_http.remove_all,
2205+ "refresh-snaps": snap_http.refresh,
2206+ "refresh-snaps-batch": snap_http.refresh_all,
2207+ "hold-snaps": snap_http.hold,
2208+ "hold-snaps-batch": snap_http.hold_all,
2209+ "unhold-snaps": snap_http.unhold,
2210+ "unhold-snaps-batch": snap_http.unhold_all,
2211+ "set-snap-config": snap_http.set_conf,
2212+ }
2213+
2214+ def register(self, registry):
2215+ super().register(registry)
2216+ self.config = registry.config
2217+
2218+ registry.register_message("install-snaps", self._handle_snap_task)
2219+ registry.register_message("remove-snaps", self._handle_snap_task)
2220+ registry.register_message("refresh-snaps", self._handle_snap_task)
2221+ registry.register_message("hold-snaps", self._handle_snap_task)
2222+ registry.register_message("unhold-snaps", self._handle_snap_task)
2223+ registry.register_message("set-snap-config", self._handle_snap_task)
2224+
2225+ def _send_snap_update(self):
2226 try:
2227- installed_snaps = self._snap_http.get_snaps()
2228+ installed_snaps = snap_http.list().result
2229 except SnapdHttpException as e:
2230 logging.error(
2231 f"Unable to list installed snaps after snap change: {e}",
2232@@ -264,7 +277,7 @@ class SnapManager(ManagerPlugin):
2233 return self.registry.broker.send_message(
2234 {
2235 "type": "snaps",
2236- "snaps": installed_snaps,
2237+ "snaps": {"installed": installed_snaps},
2238 },
2239 self._session_id,
2240 True,
2241diff --git a/landscape/client/manager/snapservicesmanager.py b/landscape/client/manager/snapservicesmanager.py
2242new file mode 100644
2243index 0000000..149e171
2244--- /dev/null
2245+++ b/landscape/client/manager/snapservicesmanager.py
2246@@ -0,0 +1,60 @@
2247+import logging
2248+
2249+from landscape.client import snap_http
2250+from landscape.client.manager.snapmanager import BaseSnapManager
2251+from landscape.client.snap_http import SnapdHttpException
2252+
2253+
2254+class SnapServicesManager(BaseSnapManager):
2255+ """
2256+ Plugin that updates the state of snap services on this machine, starting,
2257+ stopping, restarting, enabling, disabling, and reloading them in response
2258+ to messages.
2259+
2260+ Changes trigger SnapServicesMonitor to send an updated state message
2261+ immediately.
2262+ """
2263+
2264+ def __init__(self):
2265+ super().__init__()
2266+
2267+ self.SNAP_METHODS = {
2268+ "start-snap-service": snap_http.start,
2269+ "start-snap-service-batch": snap_http.start_all,
2270+ "stop-snap-service": snap_http.stop,
2271+ "stop-snap-service-batch": snap_http.stop_all,
2272+ "restart-snap-service": snap_http.restart,
2273+ "restart-snap-service-batch": snap_http.restart_all,
2274+ }
2275+
2276+ def register(self, registry):
2277+ super().register(registry)
2278+ self.config = registry.config
2279+
2280+ message_types = [
2281+ "start-snap-service",
2282+ "start-snap-service-batch",
2283+ "stop-snap-service",
2284+ "stop-snap-service-batch",
2285+ "restart-snap-service",
2286+ "restart-snap-service-batch",
2287+ ]
2288+ for msg_type in message_types:
2289+ registry.register_message(msg_type, self._handle_snap_task)
2290+
2291+ def _send_snap_update(self):
2292+ try:
2293+ services = snap_http.get_apps(services_only=True).result
2294+ except SnapdHttpException as e:
2295+ logging.error(f"Unable to list services: {e}")
2296+ return
2297+
2298+ if services:
2299+ return self.registry.broker.send_message(
2300+ {
2301+ "type": "snap-services",
2302+ "services": {"running": services},
2303+ },
2304+ self._session_id,
2305+ True,
2306+ )
2307diff --git a/landscape/client/manager/tests/test_aptsources.py b/landscape/client/manager/tests/test_aptsources.py
2308index 4903594..aa648be 100644
2309--- a/landscape/client/manager/tests/test_aptsources.py
2310+++ b/landscape/client/manager/tests/test_aptsources.py
2311@@ -456,3 +456,46 @@ class AptSourcesTests(LandscapeTest):
2312 )
2313
2314 return deferred
2315+
2316+ def test_run_reporter_snap(self):
2317+ """After receiving a message, `AptSources` in a snap triggers a
2318+ reporter run as root to have the new packages reported to the server.
2319+ """
2320+ deferred = Deferred()
2321+
2322+ def _run_process(command, args, env={}, path=None, uid=None, gid=None):
2323+ self.assertEqual(
2324+ find_reporter_command(self.manager.config),
2325+ command,
2326+ )
2327+ self.assertEqual(
2328+ [
2329+ "--force-apt-update",
2330+ f"--config={self.manager.config.config}",
2331+ ],
2332+ args,
2333+ )
2334+ self.assertEqual(uid, 0)
2335+ self.assertEqual(gid, 0)
2336+ deferred.callback(("ok", "", 0))
2337+ return deferred
2338+
2339+ self.sourceslist._run_process = _run_process
2340+
2341+ with mock.patch.multiple(
2342+ "landscape.client.manager.aptsources",
2343+ USER="root",
2344+ GROUP="root",
2345+ ):
2346+ with mock.patch("os.getuid") as getuid:
2347+ getuid.return_value = 0
2348+ self.manager.dispatch_message(
2349+ {
2350+ "type": "apt-sources-replace",
2351+ "sources": [],
2352+ "gpg-keys": [],
2353+ "operation-id": 1,
2354+ },
2355+ )
2356+
2357+ return deferred
2358diff --git a/landscape/client/manager/tests/test_config.py b/landscape/client/manager/tests/test_config.py
2359index da0c052..085bd09 100644
2360--- a/landscape/client/manager/tests/test_config.py
2361+++ b/landscape/client/manager/tests/test_config.py
2362@@ -21,6 +21,8 @@ class ManagerConfigurationTest(LandscapeTest):
2363 "HardwareInfo",
2364 "KeystoneToken",
2365 "SnapManager",
2366+ "SnapServicesManager",
2367+ "UbuntuProInfo",
2368 ],
2369 ALL_PLUGINS,
2370 )
2371diff --git a/landscape/client/manager/tests/test_processkiller.py b/landscape/client/manager/tests/test_processkiller.py
2372index ff906cd..f8408f8 100644
2373--- a/landscape/client/manager/tests/test_processkiller.py
2374+++ b/landscape/client/manager/tests/test_processkiller.py
2375@@ -99,7 +99,7 @@ class ProcessKillerTests(LandscapeTest):
2376 signaller.register(self.manager)
2377 popen = get_active_process()
2378 process_info = process_info_factory.get_process_info(popen.pid)
2379- self.assertNotEquals(process_info, None)
2380+ self.assertNotEqual(process_info, None)
2381 start_time = process_info["start-time"]
2382
2383 self.manager.dispatch_message(
2384diff --git a/landscape/client/manager/tests/test_scriptexecution.py b/landscape/client/manager/tests/test_scriptexecution.py
2385index e560f49..043c80d 100644
2386--- a/landscape/client/manager/tests/test_scriptexecution.py
2387+++ b/landscape/client/manager/tests/test_scriptexecution.py
2388@@ -42,7 +42,13 @@ def get_default_environment():
2389 "USER": username,
2390 "HOME": home,
2391 }
2392- for var in {"LANG", "LC_ALL", "LC_CTYPE"}:
2393+ for var in {
2394+ "LANG",
2395+ "LC_ALL",
2396+ "LC_CTYPE",
2397+ "LD_LIBRARY_PATH",
2398+ "PYTHONPATH",
2399+ }:
2400 if var in os.environ:
2401 env[var] = os.environ[var]
2402 return env
2403@@ -415,9 +421,14 @@ class RunScriptTests(LandscapeTest):
2404 result.addCallback(check)
2405 return result
2406
2407- def _run_script(self, username, uid, gid, path):
2408- expected_uid = uid if uid != os.getuid() else None
2409- expected_gid = gid if gid != os.getgid() else None
2410+ def _run_script(self, username, uid, gid, path, from_snap=None):
2411+
2412+ if from_snap:
2413+ expected_gid = None
2414+ expected_uid = None
2415+ else:
2416+ expected_uid = uid if uid != os.getuid() else None
2417+ expected_gid = gid if gid != os.getgid() else None
2418
2419 factory = StubProcessFactory()
2420 self.plugin.process_factory = factory
2421@@ -426,6 +437,7 @@ class RunScriptTests(LandscapeTest):
2422 patch_chown = mock.patch("os.chown")
2423 mock_chown = patch_chown.start()
2424
2425+ self.plugin.IS_SNAP = from_snap
2426 result = self.plugin.run_script("/bin/sh", "echo hi", user=username)
2427
2428 self.assertEqual(len(factory.spawns), 1)
2429@@ -441,14 +453,18 @@ class RunScriptTests(LandscapeTest):
2430 protocol.processEnded(Failure(ProcessDone(0)))
2431
2432 def check(result):
2433- mock_chown.assert_called_with()
2434+ if from_snap:
2435+ mock_chown.assert_not_called()
2436+ else:
2437+ mock_chown.assert_called()
2438+
2439 self.assertEqual(result, "foobar")
2440
2441 def cleanup(result):
2442 patch_chown.stop()
2443 return result
2444
2445- return result.addErrback(check).addBoth(cleanup)
2446+ return result.addCallback(check).addBoth(cleanup)
2447
2448 def test_user(self):
2449 """
2450@@ -463,7 +479,28 @@ class RunScriptTests(LandscapeTest):
2451 gid = info.pw_gid
2452 path = info.pw_dir
2453
2454- return self._run_script(username, uid, gid, path)
2455+ if not os.path.exists(path):
2456+ path = "/"
2457+
2458+ return self._run_script(username, uid, gid, path, from_snap=None)
2459+
2460+ def test_user_from_snap(self):
2461+ """
2462+ Running a script as a particular user calls
2463+ C{IReactorProcess.spawnProcess} with an appropriate C{uid} argument,
2464+ with the user's primary group as the C{gid} argument and with the user
2465+ home as C{path} argument.
2466+ """
2467+ uid = os.getuid()
2468+ info = pwd.getpwuid(uid)
2469+ username = info.pw_name
2470+ gid = info.pw_gid
2471+ path = info.pw_dir
2472+
2473+ if not os.path.exists(path):
2474+ path = "/"
2475+
2476+ return self._run_script(username, uid, gid, path, from_snap=True)
2477
2478 def test_user_no_home(self):
2479 """
2480diff --git a/landscape/client/manager/tests/test_service.py b/landscape/client/manager/tests/test_service.py
2481index 91b0211..44b2a32 100644
2482--- a/landscape/client/manager/tests/test_service.py
2483+++ b/landscape/client/manager/tests/test_service.py
2484@@ -1,3 +1,5 @@
2485+from unittest import mock
2486+
2487 from landscape.client.manager.config import ALL_PLUGINS
2488 from landscape.client.manager.config import ManagerConfiguration
2489 from landscape.client.manager.processkiller import ProcessKiller
2490@@ -11,22 +13,31 @@ class ManagerServiceTest(LandscapeTest):
2491
2492 helpers = [FakeBrokerServiceHelper]
2493
2494+ class FakeManagerService(ManagerService):
2495+ reactor_factory = FakeReactor
2496+
2497 def setUp(self):
2498 super().setUp()
2499 config = ManagerConfiguration()
2500 config.load(["-c", self.config_filename])
2501
2502- class FakeManagerService(ManagerService):
2503- reactor_factory = FakeReactor
2504+ self.service = self.FakeManagerService(config)
2505
2506- self.service = FakeManagerService(config)
2507-
2508- def test_plugins(self):
2509+ @mock.patch("dbus.SystemBus")
2510+ def test_plugins(self, system_bus_mock):
2511 """
2512 By default the L{ManagerService.plugins} list holds an instance of
2513 every enabled manager plugin.
2514+
2515+ We mock `dbus` because in some build environments that run these tests,
2516+ such as buildd, SystemBus is not available.
2517 """
2518- self.assertEqual(len(self.service.plugins), len(ALL_PLUGINS))
2519+ config = ManagerConfiguration()
2520+ config.load(["-c", self.config_filename])
2521+ service = self.FakeManagerService(config)
2522+
2523+ self.assertEqual(len(service.plugins), len(ALL_PLUGINS))
2524+ system_bus_mock.assert_called_once_with()
2525
2526 def test_get_plugins(self):
2527 """
2528@@ -37,6 +48,34 @@ class ManagerServiceTest(LandscapeTest):
2529 [plugin] = self.service.get_plugins()
2530 self.assertTrue(isinstance(plugin, ProcessKiller))
2531
2532+ def test_get_plugins_module_not_found(self):
2533+ """If a module is not found, a warning is logged."""
2534+ self.service.config.load(["--manager-plugins", "TotallyDoesNotExist"])
2535+
2536+ with self.assertLogs(level="WARN") as cm:
2537+ plugins = self.service.get_plugins()
2538+
2539+ self.assertEqual(len(plugins), 0)
2540+ self.assertIn("Invalid manager plugin", cm.output[0])
2541+ self.assertIn("TotallyDoesNotExist", cm.output[0])
2542+
2543+ def test_get_plugins_other_exception(self):
2544+ """If loading a plugin fails for another reason, a warning is logged,
2545+ with the exception.
2546+ """
2547+ self.service.config.load(["--manager-plugins", "ProcessKiller"])
2548+
2549+ with self.assertLogs(level="WARN") as cm:
2550+ with mock.patch(
2551+ "landscape.client.manager.service.namedClass",
2552+ ) as namedClass:
2553+ namedClass.side_effect = Exception("Is there life on Mars?")
2554+ plugins = self.service.get_plugins()
2555+
2556+ self.assertEqual(len(plugins), 0)
2557+ self.assertIn("Unable to load", cm.output[0])
2558+ self.assertIn("Mars?", cm.output[0])
2559+
2560 def test_start_service(self):
2561 """
2562 The L{ManagerService.startService} method connects to the broker,
2563diff --git a/landscape/client/manager/tests/test_shutdownmanager.py b/landscape/client/manager/tests/test_shutdownmanager.py
2564index 4d2a28e..005b0cc 100644
2565--- a/landscape/client/manager/tests/test_shutdownmanager.py
2566+++ b/landscape/client/manager/tests/test_shutdownmanager.py
2567@@ -1,15 +1,10 @@
2568-from twisted.internet.error import ProcessDone
2569-from twisted.internet.error import ProcessTerminated
2570-from twisted.internet.protocol import ProcessProtocol
2571-from twisted.python.failure import Failure
2572+from unittest.mock import Mock
2573+
2574+import twisted.internet.defer
2575
2576-from landscape.client.manager.plugin import FAILED
2577-from landscape.client.manager.plugin import SUCCEEDED
2578 from landscape.client.manager.shutdownmanager import ShutdownManager
2579-from landscape.client.manager.shutdownmanager import ShutdownProcessProtocol
2580 from landscape.client.tests.helpers import LandscapeTest
2581 from landscape.client.tests.helpers import ManagerHelper
2582-from landscape.lib.testing import StubProcessFactory
2583
2584
2585 class ShutdownManagerTest(LandscapeTest):
2586@@ -18,179 +13,40 @@ class ShutdownManagerTest(LandscapeTest):
2587
2588 def setUp(self):
2589 super().setUp()
2590+
2591 self.broker_service.message_store.set_accepted_types(
2592 ["shutdown", "operation-result"],
2593 )
2594 self.broker_service.pinger.start()
2595- self.process_factory = StubProcessFactory()
2596- self.plugin = ShutdownManager(process_factory=self.process_factory)
2597+
2598+ self.dbus_mock = Mock()
2599+ self.dbus_sysbus_mock = Mock()
2600+ self.dbus_mock.get_object.return_value = self.dbus_sysbus_mock
2601+ self.plugin = ShutdownManager(self.dbus_mock, shutdown_delay=0)
2602 self.manager.add(self.plugin)
2603
2604- def test_restart(self):
2605- """
2606- C{shutdown} processes run until the shutdown is to be performed. The
2607- L{ShutdownProcessProtocol} watches a process for errors, for 10
2608- seconds by default, and if none occur the activity is marked as
2609- L{SUCCEEDED}. Data printed by the process is included in the
2610- activity's result text.
2611- """
2612+ def test_reboot(self):
2613 message = {"type": "shutdown", "reboot": True, "operation-id": 100}
2614- self.plugin.perform_shutdown(message)
2615- [arguments] = self.process_factory.spawns
2616- protocol = arguments[0]
2617- self.assertTrue(isinstance(protocol, ShutdownProcessProtocol))
2618- self.assertEqual(
2619- arguments[1:3],
2620- (
2621- "/sbin/shutdown",
2622- [
2623- "/sbin/shutdown",
2624- "-r",
2625- "+4",
2626- "Landscape is rebooting the system",
2627- ],
2628- ),
2629- )
2630+ deferred = self.plugin._handle_shutdown(message)
2631
2632- def restart_performed(ignore):
2633- self.assertTrue(self.broker_service.exchanger.is_urgent())
2634- self.assertEqual(
2635- self.broker_service.message_store.get_pending_messages(),
2636- [
2637- {
2638- "type": "operation-result",
2639- "api": b"3.2",
2640- "operation-id": 100,
2641- "timestamp": 10,
2642- "status": SUCCEEDED,
2643- "result-text": "Data may arrive in batches.",
2644- },
2645- ],
2646- )
2647+ def check(_):
2648+ self.plugin.dbus_sysbus.get_object.assert_called_once()
2649+ self.plugin.dbus_sysbus.get_object().Reboot.assert_called_once()
2650
2651- protocol.result.addCallback(restart_performed)
2652- protocol.childDataReceived(0, b"Data may arrive ")
2653- protocol.childDataReceived(0, b"in batches.")
2654- # We need to advance both reactors to simulate that fact they
2655- # are loosely in sync with each other
2656- self.broker_service.reactor.advance(10)
2657- self.manager.reactor.advance(10)
2658- return protocol.result
2659+ deferred.addCallback(check)
2660+ return deferred
2661
2662 def test_shutdown(self):
2663- """
2664- C{shutdown} messages have a flag that indicates whether a reboot or
2665- shutdown has been requested. The C{shutdown} command is called
2666- appropriately.
2667- """
2668 message = {"type": "shutdown", "reboot": False, "operation-id": 100}
2669- self.plugin.perform_shutdown(message)
2670- [arguments] = self.process_factory.spawns
2671- self.assertEqual(
2672- arguments[1:3],
2673- (
2674- "/sbin/shutdown",
2675- [
2676- "/sbin/shutdown",
2677- "-h",
2678- "+4",
2679- "Landscape is shutting down the system",
2680- ],
2681- ),
2682- )
2683-
2684- def test_restart_fails(self):
2685- """
2686- If an error occurs before the error checking timeout the activity will
2687- be failed. Data printed by the process prior to the failure is
2688- included in the activity's result text.
2689- """
2690- message = {"type": "shutdown", "reboot": True, "operation-id": 100}
2691- self.plugin.perform_shutdown(message)
2692-
2693- def restart_failed(message_id):
2694- self.assertTrue(self.broker_service.exchanger.is_urgent())
2695- messages = self.broker_service.message_store.get_pending_messages()
2696- self.assertEqual(len(messages), 1)
2697- message = messages[0]
2698- self.assertEqual(message["type"], "operation-result")
2699- self.assertEqual(message["api"], b"3.2")
2700- self.assertEqual(message["operation-id"], 100)
2701- self.assertEqual(message["timestamp"], 0)
2702- self.assertEqual(message["status"], FAILED)
2703- self.assertIn("Failure text is reported.", message["result-text"])
2704-
2705- # Check that after failing, we attempt to force the shutdown by
2706- # switching the binary called
2707- [spawn1_args, spawn2_args] = self.process_factory.spawns
2708- protocol = spawn2_args[0]
2709- self.assertIsInstance(protocol, ProcessProtocol)
2710- self.assertEqual(
2711- spawn2_args[1:3],
2712- ("/sbin/reboot", ["/sbin/reboot"]),
2713- )
2714-
2715- [arguments] = self.process_factory.spawns
2716- protocol = arguments[0]
2717- protocol.result.addCallback(restart_failed)
2718- protocol.childDataReceived(0, b"Failure text is reported.")
2719- protocol.processEnded(Failure(ProcessTerminated(exitCode=1)))
2720- return protocol.result
2721+ deferred = self.plugin._handle_shutdown(message)
2722
2723- def test_process_ends_after_timeout(self):
2724- """
2725- If the process ends after the error checking timeout has passed
2726- C{result} will not be re-fired.
2727- """
2728- message = {"type": "shutdown", "reboot": False, "operation-id": 100}
2729- self.plugin.perform_shutdown(message)
2730-
2731- stash = []
2732-
2733- def restart_performed(ignore):
2734- self.assertEqual(stash, [])
2735- stash.append(True)
2736-
2737- [arguments] = self.process_factory.spawns
2738- protocol = arguments[0]
2739- protocol.result.addCallback(restart_performed)
2740- self.manager.reactor.advance(10)
2741- protocol.processEnded(Failure(ProcessTerminated(exitCode=1)))
2742- return protocol.result
2743-
2744- def test_process_data_is_not_collected_after_firing_result(self):
2745- """
2746- Data printed in the sub-process is not collected after C{result} has
2747- been fired.
2748- """
2749- message = {"type": "shutdown", "reboot": False, "operation-id": 100}
2750- self.plugin.perform_shutdown(message)
2751-
2752- [arguments] = self.process_factory.spawns
2753- protocol = arguments[0]
2754- protocol.childDataReceived(0, b"Data may arrive ")
2755- protocol.childDataReceived(0, b"in batches.")
2756- self.manager.reactor.advance(10)
2757- self.assertEqual(protocol.get_data(), "Data may arrive in batches.")
2758- protocol.childDataReceived(0, b"Even when you least expect it.")
2759- self.assertEqual(protocol.get_data(), "Data may arrive in batches.")
2760-
2761- def test_restart_stops_exchanger(self):
2762- """
2763- After a successful shutdown, the broker stops processing new messages.
2764- """
2765- message = {"type": "shutdown", "reboot": False, "operation-id": 100}
2766- self.plugin.perform_shutdown(message)
2767+ def check(_):
2768+ self.plugin.dbus_sysbus.get_object.assert_called_once()
2769+ self.plugin.dbus_sysbus.get_object().PowerOff.assert_called_once()
2770
2771- [arguments] = self.process_factory.spawns
2772- protocol = arguments[0]
2773- protocol.processEnded(Failure(ProcessDone(status=0)))
2774- self.broker_service.reactor.advance(100)
2775- self.manager.reactor.advance(100)
2776+ self.plugin.shutdown_deferred.addCallback(check)
2777+ return deferred
2778
2779- # New messages will not be exchanged after a reboot process is in
2780- # process.
2781- self.manager.broker.exchanger.schedule_exchange()
2782- payloads = self.manager.broker.exchanger._transport.payloads
2783- self.assertEqual(0, len(payloads))
2784- return protocol.result
2785+ def test_shutdown_failed(self):
2786+ deferred = self.plugin._respond_fail("", 100)
2787+ self.assertIsInstance(deferred, twisted.internet.defer.Deferred)
2788diff --git a/landscape/client/manager/tests/test_snapmanager.py b/landscape/client/manager/tests/test_snapmanager.py
2789index 1ce52f1..70cd6c3 100644
2790--- a/landscape/client/manager/tests/test_snapmanager.py
2791+++ b/landscape/client/manager/tests/test_snapmanager.py
2792@@ -3,8 +3,8 @@ from unittest import mock
2793 from landscape.client.manager.manager import FAILED
2794 from landscape.client.manager.manager import SUCCEEDED
2795 from landscape.client.manager.snapmanager import SnapManager
2796-from landscape.client.snap.http import SnapdHttpException
2797-from landscape.client.snap.http import SnapHttp as OrigSnapHttp
2798+from landscape.client.snap_http import SnapdHttpException
2799+from landscape.client.snap_http import SnapdResponse
2800 from landscape.client.tests.helpers import LandscapeTest
2801 from landscape.client.tests.helpers import ManagerHelper
2802
2803@@ -15,13 +15,10 @@ class SnapManagerTest(LandscapeTest):
2804 def setUp(self):
2805 super().setUp()
2806
2807- self.snap_http = mock.Mock(spec_set=OrigSnapHttp)
2808- self.SnapHttp = mock.patch(
2809- "landscape.client.manager.snapmanager.SnapHttp",
2810+ self.snap_http = mock.patch(
2811+ "landscape.client.manager.snapmanager.snap_http",
2812 ).start()
2813
2814- self.SnapHttp.return_value = self.snap_http
2815-
2816 self.broker_service.message_store.set_accepted_types(
2817 ["operation-result"],
2818 )
2819@@ -42,34 +39,37 @@ class SnapManagerTest(LandscapeTest):
2820
2821 def install_snap(name, revision=None, channel=None, classic=False):
2822 if name == "hello":
2823- return {"change": "1"}
2824+ return SnapdResponse("async", 200, "OK", None, change="1")
2825
2826 if name == "goodbye":
2827- return {"change": "2"}
2828+ return SnapdResponse("async", 200, "OK", None, change="2")
2829
2830 return mock.DEFAULT
2831
2832- self.snap_http.install_snap.side_effect = install_snap
2833- self.snap_http.check_changes.return_value = {
2834- "result": [
2835+ self.snap_http.install.side_effect = install_snap
2836+ self.snap_http.check_changes.return_value = SnapdResponse(
2837+ "sync",
2838+ "200",
2839+ "OK",
2840+ [
2841 {"id": "1", "status": "Done"},
2842 {"id": "2", "status": "Done"},
2843 ],
2844- }
2845- self.snap_http.get_snaps.return_value = {"installed": []}
2846+ )
2847+ self.snap_http.list.return_value = SnapdResponse("sync", 200, "OK", [])
2848
2849 result = self.manager.dispatch_message(
2850 {
2851 "type": "install-snaps",
2852 "operation-id": 123,
2853 "snaps": [
2854- {"name": "hello", "revision": 9001},
2855+ {"name": "hello", "args": {"revision": 9001}},
2856 {"name": "goodbye"},
2857 ],
2858 },
2859 )
2860
2861- def got_result(r):
2862+ def got_result(_):
2863 self.assertMessages(
2864 self.broker_service.message_store.get_pending_messages(),
2865 [
2866@@ -90,12 +90,24 @@ class SnapManagerTest(LandscapeTest):
2867 When no channels or revisions are specified, snaps are installed
2868 via a single call to snapd.
2869 """
2870- self.snap_http.install_snaps.return_value = {"change": "1"}
2871- self.snap_http.check_changes.return_value = {
2872- "result": [{"id": "1", "status": "Done"}],
2873- }
2874- self.snap_http.get_snaps.return_value = {
2875- "installed": [
2876+ self.snap_http.install_all.return_value = SnapdResponse(
2877+ "async",
2878+ 202,
2879+ "Accepted",
2880+ None,
2881+ change="1",
2882+ )
2883+ self.snap_http.check_changes.return_value = SnapdResponse(
2884+ "sync",
2885+ 200,
2886+ "OK",
2887+ [{"id": "1", "status": "Done"}],
2888+ )
2889+ self.snap_http.list.return_value = SnapdResponse(
2890+ "sync",
2891+ 200,
2892+ "OK",
2893+ [
2894 {
2895 "name": "hello",
2896 "id": "test",
2897@@ -106,7 +118,7 @@ class SnapManagerTest(LandscapeTest):
2898 "version": "1.2.3",
2899 },
2900 ],
2901- }
2902+ )
2903
2904 result = self.manager.dispatch_message(
2905 {
2906@@ -119,7 +131,7 @@ class SnapManagerTest(LandscapeTest):
2907 },
2908 )
2909
2910- def got_result(r):
2911+ def got_result(_):
2912 self.assertMessages(
2913 self.broker_service.message_store.get_pending_messages(),
2914 [
2915@@ -136,10 +148,10 @@ class SnapManagerTest(LandscapeTest):
2916 return result.addCallback(got_result)
2917
2918 def test_install_snap_immediate_error(self):
2919- self.snap_http.install_snaps.side_effect = SnapdHttpException(
2920+ self.snap_http.install_all.side_effect = SnapdHttpException(
2921 b'{"result": "whoops"}',
2922 )
2923- self.snap_http.get_snaps.return_value = {"installed": []}
2924+ self.snap_http.list.return_value = SnapdResponse("sync", 200, "OK", [])
2925
2926 result = self.manager.dispatch_message(
2927 {
2928@@ -151,7 +163,7 @@ class SnapManagerTest(LandscapeTest):
2929
2930 self.log_helper.ignore_errors(r".+whoops$")
2931
2932- def got_result(r):
2933+ def got_result(_):
2934 self.assertMessages(
2935 self.broker_service.message_store.get_pending_messages(),
2936 [
2937@@ -168,9 +180,20 @@ class SnapManagerTest(LandscapeTest):
2938 return result.addCallback(got_result)
2939
2940 def test_install_snap_no_status(self):
2941- self.snap_http.install_snaps.return_value = {"change": "1"}
2942- self.snap_http.check_changes.return_value = {"result": []}
2943- self.snap_http.get_snaps.return_value = {"installed": []}
2944+ self.snap_http.install_all.return_value = SnapdResponse(
2945+ "async",
2946+ 202,
2947+ "Accepted",
2948+ None,
2949+ change="1",
2950+ )
2951+ self.snap_http.check_changes.return_value = SnapdResponse(
2952+ "sync",
2953+ 200,
2954+ "OK",
2955+ [],
2956+ )
2957+ self.snap_http.list.return_value = SnapdResponse("sync", 200, "OK", [])
2958
2959 result = self.manager.dispatch_message(
2960 {
2961@@ -180,7 +203,7 @@ class SnapManagerTest(LandscapeTest):
2962 },
2963 )
2964
2965- def got_result(r):
2966+ def got_result(_):
2967 self.assertMessages(
2968 self.broker_service.message_store.get_pending_messages(),
2969 [
2970@@ -197,9 +220,15 @@ class SnapManagerTest(LandscapeTest):
2971 return result.addCallback(got_result)
2972
2973 def test_install_snap_check_error(self):
2974- self.snap_http.install_snaps.return_value = {"change": "1"}
2975+ self.snap_http.install_all.return_value = SnapdResponse(
2976+ "async",
2977+ 200,
2978+ "Accepted",
2979+ None,
2980+ change="1",
2981+ )
2982 self.snap_http.check_changes.side_effect = SnapdHttpException("whoops")
2983- self.snap_http.get_snaps.return_value = {"installed": []}
2984+ self.snap_http.list.return_value = SnapdResponse("sync", 200, "OK", [])
2985
2986 result = self.manager.dispatch_message(
2987 {
2988@@ -211,7 +240,7 @@ class SnapManagerTest(LandscapeTest):
2989
2990 self.log_helper.ignore_errors(r".+whoops$")
2991
2992- def got_result(r):
2993+ def got_result(_):
2994 self.assertMessages(
2995 self.broker_service.message_store.get_pending_messages(),
2996 [
2997@@ -228,11 +257,20 @@ class SnapManagerTest(LandscapeTest):
2998 return result.addCallback(got_result)
2999
3000 def test_remove_snap(self):
3001- self.snap_http.remove_snaps.return_value = {"change": "1"}
3002- self.snap_http.check_changes.return_value = {
3003- "result": [{"id": "1", "status": "Done"}],
3004- }
3005- self.snap_http.get_snaps.return_value = {"installed": []}
3006+ self.snap_http.remove_all.return_value = SnapdResponse(
3007+ "async",
3008+ 202,
3009+ "Accepted",
3010+ None,
3011+ change="1",
3012+ )
3013+ self.snap_http.check_changes.return_value = SnapdResponse(
3014+ "sync",
3015+ 200,
3016+ "OK",
3017+ [{"id": "1", "status": "Done"}],
3018+ )
3019+ self.snap_http.list.return_value = SnapdResponse("sync", 200, "OK", [])
3020
3021 result = self.manager.dispatch_message(
3022 {
3023@@ -242,7 +280,7 @@ class SnapManagerTest(LandscapeTest):
3024 },
3025 )
3026
3027- def got_result(r):
3028+ def got_result(_):
3029 self.assertMessages(
3030 self.broker_service.message_store.get_pending_messages(),
3031 [
3032@@ -257,3 +295,89 @@ class SnapManagerTest(LandscapeTest):
3033 )
3034
3035 return result.addCallback(got_result)
3036+
3037+ def test_set_config(self):
3038+ self.snap_http.set_conf.return_value = SnapdResponse(
3039+ "async",
3040+ 202,
3041+ "Accepted",
3042+ None,
3043+ change="1",
3044+ )
3045+ self.snap_http.check_changes.return_value = SnapdResponse(
3046+ "sync",
3047+ "200",
3048+ "OK",
3049+ [{"id": "1", "status": "Done"}],
3050+ )
3051+ self.snap_http.list.return_value = SnapdResponse("sync", 200, "OK", [])
3052+
3053+ result = self.manager.dispatch_message(
3054+ {
3055+ "type": "set-snap-config",
3056+ "operation-id": 123,
3057+ "snaps": [
3058+ {
3059+ "name": "hello",
3060+ "args": {
3061+ "config": {"foo": {"bar": "qux", "baz": "quux"}},
3062+ },
3063+ },
3064+ ],
3065+ },
3066+ )
3067+
3068+ def got_result(_):
3069+ self.assertMessages(
3070+ self.broker_service.message_store.get_pending_messages(),
3071+ [
3072+ {
3073+ "type": "operation-result",
3074+ "status": SUCCEEDED,
3075+ "result-text": "{'completed': ['hello'], "
3076+ "'errored': [], 'errors': {}}",
3077+ "operation-id": 123,
3078+ },
3079+ ],
3080+ )
3081+
3082+ return result.addCallback(got_result)
3083+
3084+ def test_set_config_sync_error(self):
3085+ self.snap_http.set_conf.side_effect = SnapdHttpException(
3086+ b'{"result": "whoops"}',
3087+ )
3088+ self.snap_http.list.return_value = SnapdResponse("sync", 200, "OK", [])
3089+
3090+ result = self.manager.dispatch_message(
3091+ {
3092+ "type": "set-snap-config",
3093+ "operation-id": 123,
3094+ "snaps": [
3095+ {
3096+ "name": "hello",
3097+ "args": {
3098+ "config": {"foo": {"bar": "qux", "baz": "quux"}},
3099+ },
3100+ },
3101+ ],
3102+ },
3103+ )
3104+
3105+ def got_result(_):
3106+ self.assertMessages(
3107+ self.broker_service.message_store.get_pending_messages(),
3108+ [
3109+ {
3110+ "type": "operation-result",
3111+ "status": FAILED,
3112+ "result-text": (
3113+ "{'completed': [], 'errored': [], "
3114+ "'errors': {'hello': 'whoops'}}"
3115+ ),
3116+ "operation-id": 123,
3117+ },
3118+ ],
3119+ )
3120+
3121+ return result.addCallback(got_result)
3122diff --git a/landscape/client/manager/tests/test_snapservicesmanager.py b/landscape/client/manager/tests/test_snapservicesmanager.py
3123new file mode 100644
3124index 0000000..6198f6c
3125--- /dev/null
3126+++ b/landscape/client/manager/tests/test_snapservicesmanager.py
3127@@ -0,0 +1,333 @@
3128+import sys
3129+from unittest import mock
3130+
3131+from landscape.client.manager.manager import FAILED
3132+from landscape.client.manager.manager import SUCCEEDED
3133+from landscape.client.manager.snapservicesmanager import SnapServicesManager
3134+from landscape.client.snap_http import SnapdHttpException
3135+from landscape.client.snap_http import SnapdResponse
3136+from landscape.client.tests.helpers import LandscapeTest
3137+from landscape.client.tests.helpers import ManagerHelper
3138+
3139+
3140+class SnapServicesManagerTest(LandscapeTest):
3141+ helpers = [ManagerHelper]
3142+
3143+ def setUp(self):
3144+ super().setUp()
3145+
3146+ self.snap_http = mock.patch(
3147+ "landscape.client.manager.snapservicesmanager.snap_http",
3148+ ).start()
3149+
3150+ self.broker_service.message_store.set_accepted_types(
3151+ ["operation-result"],
3152+ )
3153+ self.plugin = SnapServicesManager()
3154+ self.manager.add(self.plugin)
3155+
3156+ self.manager.config.snapd_poll_attempts = 2
3157+ self.manager.config.snapd_poll_interval = 0.1
3158+
3159+ def tearDown(self):
3160+ mock.patch.stopall()
3161+
3162+ @mock.patch("landscape.client.manager.snapmanager.snap_http")
3163+ def test_start_service(self, mock_base_snap_http):
3164+ self.snap_http.start.return_value = SnapdResponse(
3165+ "async",
3166+ 202,
3167+ "Accepted",
3168+ None,
3169+ change="1",
3170+ )
3171+ mock_base_snap_http.check_changes.return_value = SnapdResponse(
3172+ "sync",
3173+ 200,
3174+ "OK",
3175+ [{"id": "1", "status": "Done"}],
3176+ )
3177+ self.snap_http.get_apps.return_value = SnapdResponse(
3178+ "sync",
3179+ 200,
3180+ "OK",
3181+ [
3182+ {
3183+ "snap": "test-snap",
3184+ "name": "bye-svc",
3185+ "daemon": "simple",
3186+ "daemon-scope": "system",
3187+ "enabled": True,
3188+ },
3189+ ],
3190+ )
3191+
3192+ result = self.manager.dispatch_message(
3193+ {
3194+ "type": "start-snap-service",
3195+ "operation-id": 123,
3196+ "snaps": [
3197+ {"name": "test-snap.hello-svc", "args": {"enable": True}},
3198+ ],
3199+ },
3200+ )
3201+
3202+ def got_result(_):
3203+ self.assertMessages(
3204+ self.broker_service.message_store.get_pending_messages(),
3205+ [
3206+ {
3207+ "type": "operation-result",
3208+ "status": SUCCEEDED,
3209+ "result-text": "{'completed': ['test-snap.hello-svc'],"
3210+ " 'errored': [], 'errors': {}}",
3211+ "operation-id": 123,
3212+ },
3213+ ],
3214+ )
3215+
3216+ self.snap_http.start.assert_called_once_with(
3217+ "test-snap.hello-svc",
3218+ enable=True,
3219+ )
3220+
3221+ return result.addCallback(got_result)
3222+
3223+ def test_start_service_error(self):
3224+ self.snap_http.start.side_effect = SnapdHttpException(
3225+ b'{"result": "snap idonotexist not found"}',
3226+ )
3227+ self.snap_http.get_apps.return_value = SnapdResponse(
3228+ "sync",
3229+ 200,
3230+ "OK",
3231+ [],
3232+ )
3233+
3234+ result = self.manager.dispatch_message(
3235+ {
3236+ "type": "start-snap-service",
3237+ "operation-id": 123,
3238+ "snaps": [{"name": "idonotexist", "args": {"enable": True}}],
3239+ },
3240+ )
3241+
3242+ self.log_helper.ignore_errors(r".+idonotexist$")
3243+
3244+ def got_result(_):
3245+ self.assertMessages(
3246+ self.broker_service.message_store.get_pending_messages(),
3247+ [
3248+ {
3249+ "type": "operation-result",
3250+ "status": FAILED,
3251+ "result-text": "{'completed': [], "
3252+ "'errored': [], 'errors': {'idonotexist': "
3253+ "'snap idonotexist not found'}}",
3254+ "operation-id": 123,
3255+ },
3256+ ],
3257+ )
3258+
3259+ self.snap_http.start.assert_called_once_with(
3260+ "idonotexist",
3261+ enable=True,
3262+ )
3263+
3264+ return result.addCallback(got_result)
3265+
3266+ @mock.patch("landscape.client.manager.snapmanager.snap_http")
3267+ def test_stop_service_batch(self, mock_base_snap_http):
3268+ self.snap_http.stop_all.return_value = SnapdResponse(
3269+ "async",
3270+ 202,
3271+ "Accepted",
3272+ None,
3273+ change="1",
3274+ )
3275+ mock_base_snap_http.check_changes.return_value = SnapdResponse(
3276+ "sync",
3277+ 200,
3278+ "OK",
3279+ [{"id": "1", "status": "Done"}],
3280+ )
3281+ self.snap_http.get_apps.return_value = SnapdResponse(
3282+ "sync",
3283+ 200,
3284+ "OK",
3285+ [
3286+ {
3287+ "snap": "lxd",
3288+ "name": "lxd",
3289+ "daemon": "simple",
3290+ "daemon-scope": "system",
3291+ },
3292+ {
3293+ "snap": "landscape-client",
3294+ "name": "landscape-client",
3295+ "daemon": "simple",
3296+ "daemon-scope": "system",
3297+ },
3298+ ],
3299+ )
3300+
3301+ result = self.manager.dispatch_message(
3302+ {
3303+ "type": "stop-snap-service",
3304+ "operation-id": 123,
3305+ "snaps": [
3306+ {"name": "lxd"},
3307+ {"name": "landscape-client"},
3308+ ],
3309+ "args": {"disable": False},
3310+ },
3311+ )
3312+
3313+ def got_result(_):
3314+ self.assertMessages(
3315+ self.broker_service.message_store.get_pending_messages(),
3316+ [
3317+ {
3318+ "type": "operation-result",
3319+ "status": SUCCEEDED,
3320+ "result-text": "{'completed': ['BATCH'], "
3321+ "'errored': [], 'errors': {}}",
3322+ "operation-id": 123,
3323+ },
3324+ ],
3325+ )
3326+
3327+ self.snap_http.stop_all.assert_called_once_with(
3328+ ["lxd", "landscape-client"],
3329+ disable=False,
3330+ )
3331+
3332+ return result.addCallback(got_result)
3333+
3334+ @mock.patch("landscape.client.manager.snapmanager.snap_http")
3335+ def test_restart_service(self, mock_base_snap_http):
3336+ self.snap_http.restart_all.return_value = SnapdResponse(
3337+ "async",
3338+ 202,
3339+ "Accepted",
3340+ None,
3341+ change="1",
3342+ )
3343+ mock_base_snap_http.check_changes.return_value = SnapdResponse(
3344+ "sync",
3345+ 200,
3346+ "OK",
3347+ [{"id": "1", "status": "Done"}],
3348+ )
3349+ self.snap_http.get_apps.return_value = SnapdResponse(
3350+ "sync",
3351+ 200,
3352+ "OK",
3353+ [
3354+ {
3355+ "snap": "lxd",
3356+ "name": "lxd",
3357+ "daemon": "simple",
3358+ "daemon-scope": "system",
3359+ },
3360+ {
3361+ "snap": "test-snap",
3362+ "name": "bye-svc",
3363+ "daemon": "simple",
3364+ "daemon-scope": "system",
3365+ },
3366+ ],
3367+ )
3368+
3369+ result = self.manager.dispatch_message(
3370+ {
3371+ "type": "restart-snap-service",
3372+ "operation-id": 123,
3373+ "snaps": [
3374+ {"name": "test-snap"},
3375+ {"name": "lxd"},
3376+ ],
3377+ },
3378+ )
3379+
3380+ def got_result(_):
3381+ self.assertMessages(
3382+ self.broker_service.message_store.get_pending_messages(),
3383+ [
3384+ {
3385+ "type": "operation-result",
3386+ "status": SUCCEEDED,
3387+ "result-text": "{'completed': ['BATCH'], "
3388+ "'errored': [], 'errors': {}}",
3389+ "operation-id": 123,
3390+ },
3391+ ],
3392+ )
3393+
3394+ self.snap_http.restart_all.assert_called_once_with(
3395+ ["test-snap", "lxd"],
3396+ )
3397+
3398+ return result.addCallback(got_result)
3399+
3400+ @mock.patch("landscape.client.manager.snapmanager.snap_http")
3401+ def test_restart_service_update_failure(self, mock_base_snap_http):
3402+ """
3403+ Test when the client runs the operation successfully but
3404+ `_send_snap_update` fails.
3405+ """
3406+ self.snap_http.restart_all.return_value = SnapdResponse(
3407+ "async",
3408+ 202,
3409+ "Accepted",
3410+ None,
3411+ change="1",
3412+ )
3413+ mock_base_snap_http.check_changes.return_value = SnapdResponse(
3414+ "sync",
3415+ 200,
3416+ "OK",
3417+ [{"id": "1", "status": "Done"}],
3418+ )
3419+ self.snap_http.get_apps.side_effect = SnapdHttpException(
3420+ "An error occurred.",
3421+ )
3422+ mock_logger = mock.Mock()
3423+ self.patch(sys.modules["logging"], "error", mock_logger)
3424+
3425+ result = self.manager.dispatch_message(
3426+ {
3427+ "type": "restart-snap-service",
3428+ "operation-id": 123,
3429+ "snaps": [
3430+ {"name": "test-snap"},
3431+ {"name": "lxd"},
3432+ ],
3433+ },
3434+ )
3435+
3436+ self.log_helper.ignore_errors(r".+error$")
3437+
3438+ def got_result(_):
3439+ self.assertMessages(
3440+ self.broker_service.message_store.get_pending_messages(),
3441+ [
3442+ {
3443+ "type": "operation-result",
3444+ "status": SUCCEEDED,
3445+ "result-text": "{'completed': ['BATCH'], "
3446+ "'errored': [], 'errors': {}}",
3447+ "operation-id": 123,
3448+ },
3449+ ],
3450+ )
3451+
3452+ mock_logger.assert_called_once_with(
3453+ "Unable to list services: An error occurred.",
3454+ )
3455+
3456+ self.snap_http.restart_all.assert_called_once_with(
3457+ ["test-snap", "lxd"],
3458+ )
3459+
3460+ return result.addCallback(got_result)
3461diff --git a/landscape/client/manager/tests/test_ubuntuproinfo.py b/landscape/client/manager/tests/test_ubuntuproinfo.py
3462new file mode 100644
3463index 0000000..10c60eb
3464--- /dev/null
3465+++ b/landscape/client/manager/tests/test_ubuntuproinfo.py
3466@@ -0,0 +1,151 @@
3467+from unittest import mock
3468+
3469+from landscape.client.manager.ubuntuproinfo import get_ubuntu_pro_info
3470+from landscape.client.manager.ubuntuproinfo import UbuntuProInfo
3471+from landscape.client.tests.helpers import LandscapeTest
3472+from landscape.client.tests.helpers import MonitorHelper
3473+
3474+
3475+class UbuntuProInfoTest(LandscapeTest):
3476+ """Ubuntu Pro info plugin tests."""
3477+
3478+ helpers = [MonitorHelper]
3479+
3480+ def setUp(self):
3481+ super().setUp()
3482+ self.mstore.set_accepted_types(["ubuntu-pro-info"])
3483+
3484+ def test_ubuntu_pro_info(self):
3485+ """Tests calling `ua status`."""
3486+ plugin = UbuntuProInfo()
3487+
3488+ with mock.patch("subprocess.run") as run_mock:
3489+ run_mock.return_value = mock.Mock(
3490+ stdout='"This is a test"',
3491+ )
3492+ self.monitor.add(plugin)
3493+ plugin.run()
3494+
3495+ run_mock.assert_called()
3496+ messages = self.mstore.get_pending_messages()
3497+ self.assertTrue(len(messages) > 0)
3498+ self.assertTrue("ubuntu-pro-info" in messages[0])
3499+ self.assertEqual(messages[0]["ubuntu-pro-info"], '"This is a test"')
3500+
3501+ def test_ubuntu_pro_info_no_pro(self):
3502+ """Tests calling `pro status` when it is not installed."""
3503+ plugin = UbuntuProInfo()
3504+ self.monitor.add(plugin)
3505+
3506+ with mock.patch("subprocess.run") as run_mock:
3507+ run_mock.side_effect = FileNotFoundError()
3508+ plugin.run()
3509+
3510+ messages = self.mstore.get_pending_messages()
3511+ run_mock.assert_called_once()
3512+ self.assertTrue(len(messages) > 0)
3513+ self.assertTrue("ubuntu-pro-info" in messages[0])
3514+ self.assertIn("errors", messages[0]["ubuntu-pro-info"])
3515+
3516+ def test_get_ubuntu_pro_info_core(self):
3517+ """In Ubuntu Core, there is no pro info, so return a reasonable erro
3518+ message.
3519+ """
3520+ with mock.patch(
3521+ "landscape.client.manager.ubuntuproinfo.IS_CORE",
3522+ new="1",
3523+ ):
3524+ result = get_ubuntu_pro_info()
3525+
3526+ self.assertIn("errors", result)
3527+ self.assertIn("not available", result["errors"][0]["message"])
3528+ self.assertEqual(result["result"], "failure")
3529+
3530+ def test_persistence_unchanged_data(self):
3531+ """If data hasn't changed, a new message is not sent"""
3532+ plugin = UbuntuProInfo()
3533+ self.monitor.add(plugin)
3534+ data = '"Initial data!"'
3535+
3536+ with mock.patch("subprocess.run") as run_mock:
3537+ run_mock.return_value = mock.Mock(
3538+ stdout=data,
3539+ )
3540+ plugin.run()
3541+
3542+ messages = self.mstore.get_pending_messages()
3543+ run_mock.assert_called_once()
3544+ self.assertEqual(1, len(messages))
3545+ self.assertTrue("ubuntu-pro-info" in messages[0])
3546+ self.assertEqual(messages[0]["ubuntu-pro-info"], data)
3547+
3548+ with mock.patch("subprocess.run") as run_mock:
3549+ run_mock.return_value = mock.Mock(
3550+ stdout=data,
3551+ )
3552+ plugin.run()
3553+
3554+ run_mock.assert_called_once()
3555+ messages = self.mstore.get_pending_messages()
3556+ self.assertEqual(1, len(messages))
3557+
3558+ def test_persistence_changed_data(self):
3559+ """New data will be sent in a new message in the queue"""
3560+ plugin = UbuntuProInfo()
3561+ self.monitor.add(plugin)
3562+
3563+ with mock.patch("subprocess.run") as run_mock:
3564+ run_mock.return_value = mock.Mock(
3565+ stdout='"Initial data!"',
3566+ )
3567+ plugin.run()
3568+
3569+ messages = self.mstore.get_pending_messages()
3570+ run_mock.assert_called_once()
3571+ self.assertEqual(1, len(messages))
3572+ self.assertTrue("ubuntu-pro-info" in messages[0])
3573+ self.assertEqual(messages[0]["ubuntu-pro-info"], '"Initial data!"')
3574+
3575+ with mock.patch("subprocess.run") as run_mock:
3576+ run_mock.return_value = mock.Mock(
3577+ stdout='"New data!"',
3578+ )
3579+ plugin.run()
3580+
3581+ run_mock.assert_called_once()
3582+ messages = self.mstore.get_pending_messages()
3583+ self.assertEqual(2, len(messages))
3584+ self.assertEqual(messages[1]["ubuntu-pro-info"], '"New data!"')
3585+
3586+ def test_persistence_reset(self):
3587+ """Resetting the plugin will allow a message with identical data to
3588+ be sent"""
3589+ plugin = UbuntuProInfo()
3590+ self.monitor.add(plugin)
3591+ data = '"Initial data!"'
3592+
3593+ with mock.patch("subprocess.run") as run_mock:
3594+ run_mock.return_value = mock.Mock(
3595+ stdout=data,
3596+ )
3597+ plugin.run()
3598+
3599+ messages = self.mstore.get_pending_messages()
3600+ run_mock.assert_called_once()
3601+ self.assertEqual(1, len(messages))
3602+ self.assertTrue("ubuntu-pro-info" in messages[0])
3603+ self.assertEqual(messages[0]["ubuntu-pro-info"], data)
3604+
3605+ plugin._reset()
3606+
3607+ with mock.patch("subprocess.run") as run_mock:
3608+ run_mock.return_value = mock.Mock(
3609+ stdout=data,
3610+ )
3611+ plugin.run()
3612+
3613+ run_mock.assert_called_once()
3614+ messages = self.mstore.get_pending_messages()
3615+ self.assertEqual(2, len(messages))
3616+ self.assertTrue("ubuntu-pro-info" in messages[1])
3617+ self.assertEqual(messages[1]["ubuntu-pro-info"], data)
3618diff --git a/landscape/client/manager/tests/test_usermanager.py b/landscape/client/manager/tests/test_usermanager.py
3619index fe02d03..d4742c5 100644
3620--- a/landscape/client/manager/tests/test_usermanager.py
3621+++ b/landscape/client/manager/tests/test_usermanager.py
3622@@ -1,5 +1,6 @@
3623 import os
3624 from unittest.mock import Mock
3625+from unittest.mock import patch
3626
3627 from landscape.client.manager.plugin import FAILED
3628 from landscape.client.manager.plugin import SUCCEEDED
3629@@ -9,6 +10,7 @@ from landscape.client.monitor.usermonitor import UserMonitor
3630 from landscape.client.tests.helpers import LandscapeTest
3631 from landscape.client.tests.helpers import ManagerHelper
3632 from landscape.client.user.provider import UserManagementError
3633+from landscape.client.user.tests.helpers import FakeSnapdUserManagement
3634 from landscape.client.user.tests.helpers import FakeUserManagement
3635 from landscape.client.user.tests.helpers import FakeUserProvider
3636 from landscape.lib.persist import Persist
3637@@ -30,20 +32,25 @@ sbarnes:$1$q7sz09uw$q.A3526M/SHu8vUb.Jo1A/:13349:0:99999:7:::
3638 )
3639 accepted_types = ["operation-result", "users"]
3640 self.broker_service.message_store.set_accepted_types(accepted_types)
3641+ self.plugins = []
3642
3643 def tearDown(self):
3644 super().tearDown()
3645 for plugin in self.plugins:
3646 plugin.stop()
3647
3648- def setup_environment(self, users, groups, shadow_file):
3649+ def setup_environment(self, users, groups, shadow_file, is_core=False):
3650 provider = FakeUserProvider(
3651 users=users,
3652 groups=groups,
3653 shadow_file=shadow_file,
3654 )
3655 user_monitor = UserMonitor(provider=provider)
3656- management = FakeUserManagement(provider=provider)
3657+
3658+ if is_core:
3659+ management = FakeSnapdUserManagement(provider=provider)
3660+ else:
3661+ management = FakeUserManagement(provider=provider)
3662 user_manager = UserManager(
3663 management=management,
3664 shadow_file=shadow_file,
3665@@ -378,6 +385,64 @@ class UserOperationsMessagingTest(UserGroupTestBase):
3666 result.addCallback(handle_callback)
3667 return result
3668
3669+ @patch("landscape.client.manager.usermanager.IS_CORE", "1")
3670+ def test_add_user_event_on_core(self):
3671+ """
3672+ When an C{add-user} event is received the user should be
3673+ added. Two messages should be generated: a C{users} message
3674+ with details about the change and an C{operation-result} with
3675+ details of the outcome of the operation.
3676+ """
3677+
3678+ def handle_callback(result):
3679+ messages = self.broker_service.message_store.get_pending_messages()
3680+ self.assertMessages(
3681+ messages,
3682+ [
3683+ {
3684+ "type": "operation-result",
3685+ "status": SUCCEEDED,
3686+ "operation-id": 123,
3687+ "timestamp": 0,
3688+ "result-text": "add_user succeeded",
3689+ },
3690+ {
3691+ "timestamp": 0,
3692+ "type": "users",
3693+ "operation-id": 123,
3694+ "create-users": [
3695+ {
3696+ "home-phone": None,
3697+ "username": "john-doe",
3698+ "uid": 1000,
3699+ "enabled": True,
3700+ "location": None,
3701+ "work-phone": None,
3702+ "name": "john.doe@example.com",
3703+ "primary-gid": 1000,
3704+ },
3705+ ],
3706+ },
3707+ ],
3708+ )
3709+
3710+ shadow_file = self.makeFile("""st3v3nmw:*:19758:0:99999:7:::""")
3711+ self.setup_environment([], [], shadow_file, is_core=True)
3712+
3713+ result = self.manager.dispatch_message(
3714+ {
3715+ "type": "add-user",
3716+ "username": "john-doe",
3717+ "email": "john.doe@example.com",
3718+ "sudoer": False,
3719+ "force-managed": True,
3720+ "operation-id": 123,
3721+ },
3722+ )
3723+
3724+ result.addCallback(handle_callback)
3725+ return result
3726+
3727 def test_edit_user_event(self):
3728 """
3729 When a C{edit-user} message is received the user should be
3730@@ -834,6 +899,71 @@ class UserOperationsMessagingTest(UserGroupTestBase):
3731 result.addCallback(handle_callback)
3732 return result
3733
3734+ @patch("landscape.client.manager.usermanager.IS_CORE", "1")
3735+ def test_remove_user_event_on_core(self):
3736+ """
3737+ When a C{remove-user} event is received, the user should be removed.
3738+ Two messages should be generated: a C{users} message with details
3739+ about the change and an C{operation-result} with details of the
3740+ outcome of the operation.
3741+ """
3742+
3743+ def handle_callback(result):
3744+ messages = self.broker_service.message_store.get_pending_messages()
3745+ self.assertEqual(len(messages), 3)
3746+ # Ignore the message created by plugin.run.
3747+ self.assertMessages(
3748+ [messages[2], messages[1]],
3749+ [
3750+ {
3751+ "timestamp": 0,
3752+ "delete-users": ["john-doe"],
3753+ "type": "users",
3754+ "operation-id": 39,
3755+ },
3756+ {
3757+ "type": "operation-result",
3758+ "status": SUCCEEDED,
3759+ "operation-id": 39,
3760+ "timestamp": 0,
3761+ "result-text": "remove_user succeeded",
3762+ },
3763+ ],
3764+ )
3765+
3766+ users = [
3767+ (
3768+ "john-doe",
3769+ "x",
3770+ 1000,
3771+ 1000,
3772+ "john.doe@example.com,BtrGAhK,,",
3773+ "/home/user",
3774+ "/bin/zsh",
3775+ ),
3776+ (
3777+ "jane-doe",
3778+ "x",
3779+ 1001,
3780+ 1001,
3781+ "jane.doe@example.com,BtrGAhK,,",
3782+ "/home/user",
3783+ "/bin/zsh",
3784+ ),
3785+ ]
3786+ shadow_file = self.makeFile("""st3v3nmw:*:19758:0:99999:7:::""")
3787+ self.setup_environment(users, [], shadow_file, is_core=True)
3788+ result = self.manager.dispatch_message(
3789+ {
3790+ "username": "john-doe",
3791+ "delete-home": True,
3792+ "type": "remove-user",
3793+ "operation-id": 39,
3794+ },
3795+ )
3796+ result.addCallback(handle_callback)
3797+ return result
3798+
3799 def test_lock_user_event(self):
3800 """
3801 When a C{lock-user} event is received the user should be
3802diff --git a/landscape/client/manager/ubuntuproinfo.py b/landscape/client/manager/ubuntuproinfo.py
3803new file mode 100644
3804index 0000000..1881c34
3805--- /dev/null
3806+++ b/landscape/client/manager/ubuntuproinfo.py
3807@@ -0,0 +1,102 @@
3808+import json
3809+import subprocess
3810+from pathlib import Path
3811+
3812+from landscape.client import IS_CORE
3813+from landscape.client.manager.plugin import ManagerPlugin
3814+from landscape.lib.persist import Persist
3815+
3816+
3817+class UbuntuProInfo(ManagerPlugin):
3818+ """
3819+ Plugin that captures and reports Ubuntu Pro registration
3820+ information.
3821+
3822+ We use the `pro` CLI with output formatted as JSON. This is sent
3823+ as-is and parsed by Landscape Server because the JSON content is
3824+ considered "Experimental" and we don't want to have to change in
3825+ both Client and Server in the event that the format changes.
3826+ """
3827+
3828+ message_type = "ubuntu-pro-info"
3829+ run_interval = 900 # 15 minutes
3830+
3831+ def register(self, registry):
3832+ super().register(registry)
3833+ self._persist_filename = Path(
3834+ self.registry.config.data_path,
3835+ "ubuntu-pro-info.bpickle",
3836+ )
3837+ self._persist = Persist(filename=self._persist_filename)
3838+ self.call_on_accepted(self.message_type, self.send_message)
3839+
3840+ def run(self):
3841+ return self.registry.broker.call_if_accepted(
3842+ self.message_type,
3843+ self.send_message,
3844+ )
3845+
3846+ def send_message(self):
3847+ """Send a message to the broker if the data has changed since the last
3848+ call"""
3849+ result = self.get_data()
3850+ if not result:
3851+ return
3852+ message = {"type": self.message_type, "ubuntu-pro-info": result}
3853+ return self.registry.broker.send_message(message, self._session_id)
3854+
3855+ def get_data(self):
3856+ """Persist data to avoid sending messages if result hasn't changed"""
3857+ ubuntu_pro_info = get_ubuntu_pro_info()
3858+
3859+ if self._persist.get("data") != ubuntu_pro_info:
3860+ self._persist.set("data", ubuntu_pro_info)
3861+ return json.dumps(ubuntu_pro_info, separators=(",", ":"))
3862+
3863+ def _reset(self):
3864+ """Reset the persist."""
3865+ self._persist.remove("data")
3866+
3867+
3868+def get_ubuntu_pro_info() -> dict:
3869+ """Query ua tools for Ubuntu Pro status as JSON, parsing it to a dict.
3870+
3871+ If we are running on Ubuntu Core, Pro does not exist - returns a message
3872+ indicating this.
3873+ """
3874+ if IS_CORE:
3875+ return _ubuntu_pro_error_message(
3876+ "Ubuntu Pro is not available on Ubuntu Core.",
3877+ "core-unsupported",
3878+ )
3879+
3880+ try:
3881+ completed_process = subprocess.run(
3882+ ["pro", "status", "--format", "json"],
3883+ encoding="utf8",
3884+ stdout=subprocess.PIPE,
3885+ )
3886+ except FileNotFoundError:
3887+ return _ubuntu_pro_error_message(
3888+ "ubuntu pro tools not found.",
3889+ "tools-error",
3890+ )
3891+ else:
3892+ return json.loads(completed_process.stdout)
3893+
3894+
3895+def _ubuntu_pro_error_message(message: str, code: str) -> dict:
3896+ """Marshall `message` and `code` into a format matching that expected from
3897+ an error from ua tools.
3898+ """
3899+ return {
3900+ "errors": [
3901+ {
3902+ "message": message,
3903+ "message_code": code,
3904+ "service": None,
3905+ "type": "system",
3906+ },
3907+ ],
3908+ "result": "failure",
3909+ }
3910diff --git a/landscape/client/manager/usermanager.py b/landscape/client/manager/usermanager.py
3911index 8984ccf..5e0608d 100644
3912--- a/landscape/client/manager/usermanager.py
3913+++ b/landscape/client/manager/usermanager.py
3914@@ -1,10 +1,12 @@
3915 import logging
3916
3917+from landscape.client import IS_CORE
3918 from landscape.client.amp import ComponentConnector
3919 from landscape.client.amp import ComponentPublisher
3920 from landscape.client.amp import remote
3921 from landscape.client.manager.plugin import ManagerPlugin
3922 from landscape.client.monitor.usermonitor import RemoteUserMonitorConnector
3923+from landscape.client.user.management import SnapdUserManagement
3924 from landscape.client.user.management import UserManagement
3925
3926
3927@@ -13,7 +15,14 @@ class UserManager(ManagerPlugin):
3928 name = "usermanager"
3929
3930 def __init__(self, management=None, shadow_file="/etc/shadow"):
3931- self._management = management or UserManagement()
3932+ if IS_CORE:
3933+ management = management or SnapdUserManagement()
3934+ shadow_file = shadow_file or "/var/lib/extrausers/shadow"
3935+ else:
3936+ management = management or UserManagement()
3937+ shadow_file = shadow_file
3938+
3939+ self._management = management
3940 self._shadow_file = shadow_file
3941 self._message_types = {
3942 "add-user": self._add_user,
3943@@ -107,16 +116,7 @@ class UserManager(ManagerPlugin):
3944
3945 def _add_user(self, message):
3946 """Run an C{add-user} operation."""
3947- return self._management.add_user(
3948- message["username"],
3949- message["name"],
3950- message["password"],
3951- message["require-password-reset"],
3952- message["primary-group-name"],
3953- message["location"],
3954- message["work-number"],
3955- message["home-number"],
3956- )
3957+ return self._management.add_user(message)
3958
3959 def _edit_user(self, message):
3960 """Run an C{edit-user} operation."""
3961@@ -140,10 +140,7 @@ class UserManager(ManagerPlugin):
3962
3963 def _remove_user(self, message):
3964 """Run a C{remove-user} operation."""
3965- return self._management.remove_user(
3966- message["username"],
3967- message["delete-home"],
3968- )
3969+ return self._management.remove_user(message)
3970
3971 def _add_group(self, message):
3972 """Run an C{add-group} operation."""
3973diff --git a/landscape/client/monitor/computerinfo.py b/landscape/client/monitor/computerinfo.py
3974index dfda40b..c1b353e 100644
3975--- a/landscape/client/monitor/computerinfo.py
3976+++ b/landscape/client/monitor/computerinfo.py
3977@@ -5,12 +5,13 @@ from twisted.internet.defer import inlineCallbacks
3978 from twisted.internet.defer import returnValue
3979
3980 from landscape.client.monitor.plugin import MonitorPlugin
3981+from landscape.client.snap_utils import get_snap_info
3982 from landscape.lib.cloud import fetch_ec2_meta_data
3983 from landscape.lib.fetch import fetch_async
3984 from landscape.lib.fs import read_text_file
3985-from landscape.lib.lsb_release import LSB_RELEASE_FILENAME
3986-from landscape.lib.lsb_release import parse_lsb_release
3987 from landscape.lib.network import get_fqdn
3988+from landscape.lib.os_release import get_os_filename
3989+from landscape.lib.os_release import parse_os_release
3990
3991 METADATA_RETRY_MAX = 3 # Number of retries to get EC2 meta-data
3992
3993@@ -29,13 +30,13 @@ class ComputerInfo(MonitorPlugin):
3994 self,
3995 get_fqdn=get_fqdn,
3996 meminfo_filename="/proc/meminfo",
3997- lsb_release_filename=LSB_RELEASE_FILENAME,
3998+ os_release_filename=get_os_filename(),
3999 root_path="/",
4000 fetch_async=fetch_async,
4001 ):
4002 self._get_fqdn = get_fqdn
4003 self._meminfo_filename = meminfo_filename
4004- self._lsb_release_filename = lsb_release_filename
4005+ self._os_release_filename = os_release_filename
4006 self._root_path = root_path
4007 self._cloud_instance_metadata = None
4008 self._cloud_retries = 0
4009@@ -59,6 +60,11 @@ class ComputerInfo(MonitorPlugin):
4010 self.send_cloud_instance_metadata_message,
4011 True,
4012 )
4013+ self.call_on_accepted(
4014+ "snap-info",
4015+ self.send_snap_message,
4016+ True,
4017+ )
4018
4019 def send_computer_message(self, urgent=False):
4020 message = self._create_computer_info_message()
4021@@ -96,6 +102,17 @@ class ComputerInfo(MonitorPlugin):
4022 urgent=urgent,
4023 )
4024
4025+ def send_snap_message(self, urgent=False):
4026+ message = self._create_snap_info_message()
4027+ if message:
4028+ message["type"] = "snap-info"
4029+ logging.info("Queueing message with updated snap info.")
4030+ self.registry.broker.send_message(
4031+ message,
4032+ self._session_id,
4033+ urgent=urgent,
4034+ )
4035+
4036 def exchange(self, urgent=False):
4037 broker = self.registry.broker
4038 broker.call_if_accepted(
4039@@ -113,6 +130,11 @@ class ComputerInfo(MonitorPlugin):
4040 self.send_cloud_instance_metadata_message,
4041 urgent,
4042 )
4043+ broker.call_if_accepted(
4044+ "snap-info",
4045+ self.send_snap_message,
4046+ urgent,
4047+ )
4048
4049 def _create_computer_info_message(self):
4050 message = {}
4051@@ -160,7 +182,7 @@ class ComputerInfo(MonitorPlugin):
4052 def _get_distribution_info(self):
4053 """Get details about the distribution."""
4054 message = {}
4055- message.update(parse_lsb_release(self._lsb_release_filename))
4056+ message.update(parse_os_release(self._os_release_filename))
4057 return message
4058
4059 @inlineCallbacks
4060@@ -171,7 +193,6 @@ class ComputerInfo(MonitorPlugin):
4061 self._cloud_instance_metadata is None
4062 and self._cloud_retries < METADATA_RETRY_MAX
4063 ):
4064-
4065 self._cloud_instance_metadata = yield self._fetch_ec2_meta_data()
4066 message = self._cloud_instance_metadata
4067 returnValue(message)
4068@@ -196,3 +217,13 @@ class ComputerInfo(MonitorPlugin):
4069 deferred.addCallback(log_success)
4070 deferred.addErrback(log_no_meta_data_found)
4071 return deferred
4072+
4073+ def _create_snap_info_message(self):
4074+ """Create message with the snapd serial metadata."""
4075+ message = {}
4076+ snap_info = get_snap_info()
4077+ if snap_info:
4078+ self._add_if_new(message, "brand", snap_info["brand"])
4079+ self._add_if_new(message, "model", snap_info["model"])
4080+ self._add_if_new(message, "serial", snap_info["serial"])
4081+ return message
4082diff --git a/landscape/client/monitor/config.py b/landscape/client/monitor/config.py
4083index e6c347a..ebf54eb 100644
4084--- a/landscape/client/monitor/config.py
4085+++ b/landscape/client/monitor/config.py
4086@@ -20,10 +20,10 @@ ALL_PLUGINS = [
4087 "SwiftUsage",
4088 "CephUsage",
4089 "ComputerTags",
4090- "UbuntuProInfo",
4091 "LivePatch",
4092 "UbuntuProRebootRequired",
4093 "SnapMonitor",
4094+ "SnapServicesMonitor",
4095 ]
4096
4097
4098diff --git a/landscape/client/monitor/processorinfo.py b/landscape/client/monitor/processorinfo.py
4099index 2fdacc8..1081ced 100644
4100--- a/landscape/client/monitor/processorinfo.py
4101+++ b/landscape/client/monitor/processorinfo.py
4102@@ -181,30 +181,43 @@ class ARMMessageFactory:
4103 def create_message(self):
4104 """Returns a list containing information about each processor."""
4105 processors = []
4106- file = open(self._source_filename)
4107+ regexp = re.compile(r"(?P<key>.*?)\s*:\s*(?P<value>.*)")
4108+ current = {}
4109
4110- try:
4111- regexp = re.compile(r"(?P<key>.*?)\s*:\s*(?P<value>.*)")
4112- current = {}
4113+ with open(self._source_filename) as fp:
4114+ for line in fp:
4115+ line = line.strip()
4116+
4117+ if not line:
4118+ if current:
4119+ processors.append(current.copy())
4120+ current = {}
4121+
4122+ continue
4123
4124- for line in file:
4125 match = regexp.match(line.strip())
4126+
4127 if match:
4128 key = match.group("key")
4129 value = match.group("value")
4130
4131- if key == "Processor":
4132- # ARM doesn't support SMP, thus no processor-id in
4133- # the cpuinfo
4134- current["processor-id"] = 0
4135+ if key == "processor":
4136+ current["processor-id"] = int(value)
4137+ if "model" not in current:
4138+ current["model"] = "arm"
4139+ elif key == "Processor":
4140 current["model"] = value
4141 elif key == "Cache size":
4142 current["cache-size"] = int(value)
4143
4144- if current:
4145- processors.append(current)
4146- finally:
4147- file.close()
4148+ if current:
4149+ processors.append(current)
4150+
4151+ # Older ARM machines may not have processor-ids, but we need them, so
4152+ # we set missing ones to 0.
4153+ for processor in processors:
4154+ if "processor-id" not in processor:
4155+ processor["processor-id"] = 0
4156
4157 return processors
4158
4159@@ -357,6 +370,8 @@ class RISCVMessageFactory:
4160
4161 if key == "processor":
4162 current = {"processor-id": int(parts[1].strip())}
4163+ # A placeholder in case there is no model provided.
4164+ current["model"] = "riscv"
4165 processors.append(current)
4166 elif key == "isa":
4167 current["vendor"] = parts[1].strip()
4168diff --git a/landscape/client/monitor/service.py b/landscape/client/monitor/service.py
4169index 1822360..c0971f3 100644
4170--- a/landscape/client/monitor/service.py
4171+++ b/landscape/client/monitor/service.py
4172@@ -46,14 +46,17 @@ class MonitorService(LandscapeService):
4173 try:
4174 plugin = namedClass(
4175 "landscape.client.monitor."
4176- f"{plugin_name.lower()}.{plugin_name}"
4177+ f"{plugin_name.lower()}.{plugin_name}",
4178 )
4179 plugins.append(plugin())
4180 except ModuleNotFoundError:
4181 logging.warning(
4182- "Invalid monitor plugin specified: '{}'. "
4183+ f"Invalid monitor plugin specified: '{plugin_name}'. "
4184 "See `example.conf` for a full list of monitor plugins.",
4185- plugin_name,
4186+ )
4187+ except Exception as exc:
4188+ logging.warning(
4189+ f"Unable to load monitor plugin '{plugin_name}': {exc}",
4190 )
4191
4192 return plugins
4193diff --git a/landscape/client/monitor/snapmonitor.py b/landscape/client/monitor/snapmonitor.py
4194index 6a55a68..ef5c94e 100644
4195--- a/landscape/client/monitor/snapmonitor.py
4196+++ b/landscape/client/monitor/snapmonitor.py
4197@@ -1,8 +1,9 @@
4198+import json
4199 import logging
4200
4201+from landscape.client import snap_http
4202 from landscape.client.monitor.plugin import DataWatcher
4203-from landscape.client.snap.http import SnapdHttpException
4204-from landscape.client.snap.http import SnapHttp
4205+from landscape.client.snap_http import SnapdHttpException
4206 from landscape.message_schemas.server_bound import SNAPS
4207
4208
4209@@ -13,31 +14,41 @@ class SnapMonitor(DataWatcher):
4210 persist_name = message_type
4211 scope = "snaps"
4212
4213- def __init__(self, *args, **kwargs):
4214- super().__init__(*args, **kwargs)
4215-
4216- self._snap_http = SnapHttp()
4217-
4218 def register(self, registry):
4219 self.config = registry.config
4220 # The default interval is 30 minutes.
4221 self.run_interval = self.config.snap_monitor_interval
4222
4223- super(SnapMonitor, self).register(registry)
4224+ super().register(registry)
4225
4226 def get_data(self):
4227 try:
4228- snaps = self._snap_http.get_snaps()
4229+ snaps = snap_http.list().result
4230 except SnapdHttpException as e:
4231 logging.error(f"Unable to list installed snaps: {e}")
4232 return
4233
4234+ for i in range(len(snaps)):
4235+ snap_name = snaps[i]["name"]
4236+ try:
4237+ config = snap_http.get_conf(snap_name).result
4238+ except SnapdHttpException as e:
4239+ logging.warning(
4240+ f"Unable to get config for snap {snap_name}: {e}",
4241+ )
4242+ config = {}
4243+
4244+ snaps[i]["config"] = json.dumps(config)
4245+
4246 # We get a lot of extra info from snapd. To avoid caching it all
4247 # or invalidating the cache on timestamp changes, we use Message
4248 # coercion to strip out the unnecessaries, then sort on the snap
4249 # IDs to order the list.
4250 data = SNAPS.coerce(
4251- {"type": "snaps", "snaps": {"installed": snaps["result"]}},
4252+ {
4253+ "type": "snaps",
4254+ "snaps": {"installed": snaps},
4255+ },
4256 )
4257 data["snaps"]["installed"].sort(key=lambda x: x["id"])
4258
4259diff --git a/landscape/client/monitor/snapservicesmonitor.py b/landscape/client/monitor/snapservicesmonitor.py
4260new file mode 100644
4261index 0000000..66d5655
4262--- /dev/null
4263+++ b/landscape/client/monitor/snapservicesmonitor.py
4264@@ -0,0 +1,28 @@
4265+import logging
4266+
4267+from landscape.client import snap_http
4268+from landscape.client.monitor.plugin import DataWatcher
4269+from landscape.client.snap_http import SnapdHttpException
4270+
4271+
4272+class SnapServicesMonitor(DataWatcher):
4273+
4274+ message_type = "snap-services"
4275+ message_key = "services"
4276+ persist_name = message_type
4277+ scope = "snaps"
4278+
4279+ def register(self, registry):
4280+ self.config = registry.config
4281+ self.run_interval = 60 # 1 minute
4282+ super().register(registry)
4283+
4284+ def get_data(self):
4285+ try:
4286+ services = snap_http.get_apps(services_only=True).result
4287+ except SnapdHttpException as e:
4288+ logging.warning(f"Unable to list services: {e}")
4289+ services = []
4290+ services.sort(key=lambda x: x["name"])
4291+
4292+ return {"running": services}
4293diff --git a/landscape/client/monitor/tests/test_computerinfo.py b/landscape/client/monitor/tests/test_computerinfo.py
4294index d43a9b7..0742316 100644
4295--- a/landscape/client/monitor/tests/test_computerinfo.py
4296+++ b/landscape/client/monitor/tests/test_computerinfo.py
4297@@ -14,12 +14,19 @@ from landscape.lib.fetch import HTTPCodeError
4298 from landscape.lib.fetch import PyCurlError
4299 from landscape.lib.fs import create_text_file
4300
4301-SAMPLE_LSB_RELEASE = (
4302- "DISTRIB_ID=Ubuntu\n"
4303- "DISTRIB_RELEASE=6.06\n"
4304- "DISTRIB_CODENAME=dapper\n"
4305- 'DISTRIB_DESCRIPTION="Ubuntu 6.06.1 LTS"\n'
4306-)
4307+SAMPLE_OS_RELEASE = """PRETTY_NAME="Ubuntu 22.04.3 LTS"
4308+NAME="Ubuntu"
4309+VERSION_ID="22.04"
4310+VERSION="22.04.3 LTS (Jammy Jellyfish)"
4311+VERSION_CODENAME=codename
4312+ID=ubuntu
4313+ID_LIKE=debian
4314+HOME_URL="https://www.ubuntu.com/"
4315+SUPPORT_URL="https://help.ubuntu.com/"
4316+BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
4317+PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
4318+UBUNTU_CODENAME=codename
4319+"""
4320
4321
4322 def get_fqdn():
4323@@ -27,7 +34,6 @@ def get_fqdn():
4324
4325
4326 class ComputerInfoTest(LandscapeTest):
4327-
4328 helpers = [MonitorHelper]
4329
4330 sample_memory_info = """
4331@@ -58,7 +64,7 @@ VmallocChunk: 107432 kB
4332
4333 def setUp(self):
4334 LandscapeTest.setUp(self)
4335- self.lsb_release_filename = self.makeFile(SAMPLE_LSB_RELEASE)
4336+ self.os_release_filename = self.makeFile(SAMPLE_OS_RELEASE)
4337 self.query_results = {}
4338
4339 def fetch_stub(url, **kwargs):
4340@@ -100,7 +106,7 @@ VmallocChunk: 107432 kB
4341 messages = self.mstore.get_pending_messages()
4342 self.assertEqual(len(messages), 1)
4343 self.assertEqual(messages[0]["type"], "computer-info")
4344- self.assertNotEquals(len(messages[0]["hostname"]), 0)
4345+ self.assertNotEqual(len(messages[0]["hostname"]), 0)
4346 self.assertTrue(re.search(r"\w", messages[0]["hostname"]))
4347
4348 def test_only_report_changed_hostnames(self):
4349@@ -223,16 +229,16 @@ VmallocChunk: 107432 kB
4350 the distribution data reported by the plugin.
4351 """
4352 self.mstore.set_accepted_types(["distribution-info"])
4353- plugin = ComputerInfo(lsb_release_filename=self.lsb_release_filename)
4354+ plugin = ComputerInfo(os_release_filename=self.os_release_filename)
4355 self.monitor.add(plugin)
4356
4357 plugin.exchange()
4358 message = self.mstore.get_pending_messages()[0]
4359 self.assertEqual(message["type"], "distribution-info")
4360 self.assertEqual(message["distributor-id"], "Ubuntu")
4361- self.assertEqual(message["description"], "Ubuntu 6.06.1 LTS")
4362- self.assertEqual(message["release"], "6.06")
4363- self.assertEqual(message["code-name"], "dapper")
4364+ self.assertEqual(message["description"], "Ubuntu 22.04.3 LTS")
4365+ self.assertEqual(message["release"], "22.04")
4366+ self.assertEqual(message["code-name"], "codename")
4367
4368 def test_distribution_reported_only_once(self):
4369 """
4370@@ -258,23 +264,23 @@ VmallocChunk: 107432 kB
4371 the server.
4372 """
4373 self.mstore.set_accepted_types(["distribution-info"])
4374- plugin = ComputerInfo(lsb_release_filename=self.lsb_release_filename)
4375+ plugin = ComputerInfo(os_release_filename=self.os_release_filename)
4376 self.monitor.add(plugin)
4377
4378 plugin.exchange()
4379 message = self.mstore.get_pending_messages()[0]
4380 self.assertEqual(message["type"], "distribution-info")
4381 self.assertEqual(message["distributor-id"], "Ubuntu")
4382- self.assertEqual(message["description"], "Ubuntu 6.06.1 LTS")
4383- self.assertEqual(message["release"], "6.06")
4384- self.assertEqual(message["code-name"], "dapper")
4385+ self.assertEqual(message["description"], "Ubuntu 22.04.3 LTS")
4386+ self.assertEqual(message["release"], "22.04")
4387+ self.assertEqual(message["code-name"], "codename")
4388
4389- plugin._lsb_release_filename = self.makeFile(
4390+ plugin._os_release_filename = self.makeFile(
4391 """\
4392-DISTRIB_ID=Ubuntu
4393-DISTRIB_RELEASE=6.10
4394-DISTRIB_CODENAME=edgy
4395-DISTRIB_DESCRIPTION="Ubuntu 6.10"
4396+NAME=Ubuntu
4397+VERSION_ID=6.10
4398+VERSION_CODENAME=edgy
4399+PRETTY_NAME="Ubuntu 6.10"
4400 """,
4401 )
4402 plugin.exchange()
4403@@ -287,25 +293,25 @@ DISTRIB_DESCRIPTION="Ubuntu 6.10"
4404
4405 def test_unknown_distribution_key(self):
4406 self.mstore.set_accepted_types(["distribution-info"])
4407- lsb_release_filename = self.makeFile(
4408+ os_release_filename = self.makeFile(
4409 """\
4410-DISTRIB_ID=Ubuntu
4411-DISTRIB_RELEASE=6.10
4412-DISTRIB_CODENAME=edgy
4413-DISTRIB_DESCRIPTION="Ubuntu 6.10"
4414+NAME=Ubuntu
4415+VERSION_ID=22.04
4416+VERSION_CODENAME=codename
4417+PRETTY_NAME="Ubuntu 22.04.3 LTS"
4418 DISTRIB_NEW_UNEXPECTED_KEY=ooga
4419 """,
4420 )
4421- plugin = ComputerInfo(lsb_release_filename=lsb_release_filename)
4422+ plugin = ComputerInfo(os_release_filename=os_release_filename)
4423 self.monitor.add(plugin)
4424
4425 plugin.exchange()
4426 message = self.mstore.get_pending_messages()[0]
4427 self.assertEqual(message["type"], "distribution-info")
4428 self.assertEqual(message["distributor-id"], "Ubuntu")
4429- self.assertEqual(message["description"], "Ubuntu 6.10")
4430- self.assertEqual(message["release"], "6.10")
4431- self.assertEqual(message["code-name"], "edgy")
4432+ self.assertEqual(message["description"], "Ubuntu 22.04.3 LTS")
4433+ self.assertEqual(message["release"], "22.04")
4434+ self.assertEqual(message["code-name"], "codename")
4435
4436 def test_resynchronize(self):
4437 """
4438@@ -317,7 +323,7 @@ DISTRIB_NEW_UNEXPECTED_KEY=ooga
4439 plugin = ComputerInfo(
4440 get_fqdn=get_fqdn,
4441 meminfo_filename=meminfo_filename,
4442- lsb_release_filename=self.lsb_release_filename,
4443+ os_release_filename=self.os_release_filename,
4444 root_path=self.makeDir(),
4445 fetch_async=self.fetch_func,
4446 )
4447@@ -335,10 +341,10 @@ DISTRIB_NEW_UNEXPECTED_KEY=ooga
4448
4449 dist_info = {
4450 "type": "distribution-info",
4451- "code-name": "dapper",
4452- "description": "Ubuntu 6.06.1 LTS",
4453+ "code-name": "codename",
4454+ "description": "Ubuntu 22.04.3 LTS",
4455 "distributor-id": "Ubuntu",
4456- "release": "6.06",
4457+ "release": "22.04",
4458 }
4459 self.assertMessages(
4460 self.mstore.get_pending_messages(),
4461@@ -553,3 +559,46 @@ DISTRIB_NEW_UNEXPECTED_KEY=ooga
4462 },
4463 result,
4464 )
4465+
4466+ @mock.patch("landscape.client.snap_utils.get_assertions")
4467+ def test_snap_info(self, mock_get_assertions):
4468+ """Test getting the snap info message."""
4469+ mock_get_assertions.return_value = [
4470+ {
4471+ "authority-id": "canonical",
4472+ "brand-id": "canonical",
4473+ "model": "pc-amd64",
4474+ "serial": "03961d5d-26e5-443f-838d-6db046126bea",
4475+ },
4476+ ]
4477+
4478+ self.mstore.set_accepted_types(["snap-info"])
4479+ plugin = ComputerInfo(fetch_async=self.fetch_func)
4480+ self.monitor.add(plugin)
4481+ plugin.exchange()
4482+ messages = self.mstore.get_pending_messages()
4483+ self.assertEqual(len(messages), 1)
4484+ self.assertEqual(messages[0]["type"], "snap-info")
4485+ self.assertEqual(messages[0]["brand"], "canonical")
4486+ self.assertEqual(messages[0]["model"], "pc-amd64")
4487+ self.assertEqual(
4488+ messages[0]["serial"],
4489+ "03961d5d-26e5-443f-838d-6db046126bea",
4490+ )
4491+
4492+ @mock.patch("landscape.client.snap_utils.get_assertions")
4493+ def test_snap_info_no_results(self, mock_get_assertions):
4494+ """Test getting the snap info message when there are no results.
4495+
4496+ No results can happen when:
4497+ - A SnapdHttpException occurs
4498+ - No serial assertion is found
4499+ """
4500+ mock_get_assertions.return_value = None
4501+
4502+ self.mstore.set_accepted_types(["snap-info"])
4503+ plugin = ComputerInfo(fetch_async=self.fetch_func)
4504+ self.monitor.add(plugin)
4505+ plugin.exchange()
4506+ messages = self.mstore.get_pending_messages()
4507+ self.assertEqual(len(messages), 0)
4508diff --git a/landscape/client/monitor/tests/test_processorinfo.py b/landscape/client/monitor/tests/test_processorinfo.py
4509index 4a2bb8e..ffe47ce 100644
4510--- a/landscape/client/monitor/tests/test_processorinfo.py
4511+++ b/landscape/client/monitor/tests/test_processorinfo.py
4512@@ -30,8 +30,7 @@ class ProcessorInfoTest(LandscapeTest):
4513 self.assertTrue(len(message["processors"]) > 0)
4514
4515 for processor in message["processors"]:
4516- self.assertTrue("processor-id" in processor)
4517- self.assertTrue("model" in processor)
4518+ self.assertIn("processor-id", processor)
4519
4520 def test_call_on_accepted(self):
4521 """
4522@@ -326,6 +325,22 @@ Hardware : Foundation-v8A
4523 )
4524 self.assertEqual(processor_0["processor-id"], 0)
4525
4526+ def test_no_model_default(self):
4527+ """If there is no 'Processor' field, then the model defaults to
4528+ 'arm'.
4529+ """
4530+ filename = self.makeFile("processor : 0\n")
4531+ plugin = ProcessorInfo(
4532+ machine_name="aarch64",
4533+ source_filename=filename,
4534+ )
4535+ message = plugin.create_message()
4536+ self.assertEqual(message["type"], "processor-info")
4537+ self.assertTrue(len(message["processors"]) == 1)
4538+
4539+ processor_0 = message["processors"][0]
4540+ self.assertEqual(processor_0["model"], "arm")
4541+
4542
4543 class SparcMessageTest(LandscapeTest):
4544 """Tests for sparc-specific message builder."""
4545@@ -631,3 +646,60 @@ bogomips : 1198.25
4546
4547 self.mstore.set_accepted_types(["processor-info"])
4548 self.assertMessages(list(self.mstore.get_pending_messages()), [])
4549+
4550+
4551+class RISCVMessageTest(LandscapeTest):
4552+ """Test for RISCV-specific message handling."""
4553+
4554+ helpers = [MonitorHelper]
4555+
4556+ VISION_FIVE = """
4557+processor : 0
4558+hart : 0
4559+isa : rv64imafdc
4560+mmu : sv39
4561+uarch : sifive,u74-mc
4562+mvendorid : 0x489
4563+marchid : 0x8000000000000007
4564+mimpid : 0x20190531
4565+
4566+processor : 1
4567+hart : 1
4568+isa : rv64imafdc
4569+mmu : sv39
4570+uarch : sifive,u74-mc
4571+mvendorid : 0x489
4572+marchid : 0x8000000000000007
4573+mimpid : 0x20190531
4574+
4575+ """
4576+
4577+ def setUp(self):
4578+ super().setUp()
4579+
4580+ self.mstore.set_accepted_types(["processor-info"])
4581+
4582+ def test_read_sample_data(self):
4583+ """Ensure the plugin can parse a simple /proc/cpuinfo from a VisionFive
4584+ v1.
4585+ """
4586+ filename = self.makeFile(self.VISION_FIVE)
4587+ plugin = ProcessorInfo(
4588+ machine_name="riscv64",
4589+ source_filename=filename,
4590+ )
4591+ message = plugin.create_message()
4592+ self.assertEqual(message["type"], "processor-info")
4593+ self.assertEqual(len(message["processors"]), 2)
4594+
4595+ processor_0, processor_1 = message["processors"]
4596+
4597+ self.assertEqual(len(processor_0), 3)
4598+ self.assertEqual(processor_0["processor-id"], 0)
4599+ self.assertEqual(processor_0["model"], "sifive,u74-mc")
4600+ self.assertEqual(processor_0["vendor"], "rv64imafdc")
4601+
4602+ self.assertEqual(len(processor_1), 3)
4603+ self.assertEqual(processor_1["processor-id"], 1)
4604+ self.assertEqual(processor_1["model"], "sifive,u74-mc")
4605+ self.assertEqual(processor_1["vendor"], "rv64imafdc")
4606diff --git a/landscape/client/monitor/tests/test_service.py b/landscape/client/monitor/tests/test_service.py
4607index 67289b6..8104ba6 100644
4608--- a/landscape/client/monitor/tests/test_service.py
4609+++ b/landscape/client/monitor/tests/test_service.py
4610@@ -1,4 +1,5 @@
4611 from unittest.mock import Mock
4612+from unittest.mock import patch
4613
4614 from landscape.client.monitor.computerinfo import ComputerInfo
4615 from landscape.client.monitor.config import ALL_PLUGINS
4616@@ -44,6 +45,34 @@ class MonitorServiceTest(LandscapeTest):
4617 self.assertTrue(isinstance(plugins[0], ComputerInfo))
4618 self.assertTrue(isinstance(plugins[1], LoadAverage))
4619
4620+ def test_get_plugins_module_not_found(self):
4621+ """If a module is not found, a warning is logged."""
4622+ self.service.config.load(["--monitor-plugins", "TotallyDoesNotExist"])
4623+
4624+ with self.assertLogs(level="WARN") as cm:
4625+ plugins = self.service.get_plugins()
4626+
4627+ self.assertEqual(len(plugins), 0)
4628+ self.assertIn("Invalid monitor plugin", cm.output[0])
4629+ self.assertIn("TotallyDoesNotExist", cm.output[0])
4630+
4631+ def test_get_plugins_other_exception(self):
4632+ """If loading a plugin fails for another reason, a warning is logged,
4633+ with the exception.
4634+ """
4635+ self.service.config.load(["--monitor-plugins", "ComputerInfo"])
4636+
4637+ with self.assertLogs(level="WARN") as cm:
4638+ with patch(
4639+ "landscape.client.monitor.service.namedClass",
4640+ ) as namedClass:
4641+ namedClass.side_effect = Exception("Is there life on Mars?")
4642+ plugins = self.service.get_plugins()
4643+
4644+ self.assertEqual(len(plugins), 0)
4645+ self.assertIn("Unable to load", cm.output[0])
4646+ self.assertIn("Mars?", cm.output[0])
4647+
4648 def test_start_service(self):
4649 """
4650 The L{MonitorService.startService} method connects to the broker,
4651diff --git a/landscape/client/monitor/tests/test_snapmonitor.py b/landscape/client/monitor/tests/test_snapmonitor.py
4652index 086aec6..b112702 100644
4653--- a/landscape/client/monitor/tests/test_snapmonitor.py
4654+++ b/landscape/client/monitor/tests/test_snapmonitor.py
4655@@ -1,8 +1,10 @@
4656-from unittest.mock import Mock
4657+from unittest.mock import patch
4658
4659 from landscape.client.monitor.snapmonitor import SnapMonitor
4660-from landscape.client.snap.http import SnapdHttpException, SnapHttp
4661-from landscape.client.tests.helpers import LandscapeTest, MonitorHelper
4662+from landscape.client.snap_http import SnapdHttpException
4663+from landscape.client.snap_http import SnapdResponse
4664+from landscape.client.tests.helpers import LandscapeTest
4665+from landscape.client.tests.helpers import MonitorHelper
4666
4667
4668 class SnapMonitorTest(LandscapeTest):
4669@@ -11,11 +13,19 @@ class SnapMonitorTest(LandscapeTest):
4670 helpers = [MonitorHelper]
4671
4672 def setUp(self):
4673- super(SnapMonitorTest, self).setUp()
4674+ super().setUp()
4675 self.mstore.set_accepted_types(["snaps"])
4676
4677- def test_get_data(self):
4678+ @patch("landscape.client.monitor.snapmonitor.snap_http")
4679+ def test_get_data(self, snap_http_mock):
4680 """Tests getting installed snap data."""
4681+ snap_http_mock.list.return_value = SnapdResponse("sync", 200, "OK", [])
4682+ snap_http_mock.get_apps.return_value = SnapdResponse(
4683+ "sync",
4684+ 200,
4685+ "OK",
4686+ [],
4687+ )
4688 plugin = SnapMonitor()
4689 self.monitor.add(plugin)
4690
4691@@ -30,15 +40,13 @@ class SnapMonitorTest(LandscapeTest):
4692 """
4693 Tests that we return no data if there is an error getting it.
4694 """
4695- snap_http_mock = Mock(
4696- spec=SnapHttp,
4697- get_snaps=Mock(side_effect=SnapdHttpException)
4698- )
4699 plugin = SnapMonitor()
4700- plugin._snap_http = snap_http_mock
4701 self.monitor.add(plugin)
4702
4703- with self.assertLogs(level="ERROR") as cm:
4704+ with patch(
4705+ "landscape.client.monitor.snapmonitor.snap_http",
4706+ ) as snap_http_mock, self.assertLogs(level="ERROR") as cm:
4707+ snap_http_mock.list.side_effect = SnapdHttpException
4708 plugin.exchange()
4709
4710 messages = self.mstore.get_pending_messages()
4711@@ -46,5 +54,60 @@ class SnapMonitorTest(LandscapeTest):
4712 self.assertEqual(len(messages), 0)
4713 self.assertEqual(
4714 cm.output,
4715- ["ERROR:root:Unable to list installed snaps: "]
4716+ ["ERROR:root:Unable to list installed snaps: "],
4717+ )
4718+
4719+ @patch("landscape.client.monitor.snapmonitor.snap_http")
4720+ def test_get_snap_config(self, snap_http_mock):
4721+ """Tests that we can get and coerce snap config."""
4722+ plugin = SnapMonitor()
4723+ self.monitor.add(plugin)
4724+
4725+ snap_http_mock.list.return_value = SnapdResponse(
4726+ "sync",
4727+ 200,
4728+ "OK",
4729+ [
4730+ {
4731+ "name": "test-snap",
4732+ "revision": "1",
4733+ "confinement": "strict",
4734+ "version": "v1.0",
4735+ "id": "123",
4736+ },
4737+ ],
4738+ )
4739+ snap_http_mock.get_conf.return_value = SnapdResponse(
4740+ "sync",
4741+ 200,
4742+ "OK",
4743+ {
4744+ "foo": {"baz": "default", "qux": [1, True, 2.0]},
4745+ "bar": "enabled",
4746+ },
4747+ )
4748+ snap_http_mock.get_apps.return_value = SnapdResponse(
4749+ "sync",
4750+ 200,
4751+ "OK",
4752+ [],
4753+ )
4754+ plugin.exchange()
4755+
4756+ messages = self.mstore.get_pending_messages()
4757+
4758+ self.assertTrue(len(messages) > 0)
4759+ self.assertDictEqual(
4760+ messages[0]["snaps"]["installed"][0],
4761+ {
4762+ "name": "test-snap",
4763+ "revision": "1",
4764+ "confinement": "strict",
4765+ "version": "v1.0",
4766+ "id": "123",
4767+ "config": (
4768+ '{"foo": {"baz": "default", "qux": [1, true, 2.0]}, '
4769+ '"bar": "enabled"}'
4770+ ),
4771+ },
4772 )
4773diff --git a/landscape/client/monitor/tests/test_snapservicesmonitor.py b/landscape/client/monitor/tests/test_snapservicesmonitor.py
4774new file mode 100644
4775index 0000000..4c86cb3
4776--- /dev/null
4777+++ b/landscape/client/monitor/tests/test_snapservicesmonitor.py
4778@@ -0,0 +1,127 @@
4779+from unittest.mock import patch
4780+
4781+from landscape.client.monitor.snapservicesmonitor import SnapServicesMonitor
4782+from landscape.client.snap_http import SnapdHttpException
4783+from landscape.client.snap_http import SnapdResponse
4784+from landscape.client.tests.helpers import LandscapeTest
4785+from landscape.client.tests.helpers import MonitorHelper
4786+
4787+
4788+class SnapServicesMonitorTest(LandscapeTest):
4789+
4790+ helpers = [MonitorHelper]
4791+
4792+ def setUp(self):
4793+ super().setUp()
4794+ self.mstore.set_accepted_types(["snap-services"])
4795+
4796+ @patch("landscape.client.monitor.snapservicesmonitor.snap_http")
4797+ def test_get_data(self, snap_http_mock):
4798+ """Tests getting running snap services data."""
4799+ snap_http_mock.get_apps.return_value = SnapdResponse(
4800+ "sync",
4801+ 200,
4802+ "OK",
4803+ [
4804+ {
4805+ "snap": "test-snap",
4806+ "name": "bye-svc",
4807+ "daemon": "simple",
4808+ "daemon-scope": "system",
4809+ },
4810+ ],
4811+ )
4812+
4813+ plugin = SnapServicesMonitor()
4814+ self.monitor.add(plugin)
4815+
4816+ plugin.exchange()
4817+
4818+ messages = self.mstore.get_pending_messages()
4819+
4820+ self.assertTrue(len(messages) > 0)
4821+ self.assertIn("running", messages[0]["services"])
4822+
4823+ @patch("landscape.client.monitor.snapservicesmonitor.snap_http")
4824+ def test_get_snap_services(self, snap_http_mock):
4825+ """Tests that we can get and coerce snap services."""
4826+ plugin = SnapServicesMonitor()
4827+ self.monitor.add(plugin)
4828+ self.maxDiff = None
4829+
4830+ services = [
4831+ {
4832+ "snap": "test-snap",
4833+ "name": "bye-svc",
4834+ "daemon": "simple",
4835+ "daemon-scope": "system",
4836+ },
4837+ {
4838+ "snap": "test-snap",
4839+ "name": "hello-svc",
4840+ "daemon": "simple",
4841+ "daemon-scope": "system",
4842+ "active": True,
4843+ },
4844+ {
4845+ "activators": [
4846+ {
4847+ "Active": True,
4848+ "Enabled": True,
4849+ "Name": "unix",
4850+ "Type": "socket",
4851+ },
4852+ ],
4853+ "daemon": "simple",
4854+ "daemon-scope": "system",
4855+ "enabled": True,
4856+ "name": "user-daemon",
4857+ "snap": "lxd",
4858+ },
4859+ ]
4860+ snap_http_mock.list.return_value = SnapdResponse("sync", 200, "OK", [])
4861+ snap_http_mock.get_conf.return_value = SnapdResponse(
4862+ "sync",
4863+ 200,
4864+ "OK",
4865+ {},
4866+ )
4867+ snap_http_mock.get_apps.return_value = SnapdResponse(
4868+ "sync",
4869+ 200,
4870+ "OK",
4871+ services,
4872+ )
4873+ plugin.exchange()
4874+
4875+ messages = self.mstore.get_pending_messages()
4876+
4877+ self.assertTrue(len(messages) > 0)
4878+ self.assertCountEqual(messages[0]["services"]["running"], services)
4879+
4880+ @patch("landscape.client.monitor.snapservicesmonitor.snap_http")
4881+ def test_get_snap_services_error(self, snap_http_mock):
4882+ """Tests that we can get and coerce snap services."""
4883+ plugin = SnapServicesMonitor()
4884+ self.monitor.add(plugin)
4885+
4886+ snap_http_mock.list.return_value = SnapdResponse("sync", 200, "OK", [])
4887+ snap_http_mock.get_conf.return_value = SnapdResponse(
4888+ "sync",
4889+ 200,
4890+ "OK",
4891+ {},
4892+ )
4893+
4894+ with self.assertLogs(level="WARNING") as cm:
4895+ snap_http_mock.get_apps.side_effect = SnapdHttpException
4896+ plugin.exchange()
4897+
4898+ messages = self.mstore.get_pending_messages()
4899+
4900+ self.assertTrue(len(messages) > 0)
4901+ self.assertEqual(
4902+ cm.output,
4903+ ["WARNING:root:Unable to list services: "],
4904+ )
4905+ self.assertCountEqual(messages[0]["services"]["running"], [])
4906diff --git a/landscape/client/monitor/tests/test_temperature.py b/landscape/client/monitor/tests/test_temperature.py
4907index 93e3b6f..472fa46 100644
4908--- a/landscape/client/monitor/tests/test_temperature.py
4909+++ b/landscape/client/monitor/tests/test_temperature.py
4910@@ -5,10 +5,10 @@ from unittest import mock
4911 from landscape.client.monitor.temperature import Temperature
4912 from landscape.client.tests.helpers import LandscapeTest
4913 from landscape.client.tests.helpers import MonitorHelper
4914-from landscape.lib.tests.test_sysstats import ThermalZoneTest
4915+from landscape.lib.tests.test_sysstats import SysfsThermalZoneTest
4916
4917
4918-class TemperatureTestWithSampleData(ThermalZoneTest, LandscapeTest):
4919+class TemperatureTestWithSampleData(SysfsThermalZoneTest, LandscapeTest):
4920 """Tests for the temperature plugin."""
4921
4922 helpers = [MonitorHelper]
4923diff --git a/landscape/client/monitor/tests/test_ubuntuproinfo.py b/landscape/client/monitor/tests/test_ubuntuproinfo.py
4924deleted file mode 100644
4925index 1b8675f..0000000
4926--- a/landscape/client/monitor/tests/test_ubuntuproinfo.py
4927+++ /dev/null
4928@@ -1,47 +0,0 @@
4929-from unittest import mock
4930-
4931-from landscape.client.monitor.ubuntuproinfo import UbuntuProInfo
4932-from landscape.client.tests.helpers import LandscapeTest
4933-from landscape.client.tests.helpers import MonitorHelper
4934-
4935-
4936-class UbuntuProInfoTest(LandscapeTest):
4937- """Ubuntu Pro info plugin tests."""
4938-
4939- helpers = [MonitorHelper]
4940-
4941- def setUp(self):
4942- super().setUp()
4943- self.mstore.set_accepted_types(["ubuntu-pro-info"])
4944-
4945- def test_ubuntu_pro_info(self):
4946- """Tests calling `ua status`."""
4947- plugin = UbuntuProInfo()
4948- self.monitor.add(plugin)
4949-
4950- with mock.patch("subprocess.run") as run_mock:
4951- run_mock.return_value = mock.Mock(
4952- stdout='"This is a test"',
4953- )
4954- plugin.exchange()
4955-
4956- messages = self.mstore.get_pending_messages()
4957- run_mock.assert_called_once()
4958- self.assertTrue(len(messages) > 0)
4959- self.assertTrue("ubuntu-pro-info" in messages[0])
4960- self.assertEqual(messages[0]["ubuntu-pro-info"], '"This is a test"')
4961-
4962- def test_ubuntu_pro_info_no_ua(self):
4963- """Tests calling `ua status` when it is not installed."""
4964- plugin = UbuntuProInfo()
4965- self.monitor.add(plugin)
4966-
4967- with mock.patch("subprocess.run") as run_mock:
4968- run_mock.side_effect = FileNotFoundError()
4969- plugin.exchange()
4970-
4971- messages = self.mstore.get_pending_messages()
4972- run_mock.assert_called_once()
4973- self.assertTrue(len(messages) > 0)
4974- self.assertTrue("ubuntu-pro-info" in messages[0])
4975- self.assertIn("errors", messages[0]["ubuntu-pro-info"])
4976diff --git a/landscape/client/monitor/tests/test_usermonitor.py b/landscape/client/monitor/tests/test_usermonitor.py
4977index 1fddd9a..5a009d5 100644
4978--- a/landscape/client/monitor/tests/test_usermonitor.py
4979+++ b/landscape/client/monitor/tests/test_usermonitor.py
4980@@ -1,6 +1,7 @@
4981 import os
4982 from unittest.mock import ANY
4983 from unittest.mock import Mock
4984+from unittest.mock import patch
4985
4986 from twisted.internet.defer import fail
4987
4988@@ -208,6 +209,50 @@ class UserMonitorTest(LandscapeTest):
4989 ],
4990 )
4991
4992+ @patch("landscape.client.monitor.usermonitor.IS_CORE", "1")
4993+ def test_new_message_after_resynchronize_event_on_core(self):
4994+ """
4995+ When a 'resynchronize' reactor event is fired, a new session is
4996+ created and the UserMonitor creates a new message.
4997+ """
4998+ self.provider.users = [
4999+ (
5000+ "john-doe",
The diff has been truncated for viewing.

Subscribers

People subscribed via source and target branches