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
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 54fd142..38efb55 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,34 +1,26 @@
1name: ci1name: ci
2on: [pull_request, workflow_dispatch]2on: [pull_request, workflow_dispatch]
3jobs:3jobs:
4 check3:4 check:
5 runs-on: ${{ matrix.os }}5 runs-on: ${{ matrix.os }}
6 strategy:6 strategy:
7 matrix:7 matrix:
8 os: ["ubuntu-22.04", "ubuntu-20.04"]8 os: ["ubuntu-22.04", "ubuntu-20.04"]
9 steps:9 steps:
10 - uses: actions/checkout@v210 - uses: actions/checkout@v4
11 with:
12 submodules: true
11 - run: |13 - run: |
12 make depends14 make depends
13 # -common seems a catch-22, but this is just a shortcut to15 # -common seems a catch-22, but this is just a shortcut to
14 # initialize user and dirs, some used through tests.16 # initialize user and dirs, some used through tests.
15 sudo apt-get -y install landscape-common17 sudo apt-get -y install landscape-common
16 - run: make check3 TRIAL=/usr/bin/trial318 - run: make check TRIAL=/usr/bin/trial3
17 lint:19 lint:
18 runs-on: ubuntu-latest20 runs-on: ubuntu-latest
19 steps:21 steps:
20 - uses: actions/checkout@v222 - uses: actions/checkout@v4
23 with:
24 submodules: true
21 - run: make depends25 - run: make depends
22 - run: make lint26 - run: make lint
23 coverage:
24 runs-on: ubuntu-latest
25 steps:
26 - uses: actions/checkout@v2
27 - run: |
28 make depends
29 # -common seems a catch-22, but this is just a shortcut to
30 # initialize user and dirs, some used through tests.
31 sudo apt-get -y install landscape-common
32 - run: make coverage TRIAL=/usr/bin/trial3
33 - name: upload
34 uses: codecov/codecov-action@v1
diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml
35new file mode 10064427new file mode 100644
index 0000000..74ec228
--- /dev/null
+++ b/.github/workflows/codecov.yml
@@ -0,0 +1,19 @@
1name: Codecov upload
2
3on: [push, pull_request]
4
5jobs:
6 coverage:
7 runs-on: ubuntu-latest
8 steps:
9 - uses: actions/checkout@v4
10 with:
11 submodules: true
12 - run: |
13 make depends
14 # -common seems a catch-22, but this is just a shortcut to
15 # initialize user and dirs, some used through tests.
16 sudo apt-get -y install landscape-common
17 - run: make coverage TRIAL=/usr/bin/trial3
18 - name: upload
19 uses: codecov/codecov-action@v3
diff --git a/.gitmodules b/.gitmodules
0new file mode 10064420new file mode 100644
index 0000000..ab50d1c
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
1[submodule "snap-http"]
2 path = snap-http
3 url = https://github.com/Perfect5th/snap-http
diff --git a/Makefile b/Makefile
index 09ade0e..9626920 100644
--- a/Makefile
+++ b/Makefile
@@ -1,10 +1,10 @@
1PYDOCTOR ?= pydoctor1PYDOCTOR ?= pydoctor
2TXT2MAN ?= txt2man2TXT2MAN ?= txt2man
3PYTHON2 ?= python23PYTHON ?= python3
4PYTHON3 ?= python3
5SNAPCRAFT = SNAPCRAFT_BUILD_INFO=1 snapcraft4SNAPCRAFT = SNAPCRAFT_BUILD_INFO=1 snapcraft
6TRIAL ?= -m twisted.trial5TRIAL ?= -m twisted.trial
7TRIAL_ARGS ?=6TRIAL_ARGS ?=
7PRE_COMMIT ?= $(HOME)/.local/bin/pre-commit
88
9# PEP8 rules ignored:9# PEP8 rules ignored:
10# W503 https://www.flake8rules.com/rules/W503.html10# W503 https://www.flake8rules.com/rules/W503.html
@@ -16,57 +16,35 @@ help: ## Print help about available targets
16 @grep -h -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'16 @grep -h -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
1717
18.PHONY: depends18.PHONY: depends
19depends: depends3 ## py2 is deprecated19depends:
20 sudo apt-get -y install python3-flake8 python3-coverage20 sudo apt-get -y install python3-configobj python3-coverage python3-distutils-extra\
2121 python3-flake8 python3-mock python3-netifaces python3-pip python3-pycurl python3-twisted\
22.PHONY: depends222 net-tools
23depends2:
24 sudo apt-get -y install python-twisted-core python-distutils-extra python-mock python-configobj python-netifaces python-pycurl python-pip
25 pip install pre-commit23 pip install pre-commit
26 pre-commit install24 $(PRE_COMMIT) install
27
28.PHONY: depends3
29depends3:
30 sudo apt-get -y install python3-twisted python3-distutils-extra python3-mock python3-configobj python3-netifaces python3-pycurl python3-pip
31 pip3 install pre-commit
32 pre-commit install
3325
34all: build26all: build
3527
36.PHONY: build28.PHONY: build
37build: build2 build3 ## Build.29build:
3830 $(PYTHON) setup.py build_ext -i
39.PHONY: build2
40build2:
41 $(PYTHON2) setup.py build_ext -i
42
43.PHONY: build3
44build3:
45 $(PYTHON3) setup.py build_ext -i
46
47.PHONY: check
48check: check2 check3 ## Run all the tests.
49
50.PHONY: check2
51check2: build2
52 PYTHONPATH=$(PYTHONPATH):$(CURDIR) LC_ALL=C $(PYTHON2) $(TRIAL) --unclean-warnings $(TRIAL_ARGS) landscape
5331
54# trial3 does not support threading via `-j` at the moment32# trial3 does not support threading via `-j` at the moment
55# so we ignore TRIAL_ARGS.33# so we ignore TRIAL_ARGS.
56# TODO: Respect $TRIAL_ARGS once trial3 is fixed.34# TODO: Respect $TRIAL_ARGS once trial3 is fixed.
57.PHONY: check335.PHONY: check
58check3: TRIAL_ARGS=36check: TRIAL_ARGS=
59check3: build337check: build
60 PYTHONPATH=$(PYTHONPATH):$(CURDIR) LC_ALL=C $(PYTHON3) $(TRIAL) --unclean-warnings $(TRIAL_ARGS) landscape38 PYTHONPATH=$(PYTHONPATH):$(CURDIR) LC_ALL=C $(PYTHON) $(TRIAL) --unclean-warnings $(TRIAL_ARGS) landscape
6139
62.PHONY: coverage40.PHONY: coverage
63coverage:41coverage:
64 PYTHONPATH=$(PYTHONPATH):$(CURDIR) LC_ALL=C $(PYTHON3) -m coverage run $(TRIAL) --unclean-warnings landscape42 PYTHONPATH=$(PYTHONPATH):$(CURDIR) LC_ALL=C $(PYTHON) -m coverage run $(TRIAL) --unclean-warnings landscape
65 PYTHONPATH=$(PYTHONPATH):$(CURDIR) LC_ALL=C $(PYTHON3) -m coverage xml43 PYTHONPATH=$(PYTHONPATH):$(CURDIR) LC_ALL=C $(PYTHON) -m coverage xml
6644
67.PHONY: lint45.PHONY: lint
68lint:46lint:
69 $(PYTHON3) -m flake8 --ignore $(PEP8_IGNORED) `find landscape -name \*.py`47 $(PYTHON) -m flake8 --ignore $(PEP8_IGNORED) `find landscape -name \*.py`
7048
71.PHONY: pyflakes49.PHONY: pyflakes
72pyflakes:50pyflakes:
diff --git a/Makefile.packaging b/Makefile.packaging
index 5a43ce7..d3c90ad 100644
--- a/Makefile.packaging
+++ b/Makefile.packaging
@@ -60,8 +60,8 @@ releasetarball:
6060
61.PHONY: sdist61.PHONY: sdist
62sdist: clean62sdist: clean
63 mkdir -p sdist63 mkdir -p sdist/landscape-client-$(TARBALL_VERSION)
64 git archive --prefix landscape-client-$(TARBALL_VERSION)/ HEAD | tar -x -C sdist64 git ls-files --recurse-submodules | xargs -I {} cp -r --parents {} sdist/landscape-client-$(TARBALL_VERSION)
65 rm -rf sdist/landscape-client-$(TARBALL_VERSION)/debian65 rm -rf sdist/landscape-client-$(TARBALL_VERSION)/debian
66 sed -i -e "s/^UPSTREAM_VERSION.*/UPSTREAM_VERSION = \"$(TARBALL_VERSION)\"/g" \66 sed -i -e "s/^UPSTREAM_VERSION.*/UPSTREAM_VERSION = \"$(TARBALL_VERSION)\"/g" \
67 sdist/landscape-client-$(TARBALL_VERSION)/landscape/__init__.py67 sdist/landscape-client-$(TARBALL_VERSION)/landscape/__init__.py
diff --git a/README b/README
index 8751ef3..c8a4737 100644
--- a/README
+++ b/README
@@ -58,6 +58,12 @@ Landscape service. There are two ways to do this:
5858
59## Developing59## Developing
6060
61After cloning the repository, make sure you run the following command to pull the `snap-http` submodule:
62
63```shell
64git submodule update --init
65```
66
61To run the full test suite, run the following command:67To run the full test suite, run the following command:
6268
63```69```
@@ -81,12 +87,17 @@ system bus.
81$ sudo ./scripts/landscape-client -c root-client.conf87$ sudo ./scripts/landscape-client -c root-client.conf
82```88```
8389
84Before opening a PR, make sure to run the full testsuite and lint90Before opening a PR, make sure to run the full test suite and lint:
85```91```
86make check392make check
87make lint93make lint
88```94```
8995
96You can run a specific test by running the following (for example):
97```
98python3 -m twisted.trial landscape.client.broker.tests.test_client.BrokerClientTest.test_ping
99```
100
90### Building the Landscape Client snap101### Building the Landscape Client snap
91102
92First, you need to ensure that you have the appropriate tools installed:103First, you need to ensure that you have the appropriate tools installed:
@@ -158,7 +169,7 @@ $ gnome-keyring-daemon --unlock
158The gnome-keyring-daemon will prompt you (without a prompt) to type in169The gnome-keyring-daemon will prompt you (without a prompt) to type in
159the initial unlock password (typically the same password for the account170the initial unlock password (typically the same password for the account
160you are using - if you are using the default multipass or lxc "ubuntu"171you are using - if you are using the default multipass or lxc "ubuntu"
161login, use sudo passwd ubuntu to set it to a known value before doing 172login, use sudo passwd ubuntu to set it to a known value before doing
162the above step).173the above step).
163174
164Type the login password and hit <ENTER> followed by <CTRL>+D to end175Type the login password and hit <ENTER> followed by <CTRL>+D to end
diff --git a/debian/changelog b/debian/changelog
index 5643e19..6aa9e2c 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,34 @@
1landscape-client (24.02-0ubuntu1) noble; urgency=medium
2
3 * New upstream release 24.02
4 - d/control: added python3-dbus to Build-Depends
5 - d/control: added python3-dbus to landscape-client Depends. Added
6 python3-setuptools to landscape-common Depends. Removed lsb-base from
7 landscape-common Depends.
8 - d/control: use dephelper-compat 12, removed d/compat
9 - d/patches: removed patches that have been applied upstream
10 - d/watch: watch Github releases page instead of tags
11 - snap-http: included new submodule under identical license
12 - use dbus in shutdown manager instead of subprocess
13 - improve messages about manager plugin configuration
14 - add getting and settings snap configurations
15 - read temperature from hwmon devices
16 - add snapd device information to ComputerInfo message
17 - add snap services management
18 - d/landscape-common.postinst d/landscape-common.prerm: do not call
19 update-motd when installing/removing landscape-sysinfo.wrapper
20 (LP: #1855544)
21 - Livepatch status reporting bug
22 - bpickle: guard against negative string/bytestring lengths
23 - Makefile: remove check2, depends2, and references to python2
24 - add path for os-release inside a snap
25 - computer title generation for zero-touch deployment on Core
26 - user management on Core
27 - deploy system user assertion to device
28 - remote script execution for snaps
29
30 -- Mitch Burton <mitch.burton@canonical.com> Thu, 27 Feb 2024 16:46:56 -0800
31
1landscape-client (23.08-0ubuntu4) noble; urgency=medium32landscape-client (23.08-0ubuntu4) noble; urgency=medium
233
3 * d/p/0003-fix-cpuinfo-and-tests.patch: fix ARM and RISCV cpuinfo parsing;34 * d/p/0003-fix-cpuinfo-and-tests.patch: fix ARM and RISCV cpuinfo parsing;
diff --git a/debian/control b/debian/control
index c70ceae..3a859a6 100644
--- a/debian/control
+++ b/debian/control
@@ -8,7 +8,7 @@ Build-Depends: debhelper-compat (= 12), po-debconf, libdistro-info-perl,
8 lsb-release, gawk, net-tools,8 lsb-release, gawk, net-tools,
9 python3-apt, python3-twisted, python3-configobj,9 python3-apt, python3-twisted, python3-configobj,
10 python3-pycurl, python3-netifaces, python3-yaml,10 python3-pycurl, python3-netifaces, python3-yaml,
11 ubuntu-advantage-tools, locales-all11 ubuntu-advantage-tools, locales-all, python3-dbus
12Standards-Version: 4.4.012Standards-Version: 4.4.0
13Homepage: https://github.com/CanonicalLtd/landscape-client13Homepage: https://github.com/CanonicalLtd/landscape-client
1414
@@ -22,11 +22,11 @@ Depends: ${python3:Depends}, ${misc:Depends}, ${extra:Depends},
22 python3-gdbm,22 python3-gdbm,
23 python3-netifaces,23 python3-netifaces,
24 lsb-release,24 lsb-release,
25 lsb-base,
26 adduser,25 adduser,
27 bc,26 bc,
28 lshw,27 lshw,
29 libpam-modules28 libpam-modules,
29 python3-setuptools
30Description: Landscape administration system client - Common files30Description: Landscape administration system client - Common files
31 Landscape is a web-based tool for managing Ubuntu systems. This31 Landscape is a web-based tool for managing Ubuntu systems. This
32 package is necessary if you want your machine to be managed in a32 package is necessary if you want your machine to be managed in a
@@ -42,6 +42,7 @@ Depends: ${python3:Depends}, ${misc:Depends}, ${extra:Depends},
42 ${shlibs:Depends},42 ${shlibs:Depends},
43 landscape-common (= ${binary:Version}),43 landscape-common (= ${binary:Version}),
44 python3-pycurl,44 python3-pycurl,
45 python3-dbus
45Description: Landscape administration system client46Description: Landscape administration system client
46 Landscape is a web-based tool for managing Ubuntu systems. This47 Landscape is a web-based tool for managing Ubuntu systems. This
47 package is necessary if you want your machine to be managed in a48 package is necessary if you want your machine to be managed in a
diff --git a/debian/landscape-common.postinst b/debian/landscape-common.postinst
index e78c193..09826f5 100755
--- a/debian/landscape-common.postinst
+++ b/debian/landscape-common.postinst
@@ -90,18 +90,16 @@ case "$1" in
90 WRAPPER=/usr/share/landscape/landscape-sysinfo.wrapper90 WRAPPER=/usr/share/landscape/landscape-sysinfo.wrapper
91 PROFILE_LOCATION=/etc/profile.d/50-landscape-sysinfo.sh91 PROFILE_LOCATION=/etc/profile.d/50-landscape-sysinfo.sh
92 UPDATE_MOTD_LOCATION=/etc/update-motd.d/50-landscape-sysinfo92 UPDATE_MOTD_LOCATION=/etc/update-motd.d/50-landscape-sysinfo
93
94 if [ "$RET" = "Cache sysinfo in /etc/motd" ]; then93 if [ "$RET" = "Cache sysinfo in /etc/motd" ]; then
95 rm -f $PROFILE_LOCATION 2>/dev/null || true94 rm -f $PROFILE_LOCATION 2>/dev/null || true
96 ln -sf $WRAPPER $UPDATE_MOTD_LOCATION95 ln -sf $WRAPPER $UPDATE_MOTD_LOCATION
97 update-motd 2>/dev/null || true96 $WRAPPER >/dev/null 2>&1 || true
98 elif [ "$RET" = "Run sysinfo on every login" ]; then97 elif [ "$RET" = "Run sysinfo on every login" ]; then
99 rm -f $UPDATE_MOTD_LOCATION 2>/dev/null || true98 rm -f $UPDATE_MOTD_LOCATION 2>/dev/null || true
100 update-motd 2>/dev/null || true
101 ln -sf $WRAPPER $PROFILE_LOCATION99 ln -sf $WRAPPER $PROFILE_LOCATION
100 $WRAPPER >/dev/null 2>&1 || true
102 else101 else
103 rm -f $UPDATE_MOTD_LOCATION 2>/dev/null || true102 rm -f $UPDATE_MOTD_LOCATION 2>/dev/null || true
104 update-motd 2>/dev/null || true
105 rm -f $PROFILE_LOCATION || true103 rm -f $PROFILE_LOCATION || true
106 fi104 fi
107105
diff --git a/debian/landscape-common.prerm b/debian/landscape-common.prerm
index 049aa63..14e35dc 100644
--- a/debian/landscape-common.prerm
+++ b/debian/landscape-common.prerm
@@ -20,7 +20,6 @@ set -e
20case "$1" in20case "$1" in
21 remove|upgrade|deconfigure)21 remove|upgrade|deconfigure)
22 rm -f /etc/update-motd.d/50-landscape-sysinfo 2>/dev/null || true22 rm -f /etc/update-motd.d/50-landscape-sysinfo 2>/dev/null || true
23 update-motd 2>/dev/null || true
24 rm -f /etc/profile.d/landscape-sysinfo.sh 2>/dev/null || true23 rm -f /etc/profile.d/landscape-sysinfo.sh 2>/dev/null || true
25 ;;24 ;;
2625
diff --git a/debian/patches/0001-start-service-during-config.patch b/debian/patches/0001-start-service-during-config.patch
27deleted file mode 10064426deleted file mode 100644
index d3fb5f9..0000000
--- a/debian/patches/0001-start-service-during-config.patch
+++ /dev/null
@@ -1,269 +0,0 @@
1Description: Allow landscape-config to start landscape-client systemd service
2Bug-Ubuntu: https://bugs.launchpad.net/ubuntu/+source/landscape-client/+bug/2040189
3Author: Mitch Burton <mitch.burton@canonical.com>
4Origin: upstream, https://github.com/canonical/landscape-client/commit/0da6b4b64c7ca50c109279bd42633c537458fcf4
5Reviewed-by: Kevin Nasto <kevin.nasto@canonical.com>
6Applied-Upstream: 23.10
7Last-Update: 2023-11-07
8---
9This patch header follows DEP-3: http://dep.debian.net/deps/dep3/
10--- a/landscape/client/configuration.py
11+++ b/landscape/client/configuration.py
12@@ -624,8 +624,10 @@
13 decode_base64_ssl_public_certificate(config)
14 config.write()
15 # Restart the client to ensure that it's using the new configuration.
16+
17 if not config.no_start:
18 try:
19+ set_secure_id(config, "registering")
20 ServiceConfig.restart_landscape()
21 except ServiceConfigException as exc:
22 print_text(str(exc), error=True)
23@@ -796,13 +798,14 @@
24 # Results will be things like "success" or "ssl-error".
25 result = results[0]
26
27- if isinstance(result, SystemExit):
28- raise result
29-
30 # If there was an error and the caller requested that errors be reported
31 # to the on_error callable, then do so.
32 if result != "success" and on_error is not None:
33 on_error(1)
34+
35+ if isinstance(result, SystemExit):
36+ raise result
37+
38 return result
39
40
41@@ -883,6 +886,21 @@
42 return text
43
44
45+def set_secure_id(config, new_id):
46+ """Persists a secure id in the identity data file. This is used to indicate
47+ whether we are currently in the process of registering.
48+ """
49+ persist = Persist(
50+ filename=os.path.join(
51+ config.data_path,
52+ f"{BrokerService.service_name}.bpickle",
53+ ),
54+ )
55+ identity = Identity(config, persist)
56+ identity.secure_id = new_id
57+ persist.save()
58+
59+
60 def main(args, print=print):
61 """Interact with the user and the server to set up client configuration."""
62
63@@ -927,7 +945,11 @@
64 # Attempt to register the client.
65 reactor = LandscapeReactor()
66 if config.silent:
67- result = register(config, reactor)
68+ result = register(
69+ config,
70+ reactor,
71+ on_error=lambda _: set_secure_id(config, None),
72+ )
73 report_registration_outcome(result, print=print)
74 sys.exit(determine_exit_code(result))
75 else:
76@@ -937,6 +959,10 @@
77 default=default_answer,
78 )
79 if answer:
80- result = register(config, reactor)
81+ result = register(
82+ config,
83+ reactor,
84+ on_error=lambda _: set_secure_id(config, None),
85+ )
86 report_registration_outcome(result, print=print)
87 sys.exit(determine_exit_code(result))
88--- a/landscape/client/tests/test_configuration.py
89+++ b/landscape/client/tests/test_configuration.py
90@@ -34,6 +34,7 @@
91 from landscape.client.configuration import register
92 from landscape.client.configuration import registration_info_text
93 from landscape.client.configuration import report_registration_outcome
94+from landscape.client.configuration import set_secure_id
95 from landscape.client.configuration import setup
96 from landscape.client.configuration import show_help
97 from landscape.client.configuration import store_public_key_data
98@@ -738,12 +739,17 @@
99 bootstrap_tree_patcher = mock.patch(
100 "landscape.client.configuration.bootstrap_tree",
101 )
102+ set_secure_id_patch = mock.patch(
103+ "landscape.client.configuration.set_secure_id",
104+ )
105 self.mock_getuid = getuid_patcher.start()
106 self.mock_bootstrap_tree = bootstrap_tree_patcher.start()
107+ set_secure_id_patch.start()
108
109 def cleanup():
110 getuid_patcher.stop()
111 bootstrap_tree_patcher.stop()
112+ set_secure_id_patch.stop()
113
114 self.addCleanup(cleanup)
115
116@@ -1191,7 +1197,11 @@
117 )
118 self.assertEqual(0, exception.code)
119 mock_setup.assert_called_once_with(mock.ANY)
120- mock_register.assert_called_once_with(mock.ANY, mock.ANY)
121+ mock_register.assert_called_once_with(
122+ mock.ANY,
123+ mock.ANY,
124+ on_error=mock.ANY,
125+ )
126
127 @mock.patch("landscape.client.configuration.input", return_value="y")
128 @mock.patch(
129@@ -1219,7 +1229,11 @@
130 )
131 self.assertEqual(0, exception.code)
132 mock_setup.assert_called_once_with(mock.ANY)
133- mock_register.assert_called_once_with(mock.ANY, mock.ANY)
134+ mock_register.assert_called_once_with(
135+ mock.ANY,
136+ mock.ANY,
137+ on_error=mock.ANY,
138+ )
139 mock_input.assert_called_once_with(
140 "\nRequest a new registration for this computer now? [Y/n]: ",
141 )
142@@ -1256,7 +1270,11 @@
143 )
144 self.assertEqual(2, exception.code)
145 mock_setup.assert_called_once_with(mock.ANY)
146- mock_register.assert_called_once_with(mock.ANY, mock.ANY)
147+ mock_register.assert_called_once_with(
148+ mock.ANY,
149+ mock.ANY,
150+ on_error=mock.ANY,
151+ )
152 mock_input.assert_called_once_with(
153 "\nRequest a new registration for this computer now? [Y/n]: ",
154 )
155@@ -1295,7 +1313,11 @@
156 )
157 self.assertEqual(0, exception.code)
158 mock_setup.assert_called_once_with(mock.ANY)
159- mock_register.assert_called_once_with(mock.ANY, mock.ANY)
160+ mock_register.assert_called_once_with(
161+ mock.ANY,
162+ mock.ANY,
163+ on_error=mock.ANY,
164+ )
165 mock_input.assert_not_called()
166
167 self.assertEqual(
168@@ -1333,7 +1355,11 @@
169 )
170 self.assertEqual(2, exception.code)
171 mock_setup.assert_called_once_with(mock.ANY)
172- mock_register.assert_called_once_with(mock.ANY, mock.ANY)
173+ mock_register.assert_called_once_with(
174+ mock.ANY,
175+ mock.ANY,
176+ on_error=mock.ANY,
177+ )
178 mock_input.assert_not_called()
179 # Note that the error is output via sys.stderr.
180 self.assertEqual(
181@@ -1378,7 +1404,11 @@
182 mock_serviceconfig.set_start_on_boot.assert_called_once_with(True)
183 mock_serviceconfig.restart_landscape.assert_called_once_with()
184 mock_setup_script().run.assert_called_once_with()
185- mock_register.assert_called_once_with(mock.ANY, mock.ANY)
186+ mock_register.assert_called_once_with(
187+ mock.ANY,
188+ mock.ANY,
189+ on_error=mock.ANY,
190+ )
191 mock_input.assert_called_with(
192 "\nRequest a new registration for this computer now? [Y/n]: ",
193 )
194@@ -1457,7 +1487,11 @@
195 print=noop_print,
196 )
197 mock_setup.assert_called_once_with(mock.ANY)
198- mock_register.assert_called_once_with(mock.ANY, mock.ANY)
199+ mock_register.assert_called_once_with(
200+ mock.ANY,
201+ mock.ANY,
202+ on_error=mock.ANY,
203+ )
204 mock_input.assert_called_once_with(
205 "\nRequest a new registration for this computer now? [Y/n]: ",
206 )
207@@ -1477,7 +1511,11 @@
208 print=noop_print,
209 )
210 mock_setup.assert_called_once_with(mock.ANY)
211- mock_register.assert_called_once_with(mock.ANY, mock.ANY)
212+ mock_register.assert_called_once_with(
213+ mock.ANY,
214+ mock.ANY,
215+ on_error=mock.ANY,
216+ )
217 mock_input.assert_not_called()
218
219 @mock.patch("landscape.client.configuration.input")
220@@ -2290,6 +2328,27 @@
221 # We ask for retries because networks aren't reliable.
222 self.assertEqual(99, connector.max_retries)
223
224+ def test_register_got_error(self):
225+ """If there is an error from the connection, raises `SystemExit`."""
226+ reactor = mock.Mock()
227+ connector_factory = mock.Mock()
228+ results = []
229+
230+ def add_error():
231+ results.append(SystemExit())
232+
233+ reactor.run.side_effect = add_error
234+
235+ self.assertRaises(
236+ SystemExit,
237+ register,
238+ self.config,
239+ reactor,
240+ connector_factory,
241+ max_retries=2,
242+ results=results,
243+ )
244+
245 @mock.patch("landscape.client.configuration.LandscapeReactor")
246 def test_register_without_reactor(self, mock_reactor):
247 """If no reactor is passed, a LandscapeReactor will be instantiated.
248@@ -2683,3 +2742,21 @@
249 print=noop_print,
250 )
251 self.assertEqual(EXIT_NOT_REGISTERED, exception.code)
252+
253+
254+class SetSecureIdTest(LandscapeTest):
255+ """Tests for the `set_secure_id` function."""
256+
257+ @mock.patch("landscape.client.configuration.Persist")
258+ @mock.patch("landscape.client.configuration.Identity")
259+ def test_function(self, Identity, Persist):
260+ config = mock.Mock(data_path="/tmp/landscape")
261+
262+ set_secure_id(config, "fancysecureid")
263+
264+ Persist.assert_called_once_with(
265+ filename="/tmp/landscape/broker.bpickle",
266+ )
267+ Persist().save.assert_called_once_with()
268+ Identity.assert_called_once_with(config, Persist())
269+ self.assertEqual(Identity().secure_id, "fancysecureid")
diff --git a/debian/patches/0002-fix-broken-build-tests.patch b/debian/patches/0002-fix-broken-build-tests.patch
270deleted file mode 1006440deleted file mode 100644
index 8a118b3..0000000
--- a/debian/patches/0002-fix-broken-build-tests.patch
+++ /dev/null
@@ -1,54 +0,0 @@
1Description: Fix tests that do not pass in debian build environment
2 In environments where the run-as user's home directory does not exist, such
3 as sbuild, fall back to expecting the script path to be the root directory.
4 Mock snapd, as it does not run in an accessible way in some environments.
5Bug-Ubuntu: https://bugs.launchpad.net/ubuntu/+source/landscape-client/+bug/2044181
6Author: Mitch Burton <mitch.burton@canonical.com>
7Last-Update: 2023-11-22
8---
9This patch header follows DEP-3: http://dep.debian.net/deps/dep3/
10--- a/landscape/client/manager/tests/test_scriptexecution.py
11+++ b/landscape/client/manager/tests/test_scriptexecution.py
12@@ -463,6 +463,9 @@
13 gid = info.pw_gid
14 path = info.pw_dir
15
16+ if not os.path.exists(path):
17+ path = "/"
18+
19 return self._run_script(username, uid, gid, path)
20
21 def test_user_no_home(self):
22--- a/landscape/client/monitor/tests/test_snapmonitor.py
23+++ b/landscape/client/monitor/tests/test_snapmonitor.py
24@@ -16,7 +16,12 @@
25
26 def test_get_data(self):
27 """Tests getting installed snap data."""
28+ snap_http_mock = Mock(
29+ spec=SnapHttp,
30+ get_snaps=Mock(return_value={"result": []}),
31+ )
32 plugin = SnapMonitor()
33+ plugin._snap_http = snap_http_mock
34 self.monitor.add(plugin)
35
36 plugin.exchange()
37--- a/landscape/client/snap/tests/test_http.py
38+++ b/landscape/client/snap/tests/test_http.py
39@@ -19,6 +19,15 @@
40 def test_get_snaps(self):
41 """get_snaps() returns a dict with a list of installed snaps."""
42 http = SnapHttp()
43+
44+ def fill_buff(curl, buff, **kwargs):
45+ buff.write(
46+ b'{"result": [{"id": "foo", "name": "bar", '
47+ b'"publisher": "baz"}]}'
48+ )
49+
50+ http._perform = Mock(side_effect=fill_buff)
51+
52 result = http.get_snaps()["result"]
53
54 self.assertTrue(isinstance(result, list))
diff --git a/debian/patches/0003-fix-cpuinfo-and-tests.patch b/debian/patches/0003-fix-cpuinfo-and-tests.patch
55deleted file mode 1006440deleted file mode 100644
index 341a89e..0000000
--- a/debian/patches/0003-fix-cpuinfo-and-tests.patch
+++ /dev/null
@@ -1,134 +0,0 @@
1Description: Fix parsing processor IDs on ARM64 systems.
2Author: Mitch Burton <mitch.burton@canonical.com>
3Bug-Ubuntu: https://bugs.launchpad.net/ubuntu/+source/landscape-client/+bug/2046620
4Last-Update: 2024-01-02
5---
6This patch header follows DEP-3: http://dep.debian.net/deps/dep3/
7--- a/landscape/client/monitor/processorinfo.py
8+++ b/landscape/client/monitor/processorinfo.py
9@@ -181,30 +181,43 @@
10 def create_message(self):
11 """Returns a list containing information about each processor."""
12 processors = []
13- file = open(self._source_filename)
14+ regexp = re.compile(r"(?P<key>.*?)\s*:\s*(?P<value>.*)")
15+ current = {}
16
17- try:
18- regexp = re.compile(r"(?P<key>.*?)\s*:\s*(?P<value>.*)")
19- current = {}
20+ with open(self._source_filename) as fp:
21+ for line in fp:
22+ line = line.strip()
23+
24+ if not line:
25+ if current:
26+ processors.append(current.copy())
27+ current = {}
28+
29+ continue
30
31- for line in file:
32 match = regexp.match(line.strip())
33+
34 if match:
35 key = match.group("key")
36 value = match.group("value")
37
38- if key == "Processor":
39- # ARM doesn't support SMP, thus no processor-id in
40- # the cpuinfo
41- current["processor-id"] = 0
42+ if key == "processor":
43+ current["processor-id"] = int(value)
44+ if "model" not in current:
45+ current["model"] = "arm"
46+ elif key == "Processor":
47 current["model"] = value
48 elif key == "Cache size":
49 current["cache-size"] = int(value)
50
51- if current:
52- processors.append(current)
53- finally:
54- file.close()
55+ if current:
56+ processors.append(current)
57+
58+ # Older ARM machines may not have processor-ids, but we need them, so
59+ # we set missing ones to 0.
60+ for processor in processors:
61+ if "processor-id" not in processor:
62+ processor["processor-id"] = 0
63
64 return processors
65
66@@ -357,6 +370,8 @@
67
68 if key == "processor":
69 current = {"processor-id": int(parts[1].strip())}
70+ # A placeholder in case there is no model provided.
71+ current["model"] = "riscv"
72 processors.append(current)
73 elif key == "isa":
74 current["vendor"] = parts[1].strip()
75--- a/landscape/client/monitor/tests/test_processorinfo.py
76+++ b/landscape/client/monitor/tests/test_processorinfo.py
77@@ -30,8 +30,7 @@
78 self.assertTrue(len(message["processors"]) > 0)
79
80 for processor in message["processors"]:
81- self.assertTrue("processor-id" in processor)
82- self.assertTrue("model" in processor)
83+ self.assertIn("processor-id", processor)
84
85 def test_call_on_accepted(self):
86 """
87--- a/landscape/client/package/reporter.py
88+++ b/landscape/client/package/reporter.py
89@@ -381,7 +381,11 @@
90 env["http_proxy"] = self._config.http_proxy
91 if self._config.https_proxy:
92 env["https_proxy"] = self._config.https_proxy
93- result = spawn_process(self.apt_update_filename, env=env)
94+
95+ try:
96+ result = spawn_process(self.apt_update_filename, env=env)
97+ except Exception as e:
98+ return deferred.callback((b"", str(e).encode(), e.errno))
99
100 def callback(args, deferred):
101 return deferred.callback(args)
102--- a/landscape/client/package/tests/test_releaseupgrader.py
103+++ b/landscape/client/package/tests/test_releaseupgrader.py
104@@ -391,7 +391,8 @@
105 reactor.callWhenRunning(do_test)
106
107 def cleanup(ignored):
108- os.environ = env_backup
109+ os.environ.clear()
110+ os.environ.update(env_backup)
111 return ignored
112
113 return deferred.addBoth(cleanup)
114@@ -451,7 +452,8 @@
115 reactor.callWhenRunning(do_test)
116
117 def cleanup(ignored):
118- os.environ = env_backup
119+ os.environ.clear()
120+ os.environ.update(env_backup)
121 return ignored
122
123 return deferred.addBoth(cleanup)
124--- a/landscape/client/tests/test_watchdog.py
125+++ b/landscape/client/tests/test_watchdog.py
126@@ -792,7 +792,7 @@
127 "GRACEFUL_WAIT_PERIOD",
128 landscape.client.watchdog.GRACEFUL_WAIT_PERIOD,
129 )
130- landscape.client.watchdog.GRACEFUL_WAIT_PERIOD = 0.2
131+ landscape.client.watchdog.GRACEFUL_WAIT_PERIOD = 1
132 self.daemon.start()
133
134 def got_result(result):
diff --git a/debian/patches/series b/debian/patches/series
135deleted file mode 1006440deleted file mode 100644
index e28a21f..0000000
--- a/debian/patches/series
+++ /dev/null
@@ -1,3 +0,0 @@
10001-start-service-during-config.patch
20002-fix-broken-build-tests.patch
30003-fix-cpuinfo-and-tests.patch
diff --git a/debian/rules b/debian/rules
index cc680cd..3be827c 100755
--- a/debian/rules
+++ b/debian/rules
@@ -26,4 +26,4 @@ override_dh_installsystemd:
26 dh_installsystemd26 dh_installsystemd
2727
28override_dh_auto_test:28override_dh_auto_test:
29 HOME=$(shell mktemp -d) && make check329 HOME=$(shell mktemp -d) && make check
diff --git a/debian/watch b/debian/watch
index ba7ea91..a8377cc 100644
--- a/debian/watch
+++ b/debian/watch
@@ -1,3 +1,4 @@
1version=41version=4
2opts=filenamemangle=s/.+\/v?(\d\S+)\.tar\.gz/landscape-client-$1\.tar\.gz/ \2opts="searchmode=plain" \
3 https://github.com/CanonicalLtd/landscape-client/tags .*/?(\d{2}\.\d{2})\.tar\.gz3https://api.github.com/repos/canonical/landscape-client/releases?per_page=50 \
4https://github.com/canonical/landscape-client/releases/download/[^/]+/landscape-client_@ANY_VERSION@.orig\.tar\.gz
diff --git a/example.conf b/example.conf
index 16b710e..c7e71b8 100644
--- a/example.conf
+++ b/example.conf
@@ -96,6 +96,8 @@ ignore_sigusr1 = False
96# UbuntuProInfo - Ubuntu Pro registration information96# UbuntuProInfo - Ubuntu Pro registration information
97# LivePatch - Livepath status information97# LivePatch - Livepath status information
98# UbuntuProRebootRequired - informs if the system needs to be rebooted98# UbuntuProRebootRequired - informs if the system needs to be rebooted
99# SnapMonitor - manage installed snaps
100# SnapServicesMonitor - manage snap services
99#101#
100# The special value "ALL" is an alias for the full list of plugins.102# The special value "ALL" is an alias for the full list of plugins.
101monitor_plugins = ALL103monitor_plugins = ALL
@@ -166,9 +168,9 @@ cloud = True
166168
167# MANAGER OPTIONS169# MANAGER OPTIONS
168170
169# A comma-separated list of monitor plugins to use.171# A comma-separated list of manager plugins to use.
170#172#
171# Currently available monitor plugins are:173# Currently available manager plugins are:
172#174#
173# ProcessKiller175# ProcessKiller
174# PackageManager176# PackageManager
@@ -176,14 +178,23 @@ cloud = True
176# ShutdownManager178# ShutdownManager
177# AptSources179# AptSources
178# HardwareInfo180# HardwareInfo
181# KeystoneToken
182# SnapManager
183# SnapServicesManager
179#184#
180# The special vale "ALL" is an alias for the full list of plugins.185# The special value "ALL" is an alias for the entire list of plugins above and is the default.
181manager_plugins = ALL186manager_plugins = ALL
182187
188# A comma-separated list of manager plugins to use in addition to the default ones.
189#
190# The ScriptExecution manager plugin is not enabled by default.
191# The following example would enable it.
192#include_manager_plugins = ScriptExecution
193
183# A comma-separated list of usernames that scripts can run as.194# A comma-separated list of usernames that scripts can run as.
184#195#
185# By default, all usernames are allowed.196# By default, all usernames are allowed.
186script_users = ALL197#script_users = ALL
187198
188199
189# The maximum script output length transmitted to landscape200# The maximum script output length transmitted to landscape
@@ -198,3 +209,8 @@ script_users = ALL
198#209#
199# The default is True210# The default is True
200#manage_sources_list_d = True211#manage_sources_list_d = True
212
213# Set this for WSL instances managed by Landscape. The value
214# should match the uid assigned to the host machine.
215# For all other computers, do not set this parameter.
216#hostagent_uid = the-uid-of-the-host-machine
diff --git a/landscape/__init__.py b/landscape/__init__.py
index 1077067..6e296ac 100644
--- a/landscape/__init__.py
+++ b/landscape/__init__.py
@@ -1,5 +1,5 @@
1DEBIAN_REVISION = ""1DEBIAN_REVISION = ""
2UPSTREAM_VERSION = "23.08"2UPSTREAM_VERSION = "24.02"
3VERSION = f"{UPSTREAM_VERSION}{DEBIAN_REVISION}"3VERSION = f"{UPSTREAM_VERSION}{DEBIAN_REVISION}"
44
5# The minimum server API version that all Landscape servers are known to speak5# The minimum server API version that all Landscape servers are known to speak
diff --git a/landscape/client/__init__.py b/landscape/client/__init__.py
index e69de29..ccb5857 100644
--- a/landscape/client/__init__.py
+++ b/landscape/client/__init__.py
@@ -0,0 +1,11 @@
1import os
2
3IS_SNAP = os.getenv("LANDSCAPE_CLIENT_SNAP")
4IS_CORE = os.getenv("SNAP_SAVE_DATA") is not None
5
6USER = "root" if IS_SNAP else "landscape"
7GROUP = "root" if IS_SNAP else "landscape"
8
9DEFAULT_CONFIG = (
10 "/etc/landscape-client.conf" if IS_SNAP else "/etc/landscape/client.conf"
11)
diff --git a/landscape/client/broker/client.py b/landscape/client/broker/client.py
index edbc2c2..5d3dd4a 100644
--- a/landscape/client/broker/client.py
+++ b/landscape/client/broker/client.py
@@ -1,5 +1,6 @@
1import random1import random
2import sys2import sys
3import traceback
3from logging import debug4from logging import debug
4from logging import error5from logging import error
5from logging import exception6from logging import exception
@@ -142,11 +143,14 @@ class BrokerClientPlugin:
142143
143 def _error_log(self, failure):144 def _error_log(self, failure):
144 """Errback to log and reraise uncaught run errors."""145 """Errback to log and reraise uncaught run errors."""
145 msg = "{} raised an uncaught exception".format(type(self).__name__)146 cls = type(self).__name__
147 msg = f"{cls} raised an uncaught exception"
146 if sys.exc_info() == (None, None, None):148 if sys.exc_info() == (None, None, None):
147 error(msg)149 error(msg)
148 else:150 else:
149 exception(msg)151 exception(msg)
152 debug(traceback.format_exc(limit=15))
153
150 return failure154 return failure
151155
152156
diff --git a/landscape/client/broker/config.py b/landscape/client/broker/config.py
index 6b222fb..c050c22 100644
--- a/landscape/client/broker/config.py
+++ b/landscape/client/broker/config.py
@@ -31,6 +31,7 @@ class BrokerConfiguration(Configuration):
31 - C{urgent_exchange_interval} (C{1*60})31 - C{urgent_exchange_interval} (C{1*60})
32 - C{http_proxy}32 - C{http_proxy}
33 - C{https_proxy}33 - C{https_proxy}
34 - C{hostagent_uid}
34 """35 """
35 parser = super().make_parser()36 parser = super().make_parser()
3637
@@ -44,7 +45,7 @@ class BrokerConfiguration(Configuration):
44 "-p",45 "-p",
45 "--registration-key",46 "--registration-key",
46 metavar="KEY",47 metavar="KEY",
47 help="The account-wide key used for " "registering clients.",48 help="The account-wide key used for registering clients.",
48 )49 )
49 parser.add_option(50 parser.add_option(
50 "-t",51 "-t",
@@ -57,14 +58,14 @@ class BrokerConfiguration(Configuration):
57 default=15 * 60,58 default=15 * 60,
58 type="int",59 type="int",
59 metavar="INTERVAL",60 metavar="INTERVAL",
60 help="The number of seconds between server " "exchanges.",61 help="The number of seconds between server exchanges.",
61 )62 )
62 parser.add_option(63 parser.add_option(
63 "--urgent-exchange-interval",64 "--urgent-exchange-interval",
64 default=1 * 60,65 default=1 * 60,
65 type="int",66 type="int",
66 metavar="INTERVAL",67 metavar="INTERVAL",
67 help="The number of seconds between urgent server " "exchanges.",68 help="The number of seconds between urgent server exchanges.",
68 )69 )
69 parser.add_option(70 parser.add_option(
70 "--ping-interval",71 "--ping-interval",
@@ -93,6 +94,12 @@ class BrokerConfiguration(Configuration):
93 help="Comma separated list of tag names to be sent "94 help="Comma separated list of tag names to be sent "
94 "to the server.",95 "to the server.",
95 )96 )
97 parser.add_option(
98 "--hostagent-uid",
99 help="Only set this value if this computer is a WSL instance "
100 "managed by Landscape, in which case set it to be the uid that "
101 "Landscape assigned to the host machine.",
102 )
96103
97 return parser104 return parser
98105
diff --git a/landscape/client/broker/ping.py b/landscape/client/broker/ping.py
index 153be3c..e96ab7d 100644
--- a/landscape/client/broker/ping.py
+++ b/landscape/client/broker/ping.py
@@ -51,11 +51,12 @@ from landscape.lib.log import log_failure
51class PingClient:51class PingClient:
52 """An HTTP client which knows how to talk to the ping server."""52 """An HTTP client which knows how to talk to the ping server."""
5353
54 def __init__(self, reactor, get_page=None):54 def __init__(self, reactor, get_page=None, cainfo=None):
55 if get_page is None:55 if get_page is None:
56 get_page = fetch56 get_page = fetch
57 self._reactor = reactor57 self._reactor = reactor
58 self.get_page = get_page58 self.get_page = get_page
59 self._cainfo = cainfo
5960
60 def ping(self, url, insecure_id):61 def ping(self, url, insecure_id):
61 """Ask the question: are there messages for this computer ID?62 """Ask the question: are there messages for this computer ID?
@@ -83,6 +84,7 @@ class PingClient:
83 post=True,84 post=True,
84 data=data,85 data=data,
85 headers=headers,86 headers=headers,
87 cainfo=self._cainfo,
86 )88 )
87 page_deferred.addCallback(self._got_result)89 page_deferred.addCallback(self._got_result)
88 return page_deferred90 return page_deferred
@@ -94,8 +96,7 @@ class PingClient:
94 the response indicates that their are messages waiting for96 the response indicates that their are messages waiting for
95 this computer, False otherwise.97 this computer, False otherwise.
96 """98 """
97 if bpickle.loads(webtext) == {"messages": True}:99 return bpickle.loads(webtext) == {"messages": True}
98 return True
99100
100101
101class Pinger:102class Pinger:
@@ -138,7 +139,10 @@ class Pinger:
138139
139 def start(self):140 def start(self):
140 """Start pinging."""141 """Start pinging."""
141 self._ping_client = self.ping_client_factory(self._reactor)142 self._ping_client = self.ping_client_factory(
143 self._reactor,
144 cainfo=self._config.ssl_public_key,
145 )
142 self._schedule()146 self._schedule()
143147
144 def ping(self):148 def ping(self):
diff --git a/landscape/client/broker/registration.py b/landscape/client/broker/registration.py
index c21186e..060a16b 100644
--- a/landscape/client/broker/registration.py
+++ b/landscape/client/broker/registration.py
@@ -8,13 +8,11 @@ the machinery in this module will notice that we have no identification
8credentials yet and that the server accepts registration messages, so it8credentials yet and that the server accepts registration messages, so it
9will craft an appropriate one and send it out.9will craft an appropriate one and send it out.
10"""10"""
11import json
12import logging11import logging
1312
14from twisted.internet.defer import Deferred13from twisted.internet.defer import Deferred
1514
16from landscape.client.broker.exchange import maybe_bytes15from landscape.client.broker.exchange import maybe_bytes
17from landscape.client.monitor.ubuntuproinfo import get_ubuntu_pro_info
18from landscape.lib.juju import get_juju_info16from landscape.lib.juju import get_juju_info
19from landscape.lib.network import get_fqdn17from landscape.lib.network import get_fqdn
20from landscape.lib.tag import is_valid_tag_list18from landscape.lib.tag import is_valid_tag_list
@@ -76,6 +74,7 @@ class Identity:
76 registration_key = config_property("registration_key")74 registration_key = config_property("registration_key")
77 tags = config_property("tags")75 tags = config_property("tags")
78 access_group = config_property("access_group")76 access_group = config_property("access_group")
77 hostagent_uid = config_property("hostagent_uid")
7978
80 def __init__(self, config, persist):79 def __init__(self, config, persist):
81 self._config = config80 self._config = config
@@ -190,6 +189,7 @@ class RegistrationHandler:
190 tags = identity.tags189 tags = identity.tags
191 group = identity.access_group190 group = identity.access_group
192 registration_key = identity.registration_key191 registration_key = identity.registration_key
192 hostagent_uid = identity.hostagent_uid
193193
194 self._message_store.delete_all_messages()194 self._message_store.delete_all_messages()
195195
@@ -217,6 +217,8 @@ class RegistrationHandler:
217217
218 if group:218 if group:
219 message["access_group"] = group219 message["access_group"] = group
220 if hostagent_uid:
221 message["hostagent_uid"] = hostagent_uid
220222
221 server_api = self._message_store.get_server_api()223 server_api = self._message_store.get_server_api()
222 # If we have juju data to send and if the server is recent enough to224 # If we have juju data to send and if the server is recent enough to
@@ -237,8 +239,6 @@ class RegistrationHandler:
237 with_tags = f"and tags {tags} " if tags else ""239 with_tags = f"and tags {tags} " if tags else ""
238 with_group = f"in access group '{group}' " if group else ""240 with_group = f"in access group '{group}' " if group else ""
239241
240 message["ubuntu_pro_info"] = json.dumps(get_ubuntu_pro_info())
241
242 logging.info(242 logging.info(
243 f"Queueing message to register with account {account_name!r} "243 f"Queueing message to register with account {account_name!r} "
244 f"{with_group}{with_tags}{with_word} a password.",244 f"{with_group}{with_tags}{with_word} a password.",
diff --git a/landscape/client/broker/store.py b/landscape/client/broker/store.py
index 7557d45..ede2514 100644
--- a/landscape/client/broker/store.py
+++ b/landscape/client/broker/store.py
@@ -137,8 +137,14 @@ class MessageStore:
137 # in case the server supports it.137 # in case the server supports it.
138 _api = DEFAULT_SERVER_API138 _api = DEFAULT_SERVER_API
139139
140 def __init__(self, persist, directory, directory_size=1000, max_dirs=4,140 def __init__(
141 max_size_mb=400):141 self,
142 persist,
143 directory,
144 directory_size=1000,
145 max_dirs=4,
146 max_size_mb=400,
147 ):
142 self._directory = directory148 self._directory = directory
143 self._directory_size = directory_size149 self._directory_size = directory_size
144 self._max_dirs = max_dirs # Maximum number of directories in store150 self._max_dirs = max_dirs # Maximum number of directories in store
@@ -407,7 +413,7 @@ class MessageStore:
407 self.add({"type": "resynchronize"})413 self.add({"type": "resynchronize"})
408 self._persist.set("blackhole-messages", True)414 self._persist.set("blackhole-messages", True)
409 logging.warning(415 logging.warning(
410 "Unable to succesfully communicate with Landscape server "416 "Unable to successfully communicate with Landscape server "
411 "for more than a week. Waiting for resync.",417 "for more than a week. Waiting for resync.",
412 )418 )
413419
diff --git a/landscape/client/broker/tests/test_config.py b/landscape/client/broker/tests/test_config.py
index b5236ae..db03701 100644
--- a/landscape/client/broker/tests/test_config.py
+++ b/landscape/client/broker/tests/test_config.py
@@ -12,7 +12,7 @@ class ConfigurationTests(LandscapeTest):
12 def test_loading_sets_http_proxies(self):12 def test_loading_sets_http_proxies(self):
13 """13 """
14 The L{BrokerConfiguration.load} method sets the 'http_proxy' and14 The L{BrokerConfiguration.load} method sets the 'http_proxy' and
15 'https_proxy' enviroment variables to the provided values.15 'https_proxy' environment variables to the provided values.
16 """16 """
17 if "http_proxy" in os.environ:17 if "http_proxy" in os.environ:
18 del os.environ["http_proxy"]18 del os.environ["http_proxy"]
@@ -36,7 +36,7 @@ class ConfigurationTests(LandscapeTest):
36 def test_loading_without_http_proxies_does_not_touch_environment(self):36 def test_loading_without_http_proxies_does_not_touch_environment(self):
37 """37 """
38 The L{BrokerConfiguration.load} method doesn't override the38 The L{BrokerConfiguration.load} method doesn't override the
39 'http_proxy' and 'https_proxy' enviroment variables if they39 'http_proxy' and 'https_proxy' environment variables if they
40 are already set and no new value was specified.40 are already set and no new value was specified.
41 """41 """
42 os.environ["http_proxy"] = "heyo"42 os.environ["http_proxy"] = "heyo"
@@ -71,7 +71,7 @@ class ConfigurationTests(LandscapeTest):
71 self.assertEqual(os.environ["https_proxy"], "originals")71 self.assertEqual(os.environ["https_proxy"], "originals")
7272
73 def test_default_exchange_intervals(self):73 def test_default_exchange_intervals(self):
74 """Exchange intervales are set to sane defaults."""74 """Exchange intervals are set to sane defaults."""
75 configuration = BrokerConfiguration()75 configuration = BrokerConfiguration()
76 self.assertEqual(60, configuration.urgent_exchange_interval)76 self.assertEqual(60, configuration.urgent_exchange_interval)
77 self.assertEqual(900, configuration.exchange_interval)77 self.assertEqual(900, configuration.exchange_interval)
@@ -135,3 +135,27 @@ class ConfigurationTests(LandscapeTest):
135 configuration.url,135 configuration.url,
136 "https://landscape.canonical.com/message-system",136 "https://landscape.canonical.com/message-system",
137 )137 )
138
139 def test_hostagent_uid_handling(self):
140 """
141 The 'hostagent_uid' value specified in the configuration file is
142 passed through.
143 """
144 filename = self.makeFile("[client]\nhostagent_uid = AWESOME COMPUTER")
145
146 configuration = BrokerConfiguration()
147 configuration.load(["--config", filename, "--url", "whatever"])
148
149 self.assertEqual(configuration.hostagent_uid, "AWESOME COMPUTER")
150
151 def test_missing_hostagent_uid_is_none(self):
152 """
153 Test that if we don't explicitly pass a hostagent_uid, then this value
154 is None.
155 """
156 filename = self.makeFile("[client]\n")
157
158 configuration = BrokerConfiguration()
159 configuration.load(["--config", filename, "--url", "whatever"])
160
161 self.assertIsNone(configuration.hostagent_uid)
diff --git a/landscape/client/broker/tests/test_exchange.py b/landscape/client/broker/tests/test_exchange.py
index bc7fb5f..dd396c3 100644
--- a/landscape/client/broker/tests/test_exchange.py
+++ b/landscape/client/broker/tests/test_exchange.py
@@ -565,7 +565,7 @@ class MessageExchangeTest(LandscapeTest):
565 """565 """
566 If the server asks for messages that we no longer have, the message566 If the server asks for messages that we no longer have, the message
567 exchange plugin should send a message to the server indicating that a567 exchange plugin should send a message to the server indicating that a
568 resynchronization is occuring and then fire a "resynchronize-clients"568 resynchronization is occurring and then fire a "resynchronize-clients"
569 reactor message, so that plugins can generate new data -- if the server569 reactor message, so that plugins can generate new data -- if the server
570 got out of synch with the client, then we're best off synchronizing570 got out of synch with the client, then we're best off synchronizing
571 everything back to it.571 everything back to it.
@@ -859,7 +859,7 @@ class MessageExchangeTest(LandscapeTest):
859 self.reactor.advance(1)859 self.reactor.advance(1)
860 self.assertEqual(events, [True])860 self.assertEqual(events, [True])
861861
862 def test_impending_exchange_gets_reschudeled_with_urgent_reschedule(self):862 def test_impending_exchange_gets_rescheduled_with_urgent_reschedule(self):
863 """863 """
864 When an urgent exchange is scheduled after a regular exchange was864 When an urgent exchange is scheduled after a regular exchange was
865 scheduled but before it executed, the old C{impending-exchange} event865 scheduled but before it executed, the old C{impending-exchange} event
@@ -955,7 +955,7 @@ class MessageExchangeTest(LandscapeTest):
955 self.wait_for_exchange()955 self.wait_for_exchange()
956 self.assertFalse(self.transport.payloads)956 self.assertFalse(self.transport.payloads)
957957
958 def test_stop_twice_doesnt_break(self):958 def test_stop_twice_does_not_break(self):
959 self.exchanger.schedule_exchange()959 self.exchanger.schedule_exchange()
960 self.exchanger.stop()960 self.exchanger.stop()
961 self.exchanger.stop()961 self.exchanger.stop()
@@ -1014,7 +1014,7 @@ class MessageExchangeTest(LandscapeTest):
10141014
1015 def test_register_message(self):1015 def test_register_message(self):
1016 """1016 """
1017 The exchanger expsoses a mechanism for subscribing to messages1017 The exchanger exposes a mechanism for subscribing to messages
1018 of a particular type.1018 of a particular type.
1019 """1019 """
1020 messages = []1020 messages = []
diff --git a/landscape/client/broker/tests/test_ping.py b/landscape/client/broker/tests/test_ping.py
index 843b984..fe08dee 100644
--- a/landscape/client/broker/tests/test_ping.py
+++ b/landscape/client/broker/tests/test_ping.py
@@ -16,7 +16,7 @@ class FakePageGetter:
16 self.response = response16 self.response = response
17 self.fetches = []17 self.fetches = []
1818
19 def get_page(self, url, post, headers, data):19 def get_page(self, url, post, headers, data, cainfo=None):
20 """20 """
21 A method which is supposed to act like a limited version of21 A method which is supposed to act like a limited version of
22 L{landscape.lib.fetch.fetch}.22 L{landscape.lib.fetch.fetch}.
@@ -27,7 +27,7 @@ class FakePageGetter:
27 self.fetches.append((url, post, headers, data))27 self.fetches.append((url, post, headers, data))
28 return bpickle.dumps(self.response)28 return bpickle.dumps(self.response)
2929
30 def failing_get_page(self, url, post, headers, data):30 def failing_get_page(self, url, post, headers, data, cainfo=None):
31 """31 """
32 A method which is supposed to act like a limited version of32 A method which is supposed to act like a limited version of
33 L{landscape.lib.fetch.fetch}.33 L{landscape.lib.fetch.fetch}.
@@ -126,8 +126,12 @@ class PingerTest(LandscapeTest):
126 super().setUp()126 super().setUp()
127 self.page_getter = FakePageGetter(None)127 self.page_getter = FakePageGetter(None)
128128
129 def factory(reactor):129 def factory(reactor, **kwargs):
130 return PingClient(reactor, get_page=self.page_getter.get_page)130 return PingClient(
131 reactor,
132 get_page=self.page_getter.get_page,
133 **kwargs,
134 )
131135
132 self.config.ping_url = "http://localhost:8081/whatever"136 self.config.ping_url = "http://localhost:8081/whatever"
133 self.config.ping_interval = 10137 self.config.ping_interval = 10
diff --git a/landscape/client/broker/tests/test_registration.py b/landscape/client/broker/tests/test_registration.py
index fe1d256..1e236fe 100644
--- a/landscape/client/broker/tests/test_registration.py
+++ b/landscape/client/broker/tests/test_registration.py
@@ -78,6 +78,9 @@ class IdentityTest(LandscapeTest):
78 def test_access_group(self):78 def test_access_group(self):
79 self.check_config_property("access_group")79 self.check_config_property("access_group")
8080
81 def test_hostagent_uid(self):
82 self.check_config_property("hostagent_uid")
83
8184
82class RegistrationHandlerTestBase(LandscapeTest):85class RegistrationHandlerTestBase(LandscapeTest):
8386
@@ -383,6 +386,47 @@ class RegistrationHandlerTest(RegistrationHandlerTestBase):
383 # Make sure the key does not appear in the outgoing message.386 # Make sure the key does not appear in the outgoing message.
384 self.assertNotIn("access_group", messages[0])387 self.assertNotIn("access_group", messages[0])
385388
389 def test_queue_message_on_exchange_with_hostagent_uid(self):
390 """
391 If the admin has defined a hostagent_uid for this computer, we send
392 it to the server.
393 """
394 self.mstore.set_accepted_types(["register"])
395 # hostagent_uid is introduced in the 3.3 message schema
396 self.mstore.set_server_api(b"3.3")
397 self.config.account_name = "account_name"
398 self.config.hostagent_uid = "dinosaur computer"
399 self.config.tags = "server,london"
400 self.reactor.fire("pre-exchange")
401 messages = self.mstore.get_pending_messages()
402 self.assertEqual("dinosaur computer", messages[0]["hostagent_uid"])
403
404 def test_queue_message_on_exchange_with_empty_hostagent_uid(self):
405 """
406 If the hostagent_uid is "", then the outgoing message does not define
407 a "hostagent_uid" key.
408 """
409 self.mstore.set_accepted_types(["register"])
410 # hostagent_uid is introduced in the 3.3 message schema
411 self.mstore.set_server_api(b"3.3")
412 self.config.hostagent_uid = ""
413 self.reactor.fire("pre-exchange")
414 messages = self.mstore.get_pending_messages()
415 self.assertNotIn("hostagent_uid", messages[0])
416
417 def test_queue_message_on_exchange_with_none_hostagent_uid(self):
418 """
419 If the hostagent_uid is None, then the outgoing message does not define
420 a "hostagent_uid" key.
421 """
422 self.mstore.set_accepted_types(["register"])
423 # hostagent_uid is introduced in the 3.3 message schema
424 self.mstore.set_server_api(b"3.3")
425 self.config.hostagent_uid = None
426 self.reactor.fire("pre-exchange")
427 messages = self.mstore.get_pending_messages()
428 self.assertNotIn("hostagent_uid", messages[0])
429
386 def test_queueing_registration_message_resets_message_store(self):430 def test_queueing_registration_message_resets_message_store(self):
387 """431 """
388 When a registration message is queued, the store is reset432 When a registration message is queued, the store is reset
@@ -457,7 +501,7 @@ class RegistrationHandlerTest(RegistrationHandlerTestBase):
457 {"type": b"registration", "info": b"blah-blah"},501 {"type": b"registration", "info": b"blah-blah"},
458 )502 )
459 for name, args, kwargs in reactor_fire_mock.mock_calls:503 for name, args, kwargs in reactor_fire_mock.mock_calls:
460 self.assertNotEquals("registration-failed", args[0])504 self.assertNotEqual("registration-failed", args[0])
461505
462 def test_register_resets_ids(self):506 def test_register_resets_ids(self):
463 self.identity.secure_id = "foo"507 self.identity.secure_id = "foo"
diff --git a/landscape/client/broker/tests/test_store.py b/landscape/client/broker/tests/test_store.py
index b61edf3..5dd2fbb 100644
--- a/landscape/client/broker/tests/test_store.py
+++ b/landscape/client/broker/tests/test_store.py
@@ -700,11 +700,11 @@ class MessageStoreTest(LandscapeTest):
700 """Messages stop accumulating after one week of not being sent."""700 """Messages stop accumulating after one week of not being sent."""
701 self.store.record_failure(0)701 self.store.record_failure(0)
702 self.store.record_failure(7 * 24 * 60 * 60)702 self.store.record_failure(7 * 24 * 60 * 60)
703 self.assertIsNot(None, self.store.add({"type": "empty"}))703 self.assertIsNotNone(self.store.add({"type": "empty"}))
704 self.store.record_failure((7 * 24 * 60 * 60) + 1)704 self.store.record_failure((7 * 24 * 60 * 60) + 1)
705 self.assertIs(None, self.store.add({"type": "empty"}))705 self.assertIsNone(self.store.add({"type": "empty"}))
706 self.assertIn(706 self.assertIn(
707 "WARNING: Unable to succesfully communicate with "707 "WARNING: Unable to successfully communicate with "
708 "Landscape server for more than a week. Waiting for "708 "Landscape server for more than a week. Waiting for "
709 "resync.",709 "resync.",
710 self.logfile.getvalue(),710 self.logfile.getvalue(),
diff --git a/landscape/client/configuration.py b/landscape/client/configuration.py
index 621e797..759b45f 100644
--- a/landscape/client/configuration.py
+++ b/landscape/client/configuration.py
@@ -13,6 +13,8 @@ import textwrap
13from functools import partial13from functools import partial
14from urllib.parse import urlparse14from urllib.parse import urlparse
1515
16from landscape.client import GROUP
17from landscape.client import USER
16from landscape.client.broker.amp import RemoteBrokerConnector18from landscape.client.broker.amp import RemoteBrokerConnector
17from landscape.client.broker.config import BrokerConfiguration19from landscape.client.broker.config import BrokerConfiguration
18from landscape.client.broker.registration import Identity20from landscape.client.broker.registration import Identity
@@ -197,14 +199,15 @@ class LandscapeSetupConfiguration(BrokerConfiguration):
197 "--script-users",199 "--script-users",
198 metavar="USERS",200 metavar="USERS",
199 help="A comma-separated list of users to allow "201 help="A comma-separated list of users to allow "
200 "scripts to run. To allow scripts to be run "202 "scripts to run. To allow scripts to be run "
201 "by any user, enter: ALL",203 "by any user, enter: ALL",
202 )204 )
203 parser.add_option(205 parser.add_option(
204 "--include-manager-plugins",206 "--include-manager-plugins",
205 metavar="PLUGINS",207 metavar="PLUGINS",
206 default="",208 default="",
207 help="A comma-separated list of manager plugins to " "load.",209 help="A comma-separated list of manager plugins "
210 "to enable in addition to the defaults.",
208 )211 )
209 parser.add_option(212 parser.add_option(
210 "-n",213 "-n",
@@ -228,13 +231,13 @@ class LandscapeSetupConfiguration(BrokerConfiguration):
228 "--disable",231 "--disable",
229 action="store_true",232 action="store_true",
230 default=False,233 default=False,
231 help="Stop running clients and disable start at " "boot.",234 help="Stop running clients and disable start at boot.",
232 )235 )
233 parser.add_option(236 parser.add_option(
234 "--init",237 "--init",
235 action="store_true",238 action="store_true",
236 default=False,239 default=False,
237 help="Set up the client directories structure " "and exit.",240 help="Set up the client directories structure and exit.",
238 )241 )
239 parser.add_option(242 parser.add_option(
240 "--is-registered",243 "--is-registered",
@@ -565,7 +568,7 @@ def check_account_name_and_password(config):
565 if config.silent and not config.no_start:568 if config.silent and not config.no_start:
566 if not (config.get("account_name") and config.get("computer_title")):569 if not (config.get("account_name") and config.get("computer_title")):
567 raise ConfigurationError(570 raise ConfigurationError(
568 "An account name and computer title are " "required.",571 "An account name and computer title are required.",
569 )572 )
570573
571574
@@ -624,8 +627,10 @@ def setup(config):
624 decode_base64_ssl_public_certificate(config)627 decode_base64_ssl_public_certificate(config)
625 config.write()628 config.write()
626 # Restart the client to ensure that it's using the new configuration.629 # Restart the client to ensure that it's using the new configuration.
630
627 if not config.no_start:631 if not config.no_start:
628 try:632 try:
633 set_secure_id(config, "registering")
629 ServiceConfig.restart_landscape()634 ServiceConfig.restart_landscape()
630 except ServiceConfigException as exc:635 except ServiceConfigException as exc:
631 print_text(str(exc), error=True)636 print_text(str(exc), error=True)
@@ -643,13 +648,8 @@ def setup(config):
643def bootstrap_tree(config):648def bootstrap_tree(config):
644 """Create the client directories tree."""649 """Create the client directories tree."""
645 bootstrap_list = [650 bootstrap_list = [
646 BootstrapDirectory("$data_path", "landscape", "root", 0o755),651 BootstrapDirectory("$data_path", USER, "root", 0o755),
647 BootstrapDirectory(652 BootstrapDirectory("$annotations_path", USER, GROUP, 0o755),
648 "$annotations_path",
649 "landscape",
650 "landscape",
651 0o755,
652 ),
653 ]653 ]
654 BootstrapList(bootstrap_list).bootstrap(654 BootstrapList(bootstrap_list).bootstrap(
655 data_path=config.data_path,655 data_path=config.data_path,
@@ -796,13 +796,14 @@ def register(
796 # Results will be things like "success" or "ssl-error".796 # Results will be things like "success" or "ssl-error".
797 result = results[0]797 result = results[0]
798798
799 if isinstance(result, SystemExit):
800 raise result
801
802 # If there was an error and the caller requested that errors be reported799 # If there was an error and the caller requested that errors be reported
803 # to the on_error callable, then do so.800 # to the on_error callable, then do so.
804 if result != "success" and on_error is not None:801 if result != "success" and on_error is not None:
805 on_error(1)802 on_error(1)
803
804 if isinstance(result, SystemExit):
805 raise result
806
806 return result807 return result
807808
808809
@@ -883,6 +884,21 @@ def registration_info_text(config, registration_status):
883 return text884 return text
884885
885886
887def set_secure_id(config, new_id):
888 """Persists a secure id in the identity data file. This is used to indicate
889 whether we are currently in the process of registering.
890 """
891 persist = Persist(
892 filename=os.path.join(
893 config.data_path,
894 f"{BrokerService.service_name}.bpickle",
895 ),
896 )
897 identity = Identity(config, persist)
898 identity.secure_id = new_id
899 persist.save()
900
901
886def main(args, print=print):902def main(args, print=print):
887 """Interact with the user and the server to set up client configuration."""903 """Interact with the user and the server to set up client configuration."""
888904
@@ -927,7 +943,11 @@ def main(args, print=print):
927 # Attempt to register the client.943 # Attempt to register the client.
928 reactor = LandscapeReactor()944 reactor = LandscapeReactor()
929 if config.silent:945 if config.silent:
930 result = register(config, reactor)946 result = register(
947 config,
948 reactor,
949 on_error=lambda _: set_secure_id(config, None),
950 )
931 report_registration_outcome(result, print=print)951 report_registration_outcome(result, print=print)
932 sys.exit(determine_exit_code(result))952 sys.exit(determine_exit_code(result))
933 else:953 else:
@@ -937,6 +957,10 @@ def main(args, print=print):
937 default=default_answer,957 default=default_answer,
938 )958 )
939 if answer:959 if answer:
940 result = register(config, reactor)960 result = register(
961 config,
962 reactor,
963 on_error=lambda _: set_secure_id(config, None),
964 )
941 report_registration_outcome(result, print=print)965 report_registration_outcome(result, print=print)
942 sys.exit(determine_exit_code(result))966 sys.exit(determine_exit_code(result))
diff --git a/landscape/client/deployment.py b/landscape/client/deployment.py
index db1f110..28bc830 100644
--- a/landscape/client/deployment.py
+++ b/landscape/client/deployment.py
@@ -1,13 +1,23 @@
1import json
1import os.path2import os.path
3import subprocess
2import sys4import sys
5from datetime import datetime
6from datetime import timezone
3from optparse import SUPPRESS_HELP7from optparse import SUPPRESS_HELP
48
5from twisted.logger import globalLogBeginner9from twisted.logger import globalLogBeginner
610
7from landscape import VERSION11from landscape import VERSION
12from landscape.client import DEFAULT_CONFIG
13from landscape.client import snap_http
14from landscape.client.snap_utils import get_snap_info
8from landscape.client.upgraders import UPGRADE_MANAGERS15from landscape.client.upgraders import UPGRADE_MANAGERS
9from landscape.lib import logging16from landscape.lib import logging
10from landscape.lib.config import BaseConfiguration as _BaseConfiguration17from landscape.lib.config import BaseConfiguration as _BaseConfiguration
18from landscape.lib.format import expandvars
19from landscape.lib.network import get_active_device_info
20from landscape.lib.network import get_fqdn
11from landscape.lib.persist import Persist21from landscape.lib.persist import Persist
1222
1323
@@ -37,7 +47,7 @@ class BaseConfiguration(_BaseConfiguration):
3747
38 version = VERSION48 version = VERSION
3949
40 default_config_filename = "/etc/landscape/client.conf"50 default_config_filename = DEFAULT_CONFIG
41 if _is_script():51 if _is_script():
42 default_config_filenames = (52 default_config_filenames = (
43 "landscape-client.conf",53 "landscape-client.conf",
@@ -188,6 +198,25 @@ class Configuration(BaseConfiguration):
188 backwards-compatibility."""198 backwards-compatibility."""
189 return os.path.join(self.data_path, "juju-info.json")199 return os.path.join(self.data_path, "juju-info.json")
190200
201 def auto_configure(self):
202 """Automatically configure the client snap."""
203 client_conf = snap_http.get_conf("landscape-client").result
204 auto_enroll_conf = client_conf.get("auto-register", {})
205
206 enabled = auto_enroll_conf.get("enabled", False)
207 configured = auto_enroll_conf.get("configured", False)
208 if not enabled or configured:
209 return
210
211 title = generate_computer_title(auto_enroll_conf)
212 if title:
213 self.computer_title = title
214 self.write()
215
216 auto_enroll_conf["configured"] = True
217 client_conf["auto-register"] = auto_enroll_conf
218 snap_http.set_conf("landscape-client", client_conf)
219
191220
192def get_versioned_persist(service):221def get_versioned_persist(service):
193 """Get a L{Persist} database with upgrade rules applied.222 """Get a L{Persist} database with upgrade rules applied.
@@ -203,3 +232,52 @@ def get_versioned_persist(service):
203 upgrade_manager.initialize(persist)232 upgrade_manager.initialize(persist)
204 persist.save(service.persist_filename)233 persist.save(service.persist_filename)
205 return persist234 return persist
235
236
237def generate_computer_title(auto_enroll_config):
238 """Generate the computer title.
239
240 This follows the LA017 specification and falls back to `hostname`
241 if generating the title fails due to missing data.
242 """
243 snap_info = get_snap_info()
244 wait_for_serial = auto_enroll_config.get("wait-for-serial-as", True)
245 if "serial" not in snap_info and wait_for_serial:
246 return
247
248 hostname = get_fqdn()
249 wait_for_hostname = auto_enroll_config.get("wait-for-hostname", False)
250 if hostname == "localhost" and wait_for_hostname:
251 return
252
253 nics = get_active_device_info(default_only=True)
254 nic = nics[0] if nics else {}
255
256 lshw = subprocess.run(
257 ["lshw", "-json", "-quiet", "-c", "system"],
258 capture_output=True,
259 text=True,
260 )
261 hardware = json.loads(lshw.stdout)[0]
262
263 computer_title_pattern = auto_enroll_config.get(
264 "computer-title-pattern",
265 "${hostname}",
266 )
267 title = expandvars(
268 computer_title_pattern,
269 serial=snap_info.get("serial", ""),
270 model=snap_info.get("model", ""),
271 brand=snap_info.get("brand", ""),
272 hostname=hostname,
273 ip=nic.get("ip_address", ""),
274 mac=nic.get("mac_address", ""),
275 prodiden=hardware.get("product", ""),
276 serialno=hardware.get("serial", ""),
277 datetime=datetime.now(timezone.utc),
278 )
279
280 if title == "": # on the off-chance substitute values are missing
281 title = hostname
282
283 return title
diff --git a/landscape/client/manager/aptsources.py b/landscape/client/manager/aptsources.py
index baa641f..179fa72 100644
--- a/landscape/client/manager/aptsources.py
+++ b/landscape/client/manager/aptsources.py
@@ -8,6 +8,8 @@ import uuid
88
9from twisted.internet.defer import succeed9from twisted.internet.defer import succeed
1010
11from landscape.client import GROUP
12from landscape.client import USER
11from landscape.client.manager.plugin import ManagerPlugin13from landscape.client.manager.plugin import ManagerPlugin
12from landscape.client.package.reporter import find_reporter_command14from landscape.client.package.reporter import find_reporter_command
13from landscape.constants import FALSE_VALUES15from landscape.constants import FALSE_VALUES
@@ -167,8 +169,8 @@ class AptSources(ManagerPlugin):
167 args.append(f"--config={self.registry.config.config}")169 args.append(f"--config={self.registry.config.config}")
168170
169 if os.getuid() == 0:171 if os.getuid() == 0:
170 uid = pwd.getpwnam("landscape").pw_uid172 uid = pwd.getpwnam(USER).pw_uid
171 gid = grp.getgrnam("landscape").gr_gid173 gid = grp.getgrnam(GROUP).gr_gid
172 else:174 else:
173 uid = None175 uid = None
174 gid = None176 gid = None
diff --git a/landscape/client/manager/config.py b/landscape/client/manager/config.py
index 2f8df8b..f89ebbc 100644
--- a/landscape/client/manager/config.py
+++ b/landscape/client/manager/config.py
@@ -13,6 +13,8 @@ ALL_PLUGINS = [
13 "HardwareInfo",13 "HardwareInfo",
14 "KeystoneToken",14 "KeystoneToken",
15 "SnapManager",15 "SnapManager",
16 "SnapServicesManager",
17 "UbuntuProInfo",
16]18]
1719
1820
diff --git a/landscape/client/manager/hardwareinfo.py b/landscape/client/manager/hardwareinfo.py
index b475ee5..35b504d 100644
--- a/landscape/client/manager/hardwareinfo.py
+++ b/landscape/client/manager/hardwareinfo.py
@@ -12,7 +12,7 @@ class HardwareInfo(ManagerPlugin):
12 message_type = "hardware-info"12 message_type = "hardware-info"
13 run_interval = 60 * 60 * 2413 run_interval = 60 * 60 * 24
14 run_immediately = True14 run_immediately = True
15 command = "/usr/bin/lshw"15 command = "lshw"
1616
17 def register(self, registry):17 def register(self, registry):
18 super().register(registry)18 super().register(registry)
diff --git a/landscape/client/manager/scriptexecution.py b/landscape/client/manager/scriptexecution.py
index ac591d7..6856185 100644
--- a/landscape/client/manager/scriptexecution.py
+++ b/landscape/client/manager/scriptexecution.py
@@ -18,6 +18,7 @@ from twisted.internet.protocol import ProcessProtocol
18from twisted.python.compat import unicode18from twisted.python.compat import unicode
1919
20from landscape import VERSION20from landscape import VERSION
21from landscape.client import IS_SNAP
21from landscape.client.manager.plugin import FAILED22from landscape.client.manager.plugin import FAILED
22from landscape.client.manager.plugin import ManagerPlugin23from landscape.client.manager.plugin import ManagerPlugin
23from landscape.client.manager.plugin import SUCCEEDED24from landscape.client.manager.plugin import SUCCEEDED
@@ -85,6 +86,7 @@ class ScriptRunnerMixin:
85 if process_factory is None:86 if process_factory is None:
86 from twisted.internet import reactor as process_factory87 from twisted.internet import reactor as process_factory
87 self.process_factory = process_factory88 self.process_factory = process_factory
89 self.IS_SNAP = IS_SNAP
8890
89 def is_user_allowed(self, user):91 def is_user_allowed(self, user):
90 allowed_users = self.registry.config.get_allowed_script_users()92 allowed_users = self.registry.config.get_allowed_script_users()
@@ -96,8 +98,9 @@ class ScriptRunnerMixin:
96 # It would be nice to use fchown(2) and fchmod(2), but they're not98 # It would be nice to use fchown(2) and fchmod(2), but they're not
97 # available in python and using it with ctypes is pretty tedious, not99 # available in python and using it with ctypes is pretty tedious, not
98 # to mention we can't get errno.100 # to mention we can't get errno.
101 # Don't attempt to change file owner if the client is a snap
99 os.chmod(filename, 0o700)102 os.chmod(filename, 0o700)
100 if uid is not None:103 if not self.IS_SNAP and uid is not None:
101 os.chown(filename, uid, gid)104 os.chown(filename, uid, gid)
102105
103 script = build_script(shell, code)106 script = build_script(shell, code)
@@ -172,7 +175,8 @@ class ScriptExecutionPlugin(ManagerPlugin, ScriptRunnerMixin):
172 def _handle_execute_script(self, message):175 def _handle_execute_script(self, message):
173 opid = message["operation-id"]176 opid = message["operation-id"]
174 try:177 try:
175 user = message["username"]178 user = message["username"] if not self.IS_SNAP else "root"
179
176 if not self.is_user_allowed(user):180 if not self.is_user_allowed(user):
177 return self._respond(181 return self._respond(
178 FAILED,182 FAILED,
@@ -245,11 +249,11 @@ class ScriptExecutionPlugin(ManagerPlugin, ScriptRunnerMixin):
245 full_filename = os.path.join(attachment_dir, filename)249 full_filename = os.path.join(attachment_dir, filename)
246 with open(full_filename, "wb") as attachment:250 with open(full_filename, "wb") as attachment:
247 os.chmod(full_filename, 0o600)251 os.chmod(full_filename, 0o600)
248 if uid is not None:252 if not self.IS_SNAP and uid is not None:
249 os.chown(full_filename, uid, gid)253 os.chown(full_filename, uid, gid)
250 attachment.write(data)254 attachment.write(data)
251 os.chmod(attachment_dir, 0o700)255 os.chmod(attachment_dir, 0o700)
252 if uid is not None:256 if not self.IS_SNAP and uid is not None:
253 os.chown(attachment_dir, uid, gid)257 os.chown(attachment_dir, uid, gid)
254 returnValue(attachment_dir)258 returnValue(attachment_dir)
255259
@@ -296,9 +300,15 @@ class ScriptExecutionPlugin(ManagerPlugin, ScriptRunnerMixin):
296 "USER": user or "",300 "USER": user or "",
297 "HOME": path or "",301 "HOME": path or "",
298 }302 }
299 for locale_var in ("LANG", "LC_ALL", "LC_CTYPE"):303 for env_var in (
300 if locale_var in os.environ:304 "LANG",
301 env[locale_var] = os.environ[locale_var]305 "LC_ALL",
306 "LC_CTYPE",
307 "LD_LIBRARY_PATH",
308 "PYTHONPATH",
309 ):
310 if env_var in os.environ:
311 env[env_var] = os.environ[env_var]
302 if server_supplied_env:312 if server_supplied_env:
303 env.update(server_supplied_env)313 env.update(server_supplied_env)
304 old_umask = os.umask(0o022)314 old_umask = os.umask(0o022)
diff --git a/landscape/client/manager/service.py b/landscape/client/manager/service.py
index 7690442..c289685 100644
--- a/landscape/client/manager/service.py
+++ b/landscape/client/manager/service.py
@@ -1,3 +1,5 @@
1import logging
2
1from twisted.python.reflect import namedClass3from twisted.python.reflect import namedClass
24
3from landscape.client.amp import ComponentPublisher5from landscape.client.amp import ComponentPublisher
@@ -28,13 +30,26 @@ class ManagerService(LandscapeService):
2830
29 def get_plugins(self):31 def get_plugins(self):
30 """Return instances of all the plugins enabled in the configuration."""32 """Return instances of all the plugins enabled in the configuration."""
31 return [33 plugins = []
32 namedClass(34
33 "landscape.client.manager."35 for plugin_name in self.config.plugin_factories:
34 f"{plugin_name.lower()}.{plugin_name}",36 try:
35 )()37 plugin = namedClass(
36 for plugin_name in self.config.plugin_factories38 "landscape.client.manager."
37 ]39 f"{plugin_name.lower()}.{plugin_name}",
40 )
41 plugins.append(plugin())
42 except ModuleNotFoundError:
43 logging.warning(
44 f"Invalid manager plugin specified: '{plugin_name}'"
45 "See `example.conf` for a full list of monitor plugins.",
46 )
47 except Exception as exc:
48 logging.warning(
49 f"Unable to load manager plugin '{plugin_name}': {exc}",
50 )
51
52 return plugins
3853
39 def startService(self): # noqa: N80254 def startService(self): # noqa: N802
40 """Start the manager service.55 """Start the manager service.
diff --git a/landscape/client/manager/shutdownmanager.py b/landscape/client/manager/shutdownmanager.py
index eba2be7..bd38533 100644
--- a/landscape/client/manager/shutdownmanager.py
+++ b/landscape/client/manager/shutdownmanager.py
@@ -1,94 +1,106 @@
1import logging1import logging
22
3from twisted.internet.defer import Deferred3import dbus
4from twisted.internet.error import ProcessDone4from twisted.internet import reactor
5from twisted.internet.protocol import ProcessProtocol5from twisted.internet import task
66
7from landscape.client.manager.plugin import FAILED7from landscape.client.manager.plugin import FAILED
8from landscape.client.manager.plugin import ManagerPlugin8from landscape.client.manager.plugin import ManagerPlugin
9from landscape.client.manager.plugin import SUCCEEDED9from landscape.client.manager.plugin import SUCCEEDED
1010
1111
12class ShutdownFailedError(Exception):12class ShutdownManager(ManagerPlugin):
13 """Raised when a call to C{/sbin/shutdown} fails.
14
15 @ivar data: The data that the process printed before failing.
16 """13 """
14 Plugin that either shuts down or reboots the device.
1715
18 def __init__(self, data):16 In both cases, the manager sends the success command
19 self.data = data17 before attempting the shutdown/reboot.
18 With reboot - the call is instanteous but the success
19 message will be send as soon as the device comes back up.
2020
21 For shutdown there is a 120 second delay between
22 sending the success and firing the shutdown.
23 This is usually sufficent.
24 """
2125
22class ShutdownManager(ManagerPlugin):26 def __init__(self, dbus_provider=None, shutdown_delay=120):
23 def __init__(self, process_factory=None):27 if dbus_provider is None:
24 if process_factory is None:28 self.dbus_sysbus = dbus.SystemBus()
25 from twisted.internet import reactor as process_factory29 else:
26 self._process_factory = process_factory30 self.dbus_sysbus = dbus_provider.SystemBus()
2731
28 def register(self, registry):32 self.shutdown_delay = shutdown_delay
29 """Add this plugin to C{registry}.
3033
31 The shutdown manager handles C{shutdown} activity messages broadcast34 def register(self, registry):
32 from the server.
33 """
34 super().register(registry)35 super().register(registry)
35 registry.register_message("shutdown", self.perform_shutdown)36 self.config = registry.config
3637
37 def perform_shutdown(self, message):38 registry.register_message("shutdown", self._handle_shutdown)
38 """Request a system restart or shutdown.
3939
40 If the call to C{/sbin/shutdown} runs without errors the activity40 def _handle_shutdown(self, message, DBus_System_Bus=None):
41 specified in the message will be responded as succeeded. Otherwise,41 """
42 it will be responded as failed.42 Choose shutdown or reboot
43 """43 """
44 operation_id = message["operation-id"]44 operation_id = message["operation-id"]
45 reboot = message["reboot"]45 reboot = message["reboot"]
46 protocol = ShutdownProcessProtocol()46
47 protocol.set_timeout(self.registry.reactor)47 if reboot:
48 protocol.result.addCallback(self._respond_success, operation_id)48 logging.info("Reboot Requested")
49 protocol.result.addErrback(self._respond_failure, operation_id, reboot)49 deferred = self._respond_reboot_success(
50 command, args = self._get_command_and_args(protocol, reboot)50 "Reboot requested of the system",
51 self._process_factory.spawnProcess(protocol, command, args=args)51 operation_id,
5252 )
53 def _respond_success(self, data, operation_id):53 return deferred
54 logging.info("Shutdown request succeeded.")54 else:
55 logging.info("Shutdown Requested")
56 deferred = self._respond_shutdown_success(
57 "Shutdown requested of the system",
58 operation_id,
59 )
60 return deferred
61
62 def _Reboot(self, _, Dbus_System_bus=None):
63 logging.info("Sending Reboot Command")
64
65 bus_object = self.dbus_sysbus.get_object(
66 "org.freedesktop.login1",
67 "/org/freedesktop/login1",
68 )
69 bus_object.Reboot(
70 True,
71 dbus_interface="org.freedesktop.login1.Manager",
72 )
73
74 def _Shutdown(self):
75 logging.info("Sending Shutdown Command")
76 bus_object = self.dbus_sysbus.get_object(
77 "org.freedesktop.login1",
78 "/org/freedesktop/login1",
79 )
80 bus_object.PowerOff(
81 True,
82 dbus_interface="org.freedesktop.login1.Manager",
83 )
84
85 def _respond_reboot_success(self, data, operation_id):
55 deferred = self._respond(SUCCEEDED, data, operation_id)86 deferred = self._respond(SUCCEEDED, data, operation_id)
56 # After sending the result to the server, stop accepting messages and87 deferred.addCallback(self._Reboot)
57 # wait for the reboot/shutdown.88 deferred.addErrback(self._respond_fail)
58 deferred.addCallback(lambda _: self.registry.broker.stop_exchanger())
59 return deferred89 return deferred
6090
61 def _respond_failure(self, failure, operation_id, reboot):91 def _respond_shutdown_success(self, data, operation_id):
62 logging.info("Shutdown request failed.")92 deferred = self._respond(SUCCEEDED, data, operation_id)
63 failure_report = "\n".join(93 self.shutdown_deferred = task.deferLater(
64 [94 reactor,
65 failure.value.data,95 self.shutdown_delay,
66 "",96 self._Shutdown,
67 "Attempting to force {operation}. Please note that if this "
68 "succeeds, Landscape will have no way of knowing and will "
69 "still mark this activity as having failed. It is recommended "
70 "you check the state of the machine manually to determine "
71 "whether {operation} succeeded.".format(
72 operation="reboot" if reboot else "shutdown",
73 ),
74 ],
75 )
76 deferred = self._respond(FAILED, failure_report, operation_id)
77 # Add another callback spawning the poweroff or reboot command (which
78 # seem more reliable in aberrant situations like a post-trusty release
79 # upgrade where upstart has been replaced with systemd). If this
80 # succeeds, we won't have any opportunity to report it and if it fails
81 # we'll already have responded indicating we're attempting to force
82 # the operation so either way there's no sense capturing output
83 protocol = ProcessProtocol()
84 command, args = self._get_command_and_args(protocol, reboot, True)
85 deferred.addCallback(
86 lambda _: self._process_factory.spawnProcess(
87 protocol,
88 command,
89 args=args,
90 ),
91 )97 )
98 deferred.addErrback(self._respond_fail)
99 return deferred
100
101 def _respond_fail(self, data, operation_id):
102 logging.info("Shutdown/Reboot request failed.")
103 deferred = self._respond(FAILED, data, operation_id)
92 return deferred104 return deferred
93105
94 def _respond(self, status, data, operation_id):106 def _respond(self, status, data, operation_id):
@@ -103,93 +115,3 @@ class ShutdownManager(ManagerPlugin):
103 self._session_id,115 self._session_id,
104 True,116 True,
105 )117 )
106
107 def _get_command_and_args(self, protocol, reboot, force=False):
108 """
109 Returns a C{command, args} 2-tuple suitable for use with
110 L{IReactorProcess.spawnProcess}.
111 """
112 minutes = None if force else f"+{protocol.delay//60:d}"
113 args = {
114 (False, False): [
115 "/sbin/shutdown",
116 "-h",
117 minutes,
118 "Landscape is shutting down the system",
119 ],
120 (False, True): [
121 "/sbin/shutdown",
122 "-r",
123 minutes,
124 "Landscape is rebooting the system",
125 ],
126 (True, False): ["/sbin/poweroff"],
127 (True, True): ["/sbin/reboot"],
128 }[force, reboot]
129 return args[0], args
130
131
132class ShutdownProcessProtocol(ProcessProtocol):
133 """A ProcessProtocol for calling C{/sbin/shutdown}.
134
135 C{shutdown} doesn't return immediately when a time specification is
136 provided. Failures are reported immediately after it starts and return a
137 non-zero exit code. The process protocol calls C{shutdown} and waits for
138 failures for C{timeout} seconds. If no failures are reported it fires
139 C{result}'s callback with whatever output was received from the process.
140 If failures are reported C{result}'s errback is fired.
141
142 @ivar result: A L{Deferred} fired when C{shutdown} fails or
143 succeeds.
144 @ivar reboot: A flag indicating whether a shutdown or reboot should be
145 performed. Default is C{False}.
146 @ivar delay: The time in seconds from now to schedule the shutdown.
147 Default is 240 seconds. The time will be converted to minutes using
148 integer division when passed to C{shutdown}.
149 """
150
151 def __init__(self, reboot=False, delay=240):
152 self.result = Deferred()
153 self.reboot = reboot
154 self.delay = delay
155 self._data = []
156 self._waiting = True
157
158 def get_data(self):
159 """Get the data printed by the subprocess."""
160 return b"".join(self._data).decode("utf-8", "replace")
161
162 def set_timeout(self, reactor, timeout=10):
163 """
164 Set the error checking timeout, after which C{result}'s callback will
165 be fired.
166 """
167 reactor.call_later(timeout, self._succeed)
168
169 def childDataReceived(self, fd, data): # noqa: N802
170 """Some data was received from the child.
171
172 Add it to our buffer to pass to C{result} when it's fired.
173 """
174 if self._waiting:
175 self._data.append(data)
176
177 def processEnded(self, reason): # noqa: N802
178 """Fire back the C{result} L{Deferred}.
179
180 C{result}'s callback will be fired with the string of data received
181 from the subprocess, or if the subprocess failed C{result}'s errback
182 will be fired with the string of data received from the subprocess.
183 """
184 if self._waiting:
185 if reason.check(ProcessDone):
186 self._succeed()
187 else:
188 self.result.errback(ShutdownFailedError(self.get_data()))
189 self._waiting = False
190
191 def _succeed(self):
192 """Fire C{result}'s callback with data accumulated from the process."""
193 if self._waiting:
194 self.result.callback(self.get_data())
195 self._waiting = False
diff --git a/landscape/client/manager/snapmanager.py b/landscape/client/manager/snapmanager.py
index d34bafa..b4214c4 100644
--- a/landscape/client/manager/snapmanager.py
+++ b/landscape/client/manager/snapmanager.py
@@ -3,49 +3,22 @@ from collections import deque
33
4from twisted.internet import task4from twisted.internet import task
55
6from landscape.client import snap_http
6from landscape.client.manager.plugin import FAILED7from landscape.client.manager.plugin import FAILED
7from landscape.client.manager.plugin import ManagerPlugin8from landscape.client.manager.plugin import ManagerPlugin
8from landscape.client.manager.plugin import SUCCEEDED9from landscape.client.manager.plugin import SUCCEEDED
9from landscape.client.snap.http import INCOMPLETE_STATUSES10from landscape.client.snap_http import INCOMPLETE_STATUSES
10from landscape.client.snap.http import SnapdHttpException11from landscape.client.snap_http import SnapdHttpException
11from landscape.client.snap.http import SnapHttp12from landscape.client.snap_http import SUCCESS_STATUSES
12from landscape.client.snap.http import SUCCESS_STATUSES
1313
1414
15class SnapManager(ManagerPlugin):15class BaseSnapManager(ManagerPlugin):
16 """16 """Base class that provides machinery for snap manager tasks."""
17 Plugin that updates the state of snaps on this machine, installing,
18 removing, refreshing, enabling, and disabling them in response to messages.
19
20 Changes trigger SnapMonitor to send an updated state message immediately.
21 """
2217
23 def __init__(self):18 def __init__(self):
24 super().__init__()19 super().__init__()
2520
26 self._snap_http = SnapHttp()21 self.SNAP_METHODS = {}
27 self.SNAP_METHODS = {
28 "install-snaps": self._snap_http.install_snap,
29 "install-snaps-batch": self._snap_http.install_snaps,
30 "remove-snaps": self._snap_http.remove_snap,
31 "remove-snaps-batch": self._snap_http.remove_snaps,
32 "refresh-snaps": self._snap_http.refresh_snap,
33 "refresh-snaps-batch": self._snap_http.refresh_snaps,
34 "hold-snaps": self._snap_http.hold_snap,
35 "hold-snaps-batch": self._snap_http.hold_snaps,
36 "unhold-snaps": self._snap_http.unhold_snap,
37 "unhold-snaps-batch": self._snap_http.unhold_snaps,
38 }
39
40 def register(self, registry):
41 super().register(registry)
42 self.config = registry.config
43
44 registry.register_message("install-snaps", self._handle_snap_task)
45 registry.register_message("remove-snaps", self._handle_snap_task)
46 registry.register_message("refresh-snaps", self._handle_snap_task)
47 registry.register_message("hold-snaps", self._handle_snap_task)
48 registry.register_message("unhold-snaps", self._handle_snap_task)
4922
50 def _handle_snap_task(self, message):23 def _handle_snap_task(self, message):
51 """24 """
@@ -84,7 +57,7 @@ class SnapManager(ManagerPlugin):
84 snaps,57 snaps,
85 **snap_args,58 **snap_args,
86 )59 )
87 queue.append((response["change"], "BATCH"))60 queue.append((response.change, "BATCH"))
88 except SnapdHttpException as e:61 except SnapdHttpException as e:
89 result = e.json["result"]62 result = e.json["result"]
90 logging.error(63 logging.error(
@@ -128,7 +101,7 @@ class SnapManager(ManagerPlugin):
128 name,101 name,
129 **snap_args,102 **snap_args,
130 )103 )
131 queue.append((response["change"], name))104 queue.append((response.change, name))
132 except SnapdHttpException as e:105 except SnapdHttpException as e:
133 result = e.json["result"]106 result = e.json["result"]
134 logging.error(107 logging.error(
@@ -161,7 +134,7 @@ class SnapManager(ManagerPlugin):
161 logging.info("Polling snapd for status of pending snap changes")134 logging.info("Polling snapd for status of pending snap changes")
162135
163 try:136 try:
164 result = self._snap_http.check_changes().get("result", [])137 result = snap_http.check_changes().result
165 result_dict = {c["id"]: c for c in result}138 result_dict = {c["id"]: c for c in result}
166 except SnapdHttpException as e:139 except SnapdHttpException as e:
167 logging.error(f"Error checking status of snap changes: {e}")140 logging.error(f"Error checking status of snap changes: {e}")
@@ -206,7 +179,7 @@ class SnapManager(ManagerPlugin):
206179
207 response = snap_method(*args, **kwargs)180 response = snap_method(*args, **kwargs)
208181
209 if "change" not in response:182 if response.change is None:
210 raise SnapdHttpException(response)183 raise SnapdHttpException(response)
211184
212 return response185 return response
@@ -243,17 +216,57 @@ class SnapManager(ManagerPlugin):
243216
244 logging.debug("Sending snap-action-done response")217 logging.debug("Sending snap-action-done response")
245218
246 # Kick off an immediate SnapMonitor message as well.219 # Kick off an immediate monitor message as well.
247 self._send_installed_snap_update()220 self._send_snap_update()
248 return self.registry.broker.send_message(221 return self.registry.broker.send_message(
249 message,222 message,
250 self._session_id,223 self._session_id,
251 True,224 True,
252 )225 )
253226
254 def _send_installed_snap_update(self):227 def _send_snap_update(self):
228 """Kick off an immediate monitor message."""
229
230
231class SnapManager(BaseSnapManager):
232 """
233 Plugin that updates the state of snaps on this machine, installing,
234 removing, refreshing, enabling, and disabling them in response to messages.
235
236 Changes trigger SnapMonitor to send an updated state message immediately.
237 """
238
239 def __init__(self):
240 super().__init__()
241
242 self.SNAP_METHODS = {
243 "install-snaps": snap_http.install,
244 "install-snaps-batch": snap_http.install_all,
245 "remove-snaps": snap_http.remove,
246 "remove-snaps-batch": snap_http.remove_all,
247 "refresh-snaps": snap_http.refresh,
248 "refresh-snaps-batch": snap_http.refresh_all,
249 "hold-snaps": snap_http.hold,
250 "hold-snaps-batch": snap_http.hold_all,
251 "unhold-snaps": snap_http.unhold,
252 "unhold-snaps-batch": snap_http.unhold_all,
253 "set-snap-config": snap_http.set_conf,
254 }
255
256 def register(self, registry):
257 super().register(registry)
258 self.config = registry.config
259
260 registry.register_message("install-snaps", self._handle_snap_task)
261 registry.register_message("remove-snaps", self._handle_snap_task)
262 registry.register_message("refresh-snaps", self._handle_snap_task)
263 registry.register_message("hold-snaps", self._handle_snap_task)
264 registry.register_message("unhold-snaps", self._handle_snap_task)
265 registry.register_message("set-snap-config", self._handle_snap_task)
266
267 def _send_snap_update(self):
255 try:268 try:
256 installed_snaps = self._snap_http.get_snaps()269 installed_snaps = snap_http.list().result
257 except SnapdHttpException as e:270 except SnapdHttpException as e:
258 logging.error(271 logging.error(
259 f"Unable to list installed snaps after snap change: {e}",272 f"Unable to list installed snaps after snap change: {e}",
@@ -264,7 +277,7 @@ class SnapManager(ManagerPlugin):
264 return self.registry.broker.send_message(277 return self.registry.broker.send_message(
265 {278 {
266 "type": "snaps",279 "type": "snaps",
267 "snaps": installed_snaps,280 "snaps": {"installed": installed_snaps},
268 },281 },
269 self._session_id,282 self._session_id,
270 True,283 True,
diff --git a/landscape/client/manager/snapservicesmanager.py b/landscape/client/manager/snapservicesmanager.py
271new file mode 100644284new file mode 100644
index 0000000..149e171
--- /dev/null
+++ b/landscape/client/manager/snapservicesmanager.py
@@ -0,0 +1,60 @@
1import logging
2
3from landscape.client import snap_http
4from landscape.client.manager.snapmanager import BaseSnapManager
5from landscape.client.snap_http import SnapdHttpException
6
7
8class SnapServicesManager(BaseSnapManager):
9 """
10 Plugin that updates the state of snap services on this machine, starting,
11 stopping, restarting, enabling, disabling, and reloading them in response
12 to messages.
13
14 Changes trigger SnapServicesMonitor to send an updated state message
15 immediately.
16 """
17
18 def __init__(self):
19 super().__init__()
20
21 self.SNAP_METHODS = {
22 "start-snap-service": snap_http.start,
23 "start-snap-service-batch": snap_http.start_all,
24 "stop-snap-service": snap_http.stop,
25 "stop-snap-service-batch": snap_http.stop_all,
26 "restart-snap-service": snap_http.restart,
27 "restart-snap-service-batch": snap_http.restart_all,
28 }
29
30 def register(self, registry):
31 super().register(registry)
32 self.config = registry.config
33
34 message_types = [
35 "start-snap-service",
36 "start-snap-service-batch",
37 "stop-snap-service",
38 "stop-snap-service-batch",
39 "restart-snap-service",
40 "restart-snap-service-batch",
41 ]
42 for msg_type in message_types:
43 registry.register_message(msg_type, self._handle_snap_task)
44
45 def _send_snap_update(self):
46 try:
47 services = snap_http.get_apps(services_only=True).result
48 except SnapdHttpException as e:
49 logging.error(f"Unable to list services: {e}")
50 return
51
52 if services:
53 return self.registry.broker.send_message(
54 {
55 "type": "snap-services",
56 "services": {"running": services},
57 },
58 self._session_id,
59 True,
60 )
diff --git a/landscape/client/manager/tests/test_aptsources.py b/landscape/client/manager/tests/test_aptsources.py
index 4903594..aa648be 100644
--- a/landscape/client/manager/tests/test_aptsources.py
+++ b/landscape/client/manager/tests/test_aptsources.py
@@ -456,3 +456,46 @@ class AptSourcesTests(LandscapeTest):
456 )456 )
457457
458 return deferred458 return deferred
459
460 def test_run_reporter_snap(self):
461 """After receiving a message, `AptSources` in a snap triggers a
462 reporter run as root to have the new packages reported to the server.
463 """
464 deferred = Deferred()
465
466 def _run_process(command, args, env={}, path=None, uid=None, gid=None):
467 self.assertEqual(
468 find_reporter_command(self.manager.config),
469 command,
470 )
471 self.assertEqual(
472 [
473 "--force-apt-update",
474 f"--config={self.manager.config.config}",
475 ],
476 args,
477 )
478 self.assertEqual(uid, 0)
479 self.assertEqual(gid, 0)
480 deferred.callback(("ok", "", 0))
481 return deferred
482
483 self.sourceslist._run_process = _run_process
484
485 with mock.patch.multiple(
486 "landscape.client.manager.aptsources",
487 USER="root",
488 GROUP="root",
489 ):
490 with mock.patch("os.getuid") as getuid:
491 getuid.return_value = 0
492 self.manager.dispatch_message(
493 {
494 "type": "apt-sources-replace",
495 "sources": [],
496 "gpg-keys": [],
497 "operation-id": 1,
498 },
499 )
500
501 return deferred
diff --git a/landscape/client/manager/tests/test_config.py b/landscape/client/manager/tests/test_config.py
index da0c052..085bd09 100644
--- a/landscape/client/manager/tests/test_config.py
+++ b/landscape/client/manager/tests/test_config.py
@@ -21,6 +21,8 @@ class ManagerConfigurationTest(LandscapeTest):
21 "HardwareInfo",21 "HardwareInfo",
22 "KeystoneToken",22 "KeystoneToken",
23 "SnapManager",23 "SnapManager",
24 "SnapServicesManager",
25 "UbuntuProInfo",
24 ],26 ],
25 ALL_PLUGINS,27 ALL_PLUGINS,
26 )28 )
diff --git a/landscape/client/manager/tests/test_processkiller.py b/landscape/client/manager/tests/test_processkiller.py
index ff906cd..f8408f8 100644
--- a/landscape/client/manager/tests/test_processkiller.py
+++ b/landscape/client/manager/tests/test_processkiller.py
@@ -99,7 +99,7 @@ class ProcessKillerTests(LandscapeTest):
99 signaller.register(self.manager)99 signaller.register(self.manager)
100 popen = get_active_process()100 popen = get_active_process()
101 process_info = process_info_factory.get_process_info(popen.pid)101 process_info = process_info_factory.get_process_info(popen.pid)
102 self.assertNotEquals(process_info, None)102 self.assertNotEqual(process_info, None)
103 start_time = process_info["start-time"]103 start_time = process_info["start-time"]
104104
105 self.manager.dispatch_message(105 self.manager.dispatch_message(
diff --git a/landscape/client/manager/tests/test_scriptexecution.py b/landscape/client/manager/tests/test_scriptexecution.py
index e560f49..043c80d 100644
--- a/landscape/client/manager/tests/test_scriptexecution.py
+++ b/landscape/client/manager/tests/test_scriptexecution.py
@@ -42,7 +42,13 @@ def get_default_environment():
42 "USER": username,42 "USER": username,
43 "HOME": home,43 "HOME": home,
44 }44 }
45 for var in {"LANG", "LC_ALL", "LC_CTYPE"}:45 for var in {
46 "LANG",
47 "LC_ALL",
48 "LC_CTYPE",
49 "LD_LIBRARY_PATH",
50 "PYTHONPATH",
51 }:
46 if var in os.environ:52 if var in os.environ:
47 env[var] = os.environ[var]53 env[var] = os.environ[var]
48 return env54 return env
@@ -415,9 +421,14 @@ class RunScriptTests(LandscapeTest):
415 result.addCallback(check)421 result.addCallback(check)
416 return result422 return result
417423
418 def _run_script(self, username, uid, gid, path):424 def _run_script(self, username, uid, gid, path, from_snap=None):
419 expected_uid = uid if uid != os.getuid() else None425
420 expected_gid = gid if gid != os.getgid() else None426 if from_snap:
427 expected_gid = None
428 expected_uid = None
429 else:
430 expected_uid = uid if uid != os.getuid() else None
431 expected_gid = gid if gid != os.getgid() else None
421432
422 factory = StubProcessFactory()433 factory = StubProcessFactory()
423 self.plugin.process_factory = factory434 self.plugin.process_factory = factory
@@ -426,6 +437,7 @@ class RunScriptTests(LandscapeTest):
426 patch_chown = mock.patch("os.chown")437 patch_chown = mock.patch("os.chown")
427 mock_chown = patch_chown.start()438 mock_chown = patch_chown.start()
428439
440 self.plugin.IS_SNAP = from_snap
429 result = self.plugin.run_script("/bin/sh", "echo hi", user=username)441 result = self.plugin.run_script("/bin/sh", "echo hi", user=username)
430442
431 self.assertEqual(len(factory.spawns), 1)443 self.assertEqual(len(factory.spawns), 1)
@@ -441,14 +453,18 @@ class RunScriptTests(LandscapeTest):
441 protocol.processEnded(Failure(ProcessDone(0)))453 protocol.processEnded(Failure(ProcessDone(0)))
442454
443 def check(result):455 def check(result):
444 mock_chown.assert_called_with()456 if from_snap:
457 mock_chown.assert_not_called()
458 else:
459 mock_chown.assert_called()
460
445 self.assertEqual(result, "foobar")461 self.assertEqual(result, "foobar")
446462
447 def cleanup(result):463 def cleanup(result):
448 patch_chown.stop()464 patch_chown.stop()
449 return result465 return result
450466
451 return result.addErrback(check).addBoth(cleanup)467 return result.addCallback(check).addBoth(cleanup)
452468
453 def test_user(self):469 def test_user(self):
454 """470 """
@@ -463,7 +479,28 @@ class RunScriptTests(LandscapeTest):
463 gid = info.pw_gid479 gid = info.pw_gid
464 path = info.pw_dir480 path = info.pw_dir
465481
466 return self._run_script(username, uid, gid, path)482 if not os.path.exists(path):
483 path = "/"
484
485 return self._run_script(username, uid, gid, path, from_snap=None)
486
487 def test_user_from_snap(self):
488 """
489 Running a script as a particular user calls
490 C{IReactorProcess.spawnProcess} with an appropriate C{uid} argument,
491 with the user's primary group as the C{gid} argument and with the user
492 home as C{path} argument.
493 """
494 uid = os.getuid()
495 info = pwd.getpwuid(uid)
496 username = info.pw_name
497 gid = info.pw_gid
498 path = info.pw_dir
499
500 if not os.path.exists(path):
501 path = "/"
502
503 return self._run_script(username, uid, gid, path, from_snap=True)
467504
468 def test_user_no_home(self):505 def test_user_no_home(self):
469 """506 """
diff --git a/landscape/client/manager/tests/test_service.py b/landscape/client/manager/tests/test_service.py
index 91b0211..44b2a32 100644
--- a/landscape/client/manager/tests/test_service.py
+++ b/landscape/client/manager/tests/test_service.py
@@ -1,3 +1,5 @@
1from unittest import mock
2
1from landscape.client.manager.config import ALL_PLUGINS3from landscape.client.manager.config import ALL_PLUGINS
2from landscape.client.manager.config import ManagerConfiguration4from landscape.client.manager.config import ManagerConfiguration
3from landscape.client.manager.processkiller import ProcessKiller5from landscape.client.manager.processkiller import ProcessKiller
@@ -11,22 +13,31 @@ class ManagerServiceTest(LandscapeTest):
1113
12 helpers = [FakeBrokerServiceHelper]14 helpers = [FakeBrokerServiceHelper]
1315
16 class FakeManagerService(ManagerService):
17 reactor_factory = FakeReactor
18
14 def setUp(self):19 def setUp(self):
15 super().setUp()20 super().setUp()
16 config = ManagerConfiguration()21 config = ManagerConfiguration()
17 config.load(["-c", self.config_filename])22 config.load(["-c", self.config_filename])
1823
19 class FakeManagerService(ManagerService):24 self.service = self.FakeManagerService(config)
20 reactor_factory = FakeReactor
2125
22 self.service = FakeManagerService(config)26 @mock.patch("dbus.SystemBus")
2327 def test_plugins(self, system_bus_mock):
24 def test_plugins(self):
25 """28 """
26 By default the L{ManagerService.plugins} list holds an instance of29 By default the L{ManagerService.plugins} list holds an instance of
27 every enabled manager plugin.30 every enabled manager plugin.
31
32 We mock `dbus` because in some build environments that run these tests,
33 such as buildd, SystemBus is not available.
28 """34 """
29 self.assertEqual(len(self.service.plugins), len(ALL_PLUGINS))35 config = ManagerConfiguration()
36 config.load(["-c", self.config_filename])
37 service = self.FakeManagerService(config)
38
39 self.assertEqual(len(service.plugins), len(ALL_PLUGINS))
40 system_bus_mock.assert_called_once_with()
3041
31 def test_get_plugins(self):42 def test_get_plugins(self):
32 """43 """
@@ -37,6 +48,34 @@ class ManagerServiceTest(LandscapeTest):
37 [plugin] = self.service.get_plugins()48 [plugin] = self.service.get_plugins()
38 self.assertTrue(isinstance(plugin, ProcessKiller))49 self.assertTrue(isinstance(plugin, ProcessKiller))
3950
51 def test_get_plugins_module_not_found(self):
52 """If a module is not found, a warning is logged."""
53 self.service.config.load(["--manager-plugins", "TotallyDoesNotExist"])
54
55 with self.assertLogs(level="WARN") as cm:
56 plugins = self.service.get_plugins()
57
58 self.assertEqual(len(plugins), 0)
59 self.assertIn("Invalid manager plugin", cm.output[0])
60 self.assertIn("TotallyDoesNotExist", cm.output[0])
61
62 def test_get_plugins_other_exception(self):
63 """If loading a plugin fails for another reason, a warning is logged,
64 with the exception.
65 """
66 self.service.config.load(["--manager-plugins", "ProcessKiller"])
67
68 with self.assertLogs(level="WARN") as cm:
69 with mock.patch(
70 "landscape.client.manager.service.namedClass",
71 ) as namedClass:
72 namedClass.side_effect = Exception("Is there life on Mars?")
73 plugins = self.service.get_plugins()
74
75 self.assertEqual(len(plugins), 0)
76 self.assertIn("Unable to load", cm.output[0])
77 self.assertIn("Mars?", cm.output[0])
78
40 def test_start_service(self):79 def test_start_service(self):
41 """80 """
42 The L{ManagerService.startService} method connects to the broker,81 The L{ManagerService.startService} method connects to the broker,
diff --git a/landscape/client/manager/tests/test_shutdownmanager.py b/landscape/client/manager/tests/test_shutdownmanager.py
index 4d2a28e..005b0cc 100644
--- a/landscape/client/manager/tests/test_shutdownmanager.py
+++ b/landscape/client/manager/tests/test_shutdownmanager.py
@@ -1,15 +1,10 @@
1from twisted.internet.error import ProcessDone1from unittest.mock import Mock
2from twisted.internet.error import ProcessTerminated2
3from twisted.internet.protocol import ProcessProtocol3import twisted.internet.defer
4from twisted.python.failure import Failure
54
6from landscape.client.manager.plugin import FAILED
7from landscape.client.manager.plugin import SUCCEEDED
8from landscape.client.manager.shutdownmanager import ShutdownManager5from landscape.client.manager.shutdownmanager import ShutdownManager
9from landscape.client.manager.shutdownmanager import ShutdownProcessProtocol
10from landscape.client.tests.helpers import LandscapeTest6from landscape.client.tests.helpers import LandscapeTest
11from landscape.client.tests.helpers import ManagerHelper7from landscape.client.tests.helpers import ManagerHelper
12from landscape.lib.testing import StubProcessFactory
138
149
15class ShutdownManagerTest(LandscapeTest):10class ShutdownManagerTest(LandscapeTest):
@@ -18,179 +13,40 @@ class ShutdownManagerTest(LandscapeTest):
1813
19 def setUp(self):14 def setUp(self):
20 super().setUp()15 super().setUp()
16
21 self.broker_service.message_store.set_accepted_types(17 self.broker_service.message_store.set_accepted_types(
22 ["shutdown", "operation-result"],18 ["shutdown", "operation-result"],
23 )19 )
24 self.broker_service.pinger.start()20 self.broker_service.pinger.start()
25 self.process_factory = StubProcessFactory()21
26 self.plugin = ShutdownManager(process_factory=self.process_factory)22 self.dbus_mock = Mock()
23 self.dbus_sysbus_mock = Mock()
24 self.dbus_mock.get_object.return_value = self.dbus_sysbus_mock
25 self.plugin = ShutdownManager(self.dbus_mock, shutdown_delay=0)
27 self.manager.add(self.plugin)26 self.manager.add(self.plugin)
2827
29 def test_restart(self):28 def test_reboot(self):
30 """
31 C{shutdown} processes run until the shutdown is to be performed. The
32 L{ShutdownProcessProtocol} watches a process for errors, for 10
33 seconds by default, and if none occur the activity is marked as
34 L{SUCCEEDED}. Data printed by the process is included in the
35 activity's result text.
36 """
37 message = {"type": "shutdown", "reboot": True, "operation-id": 100}29 message = {"type": "shutdown", "reboot": True, "operation-id": 100}
38 self.plugin.perform_shutdown(message)30 deferred = self.plugin._handle_shutdown(message)
39 [arguments] = self.process_factory.spawns
40 protocol = arguments[0]
41 self.assertTrue(isinstance(protocol, ShutdownProcessProtocol))
42 self.assertEqual(
43 arguments[1:3],
44 (
45 "/sbin/shutdown",
46 [
47 "/sbin/shutdown",
48 "-r",
49 "+4",
50 "Landscape is rebooting the system",
51 ],
52 ),
53 )
5431
55 def restart_performed(ignore):32 def check(_):
56 self.assertTrue(self.broker_service.exchanger.is_urgent())33 self.plugin.dbus_sysbus.get_object.assert_called_once()
57 self.assertEqual(34 self.plugin.dbus_sysbus.get_object().Reboot.assert_called_once()
58 self.broker_service.message_store.get_pending_messages(),
59 [
60 {
61 "type": "operation-result",
62 "api": b"3.2",
63 "operation-id": 100,
64 "timestamp": 10,
65 "status": SUCCEEDED,
66 "result-text": "Data may arrive in batches.",
67 },
68 ],
69 )
7035
71 protocol.result.addCallback(restart_performed)36 deferred.addCallback(check)
72 protocol.childDataReceived(0, b"Data may arrive ")37 return deferred
73 protocol.childDataReceived(0, b"in batches.")
74 # We need to advance both reactors to simulate that fact they
75 # are loosely in sync with each other
76 self.broker_service.reactor.advance(10)
77 self.manager.reactor.advance(10)
78 return protocol.result
7938
80 def test_shutdown(self):39 def test_shutdown(self):
81 """
82 C{shutdown} messages have a flag that indicates whether a reboot or
83 shutdown has been requested. The C{shutdown} command is called
84 appropriately.
85 """
86 message = {"type": "shutdown", "reboot": False, "operation-id": 100}40 message = {"type": "shutdown", "reboot": False, "operation-id": 100}
87 self.plugin.perform_shutdown(message)41 deferred = self.plugin._handle_shutdown(message)
88 [arguments] = self.process_factory.spawns
89 self.assertEqual(
90 arguments[1:3],
91 (
92 "/sbin/shutdown",
93 [
94 "/sbin/shutdown",
95 "-h",
96 "+4",
97 "Landscape is shutting down the system",
98 ],
99 ),
100 )
101
102 def test_restart_fails(self):
103 """
104 If an error occurs before the error checking timeout the activity will
105 be failed. Data printed by the process prior to the failure is
106 included in the activity's result text.
107 """
108 message = {"type": "shutdown", "reboot": True, "operation-id": 100}
109 self.plugin.perform_shutdown(message)
110
111 def restart_failed(message_id):
112 self.assertTrue(self.broker_service.exchanger.is_urgent())
113 messages = self.broker_service.message_store.get_pending_messages()
114 self.assertEqual(len(messages), 1)
115 message = messages[0]
116 self.assertEqual(message["type"], "operation-result")
117 self.assertEqual(message["api"], b"3.2")
118 self.assertEqual(message["operation-id"], 100)
119 self.assertEqual(message["timestamp"], 0)
120 self.assertEqual(message["status"], FAILED)
121 self.assertIn("Failure text is reported.", message["result-text"])
122
123 # Check that after failing, we attempt to force the shutdown by
124 # switching the binary called
125 [spawn1_args, spawn2_args] = self.process_factory.spawns
126 protocol = spawn2_args[0]
127 self.assertIsInstance(protocol, ProcessProtocol)
128 self.assertEqual(
129 spawn2_args[1:3],
130 ("/sbin/reboot", ["/sbin/reboot"]),
131 )
132
133 [arguments] = self.process_factory.spawns
134 protocol = arguments[0]
135 protocol.result.addCallback(restart_failed)
136 protocol.childDataReceived(0, b"Failure text is reported.")
137 protocol.processEnded(Failure(ProcessTerminated(exitCode=1)))
138 return protocol.result
13942
140 def test_process_ends_after_timeout(self):43 def check(_):
141 """44 self.plugin.dbus_sysbus.get_object.assert_called_once()
142 If the process ends after the error checking timeout has passed45 self.plugin.dbus_sysbus.get_object().PowerOff.assert_called_once()
143 C{result} will not be re-fired.
144 """
145 message = {"type": "shutdown", "reboot": False, "operation-id": 100}
146 self.plugin.perform_shutdown(message)
147
148 stash = []
149
150 def restart_performed(ignore):
151 self.assertEqual(stash, [])
152 stash.append(True)
153
154 [arguments] = self.process_factory.spawns
155 protocol = arguments[0]
156 protocol.result.addCallback(restart_performed)
157 self.manager.reactor.advance(10)
158 protocol.processEnded(Failure(ProcessTerminated(exitCode=1)))
159 return protocol.result
160
161 def test_process_data_is_not_collected_after_firing_result(self):
162 """
163 Data printed in the sub-process is not collected after C{result} has
164 been fired.
165 """
166 message = {"type": "shutdown", "reboot": False, "operation-id": 100}
167 self.plugin.perform_shutdown(message)
168
169 [arguments] = self.process_factory.spawns
170 protocol = arguments[0]
171 protocol.childDataReceived(0, b"Data may arrive ")
172 protocol.childDataReceived(0, b"in batches.")
173 self.manager.reactor.advance(10)
174 self.assertEqual(protocol.get_data(), "Data may arrive in batches.")
175 protocol.childDataReceived(0, b"Even when you least expect it.")
176 self.assertEqual(protocol.get_data(), "Data may arrive in batches.")
177
178 def test_restart_stops_exchanger(self):
179 """
180 After a successful shutdown, the broker stops processing new messages.
181 """
182 message = {"type": "shutdown", "reboot": False, "operation-id": 100}
183 self.plugin.perform_shutdown(message)
18446
185 [arguments] = self.process_factory.spawns47 self.plugin.shutdown_deferred.addCallback(check)
186 protocol = arguments[0]48 return deferred
187 protocol.processEnded(Failure(ProcessDone(status=0)))
188 self.broker_service.reactor.advance(100)
189 self.manager.reactor.advance(100)
19049
191 # New messages will not be exchanged after a reboot process is in50 def test_shutdown_failed(self):
192 # process.51 deferred = self.plugin._respond_fail("", 100)
193 self.manager.broker.exchanger.schedule_exchange()52 self.assertIsInstance(deferred, twisted.internet.defer.Deferred)
194 payloads = self.manager.broker.exchanger._transport.payloads
195 self.assertEqual(0, len(payloads))
196 return protocol.result
diff --git a/landscape/client/manager/tests/test_snapmanager.py b/landscape/client/manager/tests/test_snapmanager.py
index 1ce52f1..70cd6c3 100644
--- a/landscape/client/manager/tests/test_snapmanager.py
+++ b/landscape/client/manager/tests/test_snapmanager.py
@@ -3,8 +3,8 @@ from unittest import mock
3from landscape.client.manager.manager import FAILED3from landscape.client.manager.manager import FAILED
4from landscape.client.manager.manager import SUCCEEDED4from landscape.client.manager.manager import SUCCEEDED
5from landscape.client.manager.snapmanager import SnapManager5from landscape.client.manager.snapmanager import SnapManager
6from landscape.client.snap.http import SnapdHttpException6from landscape.client.snap_http import SnapdHttpException
7from landscape.client.snap.http import SnapHttp as OrigSnapHttp7from landscape.client.snap_http import SnapdResponse
8from landscape.client.tests.helpers import LandscapeTest8from landscape.client.tests.helpers import LandscapeTest
9from landscape.client.tests.helpers import ManagerHelper9from landscape.client.tests.helpers import ManagerHelper
1010
@@ -15,13 +15,10 @@ class SnapManagerTest(LandscapeTest):
15 def setUp(self):15 def setUp(self):
16 super().setUp()16 super().setUp()
1717
18 self.snap_http = mock.Mock(spec_set=OrigSnapHttp)18 self.snap_http = mock.patch(
19 self.SnapHttp = mock.patch(19 "landscape.client.manager.snapmanager.snap_http",
20 "landscape.client.manager.snapmanager.SnapHttp",
21 ).start()20 ).start()
2221
23 self.SnapHttp.return_value = self.snap_http
24
25 self.broker_service.message_store.set_accepted_types(22 self.broker_service.message_store.set_accepted_types(
26 ["operation-result"],23 ["operation-result"],
27 )24 )
@@ -42,34 +39,37 @@ class SnapManagerTest(LandscapeTest):
4239
43 def install_snap(name, revision=None, channel=None, classic=False):40 def install_snap(name, revision=None, channel=None, classic=False):
44 if name == "hello":41 if name == "hello":
45 return {"change": "1"}42 return SnapdResponse("async", 200, "OK", None, change="1")
4643
47 if name == "goodbye":44 if name == "goodbye":
48 return {"change": "2"}45 return SnapdResponse("async", 200, "OK", None, change="2")
4946
50 return mock.DEFAULT47 return mock.DEFAULT
5148
52 self.snap_http.install_snap.side_effect = install_snap49 self.snap_http.install.side_effect = install_snap
53 self.snap_http.check_changes.return_value = {50 self.snap_http.check_changes.return_value = SnapdResponse(
54 "result": [51 "sync",
52 "200",
53 "OK",
54 [
55 {"id": "1", "status": "Done"},55 {"id": "1", "status": "Done"},
56 {"id": "2", "status": "Done"},56 {"id": "2", "status": "Done"},
57 ],57 ],
58 }58 )
59 self.snap_http.get_snaps.return_value = {"installed": []}59 self.snap_http.list.return_value = SnapdResponse("sync", 200, "OK", [])
6060
61 result = self.manager.dispatch_message(61 result = self.manager.dispatch_message(
62 {62 {
63 "type": "install-snaps",63 "type": "install-snaps",
64 "operation-id": 123,64 "operation-id": 123,
65 "snaps": [65 "snaps": [
66 {"name": "hello", "revision": 9001},66 {"name": "hello", "args": {"revision": 9001}},
67 {"name": "goodbye"},67 {"name": "goodbye"},
68 ],68 ],
69 },69 },
70 )70 )
7171
72 def got_result(r):72 def got_result(_):
73 self.assertMessages(73 self.assertMessages(
74 self.broker_service.message_store.get_pending_messages(),74 self.broker_service.message_store.get_pending_messages(),
75 [75 [
@@ -90,12 +90,24 @@ class SnapManagerTest(LandscapeTest):
90 When no channels or revisions are specified, snaps are installed90 When no channels or revisions are specified, snaps are installed
91 via a single call to snapd.91 via a single call to snapd.
92 """92 """
93 self.snap_http.install_snaps.return_value = {"change": "1"}93 self.snap_http.install_all.return_value = SnapdResponse(
94 self.snap_http.check_changes.return_value = {94 "async",
95 "result": [{"id": "1", "status": "Done"}],95 202,
96 }96 "Accepted",
97 self.snap_http.get_snaps.return_value = {97 None,
98 "installed": [98 change="1",
99 )
100 self.snap_http.check_changes.return_value = SnapdResponse(
101 "sync",
102 200,
103 "OK",
104 [{"id": "1", "status": "Done"}],
105 )
106 self.snap_http.list.return_value = SnapdResponse(
107 "sync",
108 200,
109 "OK",
110 [
99 {111 {
100 "name": "hello",112 "name": "hello",
101 "id": "test",113 "id": "test",
@@ -106,7 +118,7 @@ class SnapManagerTest(LandscapeTest):
106 "version": "1.2.3",118 "version": "1.2.3",
107 },119 },
108 ],120 ],
109 }121 )
110122
111 result = self.manager.dispatch_message(123 result = self.manager.dispatch_message(
112 {124 {
@@ -119,7 +131,7 @@ class SnapManagerTest(LandscapeTest):
119 },131 },
120 )132 )
121133
122 def got_result(r):134 def got_result(_):
123 self.assertMessages(135 self.assertMessages(
124 self.broker_service.message_store.get_pending_messages(),136 self.broker_service.message_store.get_pending_messages(),
125 [137 [
@@ -136,10 +148,10 @@ class SnapManagerTest(LandscapeTest):
136 return result.addCallback(got_result)148 return result.addCallback(got_result)
137149
138 def test_install_snap_immediate_error(self):150 def test_install_snap_immediate_error(self):
139 self.snap_http.install_snaps.side_effect = SnapdHttpException(151 self.snap_http.install_all.side_effect = SnapdHttpException(
140 b'{"result": "whoops"}',152 b'{"result": "whoops"}',
141 )153 )
142 self.snap_http.get_snaps.return_value = {"installed": []}154 self.snap_http.list.return_value = SnapdResponse("sync", 200, "OK", [])
143155
144 result = self.manager.dispatch_message(156 result = self.manager.dispatch_message(
145 {157 {
@@ -151,7 +163,7 @@ class SnapManagerTest(LandscapeTest):
151163
152 self.log_helper.ignore_errors(r".+whoops$")164 self.log_helper.ignore_errors(r".+whoops$")
153165
154 def got_result(r):166 def got_result(_):
155 self.assertMessages(167 self.assertMessages(
156 self.broker_service.message_store.get_pending_messages(),168 self.broker_service.message_store.get_pending_messages(),
157 [169 [
@@ -168,9 +180,20 @@ class SnapManagerTest(LandscapeTest):
168 return result.addCallback(got_result)180 return result.addCallback(got_result)
169181
170 def test_install_snap_no_status(self):182 def test_install_snap_no_status(self):
171 self.snap_http.install_snaps.return_value = {"change": "1"}183 self.snap_http.install_all.return_value = SnapdResponse(
172 self.snap_http.check_changes.return_value = {"result": []}184 "async",
173 self.snap_http.get_snaps.return_value = {"installed": []}185 202,
186 "Accepted",
187 None,
188 change="1",
189 )
190 self.snap_http.check_changes.return_value = SnapdResponse(
191 "sync",
192 200,
193 "OK",
194 [],
195 )
196 self.snap_http.list.return_value = SnapdResponse("sync", 200, "OK", [])
174197
175 result = self.manager.dispatch_message(198 result = self.manager.dispatch_message(
176 {199 {
@@ -180,7 +203,7 @@ class SnapManagerTest(LandscapeTest):
180 },203 },
181 )204 )
182205
183 def got_result(r):206 def got_result(_):
184 self.assertMessages(207 self.assertMessages(
185 self.broker_service.message_store.get_pending_messages(),208 self.broker_service.message_store.get_pending_messages(),
186 [209 [
@@ -197,9 +220,15 @@ class SnapManagerTest(LandscapeTest):
197 return result.addCallback(got_result)220 return result.addCallback(got_result)
198221
199 def test_install_snap_check_error(self):222 def test_install_snap_check_error(self):
200 self.snap_http.install_snaps.return_value = {"change": "1"}223 self.snap_http.install_all.return_value = SnapdResponse(
224 "async",
225 200,
226 "Accepted",
227 None,
228 change="1",
229 )
201 self.snap_http.check_changes.side_effect = SnapdHttpException("whoops")230 self.snap_http.check_changes.side_effect = SnapdHttpException("whoops")
202 self.snap_http.get_snaps.return_value = {"installed": []}231 self.snap_http.list.return_value = SnapdResponse("sync", 200, "OK", [])
203232
204 result = self.manager.dispatch_message(233 result = self.manager.dispatch_message(
205 {234 {
@@ -211,7 +240,7 @@ class SnapManagerTest(LandscapeTest):
211240
212 self.log_helper.ignore_errors(r".+whoops$")241 self.log_helper.ignore_errors(r".+whoops$")
213242
214 def got_result(r):243 def got_result(_):
215 self.assertMessages(244 self.assertMessages(
216 self.broker_service.message_store.get_pending_messages(),245 self.broker_service.message_store.get_pending_messages(),
217 [246 [
@@ -228,11 +257,20 @@ class SnapManagerTest(LandscapeTest):
228 return result.addCallback(got_result)257 return result.addCallback(got_result)
229258
230 def test_remove_snap(self):259 def test_remove_snap(self):
231 self.snap_http.remove_snaps.return_value = {"change": "1"}260 self.snap_http.remove_all.return_value = SnapdResponse(
232 self.snap_http.check_changes.return_value = {261 "async",
233 "result": [{"id": "1", "status": "Done"}],262 202,
234 }263 "Accepted",
235 self.snap_http.get_snaps.return_value = {"installed": []}264 None,
265 change="1",
266 )
267 self.snap_http.check_changes.return_value = SnapdResponse(
268 "sync",
269 200,
270 "OK",
271 [{"id": "1", "status": "Done"}],
272 )
273 self.snap_http.list.return_value = SnapdResponse("sync", 200, "OK", [])
236274
237 result = self.manager.dispatch_message(275 result = self.manager.dispatch_message(
238 {276 {
@@ -242,7 +280,7 @@ class SnapManagerTest(LandscapeTest):
242 },280 },
243 )281 )
244282
245 def got_result(r):283 def got_result(_):
246 self.assertMessages(284 self.assertMessages(
247 self.broker_service.message_store.get_pending_messages(),285 self.broker_service.message_store.get_pending_messages(),
248 [286 [
@@ -257,3 +295,89 @@ class SnapManagerTest(LandscapeTest):
257 )295 )
258296
259 return result.addCallback(got_result)297 return result.addCallback(got_result)
298
299 def test_set_config(self):
300 self.snap_http.set_conf.return_value = SnapdResponse(
301 "async",
302 202,
303 "Accepted",
304 None,
305 change="1",
306 )
307 self.snap_http.check_changes.return_value = SnapdResponse(
308 "sync",
309 "200",
310 "OK",
311 [{"id": "1", "status": "Done"}],
312 )
313 self.snap_http.list.return_value = SnapdResponse("sync", 200, "OK", [])
314
315 result = self.manager.dispatch_message(
316 {
317 "type": "set-snap-config",
318 "operation-id": 123,
319 "snaps": [
320 {
321 "name": "hello",
322 "args": {
323 "config": {"foo": {"bar": "qux", "baz": "quux"}},
324 },
325 },
326 ],
327 },
328 )
329
330 def got_result(_):
331 self.assertMessages(
332 self.broker_service.message_store.get_pending_messages(),
333 [
334 {
335 "type": "operation-result",
336 "status": SUCCEEDED,
337 "result-text": "{'completed': ['hello'], "
338 "'errored': [], 'errors': {}}",
339 "operation-id": 123,
340 },
341 ],
342 )
343
344 return result.addCallback(got_result)
345
346 def test_set_config_sync_error(self):
347 self.snap_http.set_conf.side_effect = SnapdHttpException(
348 b'{"result": "whoops"}',
349 )
350 self.snap_http.list.return_value = SnapdResponse("sync", 200, "OK", [])
351
352 result = self.manager.dispatch_message(
353 {
354 "type": "set-snap-config",
355 "operation-id": 123,
356 "snaps": [
357 {
358 "name": "hello",
359 "args": {
360 "config": {"foo": {"bar": "qux", "baz": "quux"}},
361 },
362 },
363 ],
364 },
365 )
366
367 def got_result(_):
368 self.assertMessages(
369 self.broker_service.message_store.get_pending_messages(),
370 [
371 {
372 "type": "operation-result",
373 "status": FAILED,
374 "result-text": (
375 "{'completed': [], 'errored': [], "
376 "'errors': {'hello': 'whoops'}}"
377 ),
378 "operation-id": 123,
379 },
380 ],
381 )
382
383 return result.addCallback(got_result)
diff --git a/landscape/client/manager/tests/test_snapservicesmanager.py b/landscape/client/manager/tests/test_snapservicesmanager.py
260new file mode 100644384new file mode 100644
index 0000000..6198f6c
--- /dev/null
+++ b/landscape/client/manager/tests/test_snapservicesmanager.py
@@ -0,0 +1,333 @@
1import sys
2from unittest import mock
3
4from landscape.client.manager.manager import FAILED
5from landscape.client.manager.manager import SUCCEEDED
6from landscape.client.manager.snapservicesmanager import SnapServicesManager
7from landscape.client.snap_http import SnapdHttpException
8from landscape.client.snap_http import SnapdResponse
9from landscape.client.tests.helpers import LandscapeTest
10from landscape.client.tests.helpers import ManagerHelper
11
12
13class SnapServicesManagerTest(LandscapeTest):
14 helpers = [ManagerHelper]
15
16 def setUp(self):
17 super().setUp()
18
19 self.snap_http = mock.patch(
20 "landscape.client.manager.snapservicesmanager.snap_http",
21 ).start()
22
23 self.broker_service.message_store.set_accepted_types(
24 ["operation-result"],
25 )
26 self.plugin = SnapServicesManager()
27 self.manager.add(self.plugin)
28
29 self.manager.config.snapd_poll_attempts = 2
30 self.manager.config.snapd_poll_interval = 0.1
31
32 def tearDown(self):
33 mock.patch.stopall()
34
35 @mock.patch("landscape.client.manager.snapmanager.snap_http")
36 def test_start_service(self, mock_base_snap_http):
37 self.snap_http.start.return_value = SnapdResponse(
38 "async",
39 202,
40 "Accepted",
41 None,
42 change="1",
43 )
44 mock_base_snap_http.check_changes.return_value = SnapdResponse(
45 "sync",
46 200,
47 "OK",
48 [{"id": "1", "status": "Done"}],
49 )
50 self.snap_http.get_apps.return_value = SnapdResponse(
51 "sync",
52 200,
53 "OK",
54 [
55 {
56 "snap": "test-snap",
57 "name": "bye-svc",
58 "daemon": "simple",
59 "daemon-scope": "system",
60 "enabled": True,
61 },
62 ],
63 )
64
65 result = self.manager.dispatch_message(
66 {
67 "type": "start-snap-service",
68 "operation-id": 123,
69 "snaps": [
70 {"name": "test-snap.hello-svc", "args": {"enable": True}},
71 ],
72 },
73 )
74
75 def got_result(_):
76 self.assertMessages(
77 self.broker_service.message_store.get_pending_messages(),
78 [
79 {
80 "type": "operation-result",
81 "status": SUCCEEDED,
82 "result-text": "{'completed': ['test-snap.hello-svc'],"
83 " 'errored': [], 'errors': {}}",
84 "operation-id": 123,
85 },
86 ],
87 )
88
89 self.snap_http.start.assert_called_once_with(
90 "test-snap.hello-svc",
91 enable=True,
92 )
93
94 return result.addCallback(got_result)
95
96 def test_start_service_error(self):
97 self.snap_http.start.side_effect = SnapdHttpException(
98 b'{"result": "snap idonotexist not found"}',
99 )
100 self.snap_http.get_apps.return_value = SnapdResponse(
101 "sync",
102 200,
103 "OK",
104 [],
105 )
106
107 result = self.manager.dispatch_message(
108 {
109 "type": "start-snap-service",
110 "operation-id": 123,
111 "snaps": [{"name": "idonotexist", "args": {"enable": True}}],
112 },
113 )
114
115 self.log_helper.ignore_errors(r".+idonotexist$")
116
117 def got_result(_):
118 self.assertMessages(
119 self.broker_service.message_store.get_pending_messages(),
120 [
121 {
122 "type": "operation-result",
123 "status": FAILED,
124 "result-text": "{'completed': [], "
125 "'errored': [], 'errors': {'idonotexist': "
126 "'snap idonotexist not found'}}",
127 "operation-id": 123,
128 },
129 ],
130 )
131
132 self.snap_http.start.assert_called_once_with(
133 "idonotexist",
134 enable=True,
135 )
136
137 return result.addCallback(got_result)
138
139 @mock.patch("landscape.client.manager.snapmanager.snap_http")
140 def test_stop_service_batch(self, mock_base_snap_http):
141 self.snap_http.stop_all.return_value = SnapdResponse(
142 "async",
143 202,
144 "Accepted",
145 None,
146 change="1",
147 )
148 mock_base_snap_http.check_changes.return_value = SnapdResponse(
149 "sync",
150 200,
151 "OK",
152 [{"id": "1", "status": "Done"}],
153 )
154 self.snap_http.get_apps.return_value = SnapdResponse(
155 "sync",
156 200,
157 "OK",
158 [
159 {
160 "snap": "lxd",
161 "name": "lxd",
162 "daemon": "simple",
163 "daemon-scope": "system",
164 },
165 {
166 "snap": "landscape-client",
167 "name": "landscape-client",
168 "daemon": "simple",
169 "daemon-scope": "system",
170 },
171 ],
172 )
173
174 result = self.manager.dispatch_message(
175 {
176 "type": "stop-snap-service",
177 "operation-id": 123,
178 "snaps": [
179 {"name": "lxd"},
180 {"name": "landscape-client"},
181 ],
182 "args": {"disable": False},
183 },
184 )
185
186 def got_result(_):
187 self.assertMessages(
188 self.broker_service.message_store.get_pending_messages(),
189 [
190 {
191 "type": "operation-result",
192 "status": SUCCEEDED,
193 "result-text": "{'completed': ['BATCH'], "
194 "'errored': [], 'errors': {}}",
195 "operation-id": 123,
196 },
197 ],
198 )
199
200 self.snap_http.stop_all.assert_called_once_with(
201 ["lxd", "landscape-client"],
202 disable=False,
203 )
204
205 return result.addCallback(got_result)
206
207 @mock.patch("landscape.client.manager.snapmanager.snap_http")
208 def test_restart_service(self, mock_base_snap_http):
209 self.snap_http.restart_all.return_value = SnapdResponse(
210 "async",
211 202,
212 "Accepted",
213 None,
214 change="1",
215 )
216 mock_base_snap_http.check_changes.return_value = SnapdResponse(
217 "sync",
218 200,
219 "OK",
220 [{"id": "1", "status": "Done"}],
221 )
222 self.snap_http.get_apps.return_value = SnapdResponse(
223 "sync",
224 200,
225 "OK",
226 [
227 {
228 "snap": "lxd",
229 "name": "lxd",
230 "daemon": "simple",
231 "daemon-scope": "system",
232 },
233 {
234 "snap": "test-snap",
235 "name": "bye-svc",
236 "daemon": "simple",
237 "daemon-scope": "system",
238 },
239 ],
240 )
241
242 result = self.manager.dispatch_message(
243 {
244 "type": "restart-snap-service",
245 "operation-id": 123,
246 "snaps": [
247 {"name": "test-snap"},
248 {"name": "lxd"},
249 ],
250 },
251 )
252
253 def got_result(_):
254 self.assertMessages(
255 self.broker_service.message_store.get_pending_messages(),
256 [
257 {
258 "type": "operation-result",
259 "status": SUCCEEDED,
260 "result-text": "{'completed': ['BATCH'], "
261 "'errored': [], 'errors': {}}",
262 "operation-id": 123,
263 },
264 ],
265 )
266
267 self.snap_http.restart_all.assert_called_once_with(
268 ["test-snap", "lxd"],
269 )
270
271 return result.addCallback(got_result)
272
273 @mock.patch("landscape.client.manager.snapmanager.snap_http")
274 def test_restart_service_update_failure(self, mock_base_snap_http):
275 """
276 Test when the client runs the operation successfully but
277 `_send_snap_update` fails.
278 """
279 self.snap_http.restart_all.return_value = SnapdResponse(
280 "async",
281 202,
282 "Accepted",
283 None,
284 change="1",
285 )
286 mock_base_snap_http.check_changes.return_value = SnapdResponse(
287 "sync",
288 200,
289 "OK",
290 [{"id": "1", "status": "Done"}],
291 )
292 self.snap_http.get_apps.side_effect = SnapdHttpException(
293 "An error occurred.",
294 )
295 mock_logger = mock.Mock()
296 self.patch(sys.modules["logging"], "error", mock_logger)
297
298 result = self.manager.dispatch_message(
299 {
300 "type": "restart-snap-service",
301 "operation-id": 123,
302 "snaps": [
303 {"name": "test-snap"},
304 {"name": "lxd"},
305 ],
306 },
307 )
308
309 self.log_helper.ignore_errors(r".+error$")
310
311 def got_result(_):
312 self.assertMessages(
313 self.broker_service.message_store.get_pending_messages(),
314 [
315 {
316 "type": "operation-result",
317 "status": SUCCEEDED,
318 "result-text": "{'completed': ['BATCH'], "
319 "'errored': [], 'errors': {}}",
320 "operation-id": 123,
321 },
322 ],
323 )
324
325 mock_logger.assert_called_once_with(
326 "Unable to list services: An error occurred.",
327 )
328
329 self.snap_http.restart_all.assert_called_once_with(
330 ["test-snap", "lxd"],
331 )
332
333 return result.addCallback(got_result)
diff --git a/landscape/client/manager/tests/test_ubuntuproinfo.py b/landscape/client/manager/tests/test_ubuntuproinfo.py
0new file mode 100644334new file mode 100644
index 0000000..10c60eb
--- /dev/null
+++ b/landscape/client/manager/tests/test_ubuntuproinfo.py
@@ -0,0 +1,151 @@
1from unittest import mock
2
3from landscape.client.manager.ubuntuproinfo import get_ubuntu_pro_info
4from landscape.client.manager.ubuntuproinfo import UbuntuProInfo
5from landscape.client.tests.helpers import LandscapeTest
6from landscape.client.tests.helpers import MonitorHelper
7
8
9class UbuntuProInfoTest(LandscapeTest):
10 """Ubuntu Pro info plugin tests."""
11
12 helpers = [MonitorHelper]
13
14 def setUp(self):
15 super().setUp()
16 self.mstore.set_accepted_types(["ubuntu-pro-info"])
17
18 def test_ubuntu_pro_info(self):
19 """Tests calling `ua status`."""
20 plugin = UbuntuProInfo()
21
22 with mock.patch("subprocess.run") as run_mock:
23 run_mock.return_value = mock.Mock(
24 stdout='"This is a test"',
25 )
26 self.monitor.add(plugin)
27 plugin.run()
28
29 run_mock.assert_called()
30 messages = self.mstore.get_pending_messages()
31 self.assertTrue(len(messages) > 0)
32 self.assertTrue("ubuntu-pro-info" in messages[0])
33 self.assertEqual(messages[0]["ubuntu-pro-info"], '"This is a test"')
34
35 def test_ubuntu_pro_info_no_pro(self):
36 """Tests calling `pro status` when it is not installed."""
37 plugin = UbuntuProInfo()
38 self.monitor.add(plugin)
39
40 with mock.patch("subprocess.run") as run_mock:
41 run_mock.side_effect = FileNotFoundError()
42 plugin.run()
43
44 messages = self.mstore.get_pending_messages()
45 run_mock.assert_called_once()
46 self.assertTrue(len(messages) > 0)
47 self.assertTrue("ubuntu-pro-info" in messages[0])
48 self.assertIn("errors", messages[0]["ubuntu-pro-info"])
49
50 def test_get_ubuntu_pro_info_core(self):
51 """In Ubuntu Core, there is no pro info, so return a reasonable erro
52 message.
53 """
54 with mock.patch(
55 "landscape.client.manager.ubuntuproinfo.IS_CORE",
56 new="1",
57 ):
58 result = get_ubuntu_pro_info()
59
60 self.assertIn("errors", result)
61 self.assertIn("not available", result["errors"][0]["message"])
62 self.assertEqual(result["result"], "failure")
63
64 def test_persistence_unchanged_data(self):
65 """If data hasn't changed, a new message is not sent"""
66 plugin = UbuntuProInfo()
67 self.monitor.add(plugin)
68 data = '"Initial data!"'
69
70 with mock.patch("subprocess.run") as run_mock:
71 run_mock.return_value = mock.Mock(
72 stdout=data,
73 )
74 plugin.run()
75
76 messages = self.mstore.get_pending_messages()
77 run_mock.assert_called_once()
78 self.assertEqual(1, len(messages))
79 self.assertTrue("ubuntu-pro-info" in messages[0])
80 self.assertEqual(messages[0]["ubuntu-pro-info"], data)
81
82 with mock.patch("subprocess.run") as run_mock:
83 run_mock.return_value = mock.Mock(
84 stdout=data,
85 )
86 plugin.run()
87
88 run_mock.assert_called_once()
89 messages = self.mstore.get_pending_messages()
90 self.assertEqual(1, len(messages))
91
92 def test_persistence_changed_data(self):
93 """New data will be sent in a new message in the queue"""
94 plugin = UbuntuProInfo()
95 self.monitor.add(plugin)
96
97 with mock.patch("subprocess.run") as run_mock:
98 run_mock.return_value = mock.Mock(
99 stdout='"Initial data!"',
100 )
101 plugin.run()
102
103 messages = self.mstore.get_pending_messages()
104 run_mock.assert_called_once()
105 self.assertEqual(1, len(messages))
106 self.assertTrue("ubuntu-pro-info" in messages[0])
107 self.assertEqual(messages[0]["ubuntu-pro-info"], '"Initial data!"')
108
109 with mock.patch("subprocess.run") as run_mock:
110 run_mock.return_value = mock.Mock(
111 stdout='"New data!"',
112 )
113 plugin.run()
114
115 run_mock.assert_called_once()
116 messages = self.mstore.get_pending_messages()
117 self.assertEqual(2, len(messages))
118 self.assertEqual(messages[1]["ubuntu-pro-info"], '"New data!"')
119
120 def test_persistence_reset(self):
121 """Resetting the plugin will allow a message with identical data to
122 be sent"""
123 plugin = UbuntuProInfo()
124 self.monitor.add(plugin)
125 data = '"Initial data!"'
126
127 with mock.patch("subprocess.run") as run_mock:
128 run_mock.return_value = mock.Mock(
129 stdout=data,
130 )
131 plugin.run()
132
133 messages = self.mstore.get_pending_messages()
134 run_mock.assert_called_once()
135 self.assertEqual(1, len(messages))
136 self.assertTrue("ubuntu-pro-info" in messages[0])
137 self.assertEqual(messages[0]["ubuntu-pro-info"], data)
138
139 plugin._reset()
140
141 with mock.patch("subprocess.run") as run_mock:
142 run_mock.return_value = mock.Mock(
143 stdout=data,
144 )
145 plugin.run()
146
147 run_mock.assert_called_once()
148 messages = self.mstore.get_pending_messages()
149 self.assertEqual(2, len(messages))
150 self.assertTrue("ubuntu-pro-info" in messages[1])
151 self.assertEqual(messages[1]["ubuntu-pro-info"], data)
diff --git a/landscape/client/manager/tests/test_usermanager.py b/landscape/client/manager/tests/test_usermanager.py
index fe02d03..d4742c5 100644
--- a/landscape/client/manager/tests/test_usermanager.py
+++ b/landscape/client/manager/tests/test_usermanager.py
@@ -1,5 +1,6 @@
1import os1import os
2from unittest.mock import Mock2from unittest.mock import Mock
3from unittest.mock import patch
34
4from landscape.client.manager.plugin import FAILED5from landscape.client.manager.plugin import FAILED
5from landscape.client.manager.plugin import SUCCEEDED6from landscape.client.manager.plugin import SUCCEEDED
@@ -9,6 +10,7 @@ from landscape.client.monitor.usermonitor import UserMonitor
9from landscape.client.tests.helpers import LandscapeTest10from landscape.client.tests.helpers import LandscapeTest
10from landscape.client.tests.helpers import ManagerHelper11from landscape.client.tests.helpers import ManagerHelper
11from landscape.client.user.provider import UserManagementError12from landscape.client.user.provider import UserManagementError
13from landscape.client.user.tests.helpers import FakeSnapdUserManagement
12from landscape.client.user.tests.helpers import FakeUserManagement14from landscape.client.user.tests.helpers import FakeUserManagement
13from landscape.client.user.tests.helpers import FakeUserProvider15from landscape.client.user.tests.helpers import FakeUserProvider
14from landscape.lib.persist import Persist16from landscape.lib.persist import Persist
@@ -30,20 +32,25 @@ sbarnes:$1$q7sz09uw$q.A3526M/SHu8vUb.Jo1A/:13349:0:99999:7:::
30 )32 )
31 accepted_types = ["operation-result", "users"]33 accepted_types = ["operation-result", "users"]
32 self.broker_service.message_store.set_accepted_types(accepted_types)34 self.broker_service.message_store.set_accepted_types(accepted_types)
35 self.plugins = []
3336
34 def tearDown(self):37 def tearDown(self):
35 super().tearDown()38 super().tearDown()
36 for plugin in self.plugins:39 for plugin in self.plugins:
37 plugin.stop()40 plugin.stop()
3841
39 def setup_environment(self, users, groups, shadow_file):42 def setup_environment(self, users, groups, shadow_file, is_core=False):
40 provider = FakeUserProvider(43 provider = FakeUserProvider(
41 users=users,44 users=users,
42 groups=groups,45 groups=groups,
43 shadow_file=shadow_file,46 shadow_file=shadow_file,
44 )47 )
45 user_monitor = UserMonitor(provider=provider)48 user_monitor = UserMonitor(provider=provider)
46 management = FakeUserManagement(provider=provider)49
50 if is_core:
51 management = FakeSnapdUserManagement(provider=provider)
52 else:
53 management = FakeUserManagement(provider=provider)
47 user_manager = UserManager(54 user_manager = UserManager(
48 management=management,55 management=management,
49 shadow_file=shadow_file,56 shadow_file=shadow_file,
@@ -378,6 +385,64 @@ class UserOperationsMessagingTest(UserGroupTestBase):
378 result.addCallback(handle_callback)385 result.addCallback(handle_callback)
379 return result386 return result
380387
388 @patch("landscape.client.manager.usermanager.IS_CORE", "1")
389 def test_add_user_event_on_core(self):
390 """
391 When an C{add-user} event is received the user should be
392 added. Two messages should be generated: a C{users} message
393 with details about the change and an C{operation-result} with
394 details of the outcome of the operation.
395 """
396
397 def handle_callback(result):
398 messages = self.broker_service.message_store.get_pending_messages()
399 self.assertMessages(
400 messages,
401 [
402 {
403 "type": "operation-result",
404 "status": SUCCEEDED,
405 "operation-id": 123,
406 "timestamp": 0,
407 "result-text": "add_user succeeded",
408 },
409 {
410 "timestamp": 0,
411 "type": "users",
412 "operation-id": 123,
413 "create-users": [
414 {
415 "home-phone": None,
416 "username": "john-doe",
417 "uid": 1000,
418 "enabled": True,
419 "location": None,
420 "work-phone": None,
421 "name": "john.doe@example.com",
422 "primary-gid": 1000,
423 },
424 ],
425 },
426 ],
427 )
428
429 shadow_file = self.makeFile("""st3v3nmw:*:19758:0:99999:7:::""")
430 self.setup_environment([], [], shadow_file, is_core=True)
431
432 result = self.manager.dispatch_message(
433 {
434 "type": "add-user",
435 "username": "john-doe",
436 "email": "john.doe@example.com",
437 "sudoer": False,
438 "force-managed": True,
439 "operation-id": 123,
440 },
441 )
442
443 result.addCallback(handle_callback)
444 return result
445
381 def test_edit_user_event(self):446 def test_edit_user_event(self):
382 """447 """
383 When a C{edit-user} message is received the user should be448 When a C{edit-user} message is received the user should be
@@ -834,6 +899,71 @@ class UserOperationsMessagingTest(UserGroupTestBase):
834 result.addCallback(handle_callback)899 result.addCallback(handle_callback)
835 return result900 return result
836901
902 @patch("landscape.client.manager.usermanager.IS_CORE", "1")
903 def test_remove_user_event_on_core(self):
904 """
905 When a C{remove-user} event is received, the user should be removed.
906 Two messages should be generated: a C{users} message with details
907 about the change and an C{operation-result} with details of the
908 outcome of the operation.
909 """
910
911 def handle_callback(result):
912 messages = self.broker_service.message_store.get_pending_messages()
913 self.assertEqual(len(messages), 3)
914 # Ignore the message created by plugin.run.
915 self.assertMessages(
916 [messages[2], messages[1]],
917 [
918 {
919 "timestamp": 0,
920 "delete-users": ["john-doe"],
921 "type": "users",
922 "operation-id": 39,
923 },
924 {
925 "type": "operation-result",
926 "status": SUCCEEDED,
927 "operation-id": 39,
928 "timestamp": 0,
929 "result-text": "remove_user succeeded",
930 },
931 ],
932 )
933
934 users = [
935 (
936 "john-doe",
937 "x",
938 1000,
939 1000,
940 "john.doe@example.com,BtrGAhK,,",
941 "/home/user",
942 "/bin/zsh",
943 ),
944 (
945 "jane-doe",
946 "x",
947 1001,
948 1001,
949 "jane.doe@example.com,BtrGAhK,,",
950 "/home/user",
951 "/bin/zsh",
952 ),
953 ]
954 shadow_file = self.makeFile("""st3v3nmw:*:19758:0:99999:7:::""")
955 self.setup_environment(users, [], shadow_file, is_core=True)
956 result = self.manager.dispatch_message(
957 {
958 "username": "john-doe",
959 "delete-home": True,
960 "type": "remove-user",
961 "operation-id": 39,
962 },
963 )
964 result.addCallback(handle_callback)
965 return result
966
837 def test_lock_user_event(self):967 def test_lock_user_event(self):
838 """968 """
839 When a C{lock-user} event is received the user should be969 When a C{lock-user} event is received the user should be
diff --git a/landscape/client/manager/ubuntuproinfo.py b/landscape/client/manager/ubuntuproinfo.py
840new file mode 100644970new file mode 100644
index 0000000..1881c34
--- /dev/null
+++ b/landscape/client/manager/ubuntuproinfo.py
@@ -0,0 +1,102 @@
1import json
2import subprocess
3from pathlib import Path
4
5from landscape.client import IS_CORE
6from landscape.client.manager.plugin import ManagerPlugin
7from landscape.lib.persist import Persist
8
9
10class UbuntuProInfo(ManagerPlugin):
11 """
12 Plugin that captures and reports Ubuntu Pro registration
13 information.
14
15 We use the `pro` CLI with output formatted as JSON. This is sent
16 as-is and parsed by Landscape Server because the JSON content is
17 considered "Experimental" and we don't want to have to change in
18 both Client and Server in the event that the format changes.
19 """
20
21 message_type = "ubuntu-pro-info"
22 run_interval = 900 # 15 minutes
23
24 def register(self, registry):
25 super().register(registry)
26 self._persist_filename = Path(
27 self.registry.config.data_path,
28 "ubuntu-pro-info.bpickle",
29 )
30 self._persist = Persist(filename=self._persist_filename)
31 self.call_on_accepted(self.message_type, self.send_message)
32
33 def run(self):
34 return self.registry.broker.call_if_accepted(
35 self.message_type,
36 self.send_message,
37 )
38
39 def send_message(self):
40 """Send a message to the broker if the data has changed since the last
41 call"""
42 result = self.get_data()
43 if not result:
44 return
45 message = {"type": self.message_type, "ubuntu-pro-info": result}
46 return self.registry.broker.send_message(message, self._session_id)
47
48 def get_data(self):
49 """Persist data to avoid sending messages if result hasn't changed"""
50 ubuntu_pro_info = get_ubuntu_pro_info()
51
52 if self._persist.get("data") != ubuntu_pro_info:
53 self._persist.set("data", ubuntu_pro_info)
54 return json.dumps(ubuntu_pro_info, separators=(",", ":"))
55
56 def _reset(self):
57 """Reset the persist."""
58 self._persist.remove("data")
59
60
61def get_ubuntu_pro_info() -> dict:
62 """Query ua tools for Ubuntu Pro status as JSON, parsing it to a dict.
63
64 If we are running on Ubuntu Core, Pro does not exist - returns a message
65 indicating this.
66 """
67 if IS_CORE:
68 return _ubuntu_pro_error_message(
69 "Ubuntu Pro is not available on Ubuntu Core.",
70 "core-unsupported",
71 )
72
73 try:
74 completed_process = subprocess.run(
75 ["pro", "status", "--format", "json"],
76 encoding="utf8",
77 stdout=subprocess.PIPE,
78 )
79 except FileNotFoundError:
80 return _ubuntu_pro_error_message(
81 "ubuntu pro tools not found.",
82 "tools-error",
83 )
84 else:
85 return json.loads(completed_process.stdout)
86
87
88def _ubuntu_pro_error_message(message: str, code: str) -> dict:
89 """Marshall `message` and `code` into a format matching that expected from
90 an error from ua tools.
91 """
92 return {
93 "errors": [
94 {
95 "message": message,
96 "message_code": code,
97 "service": None,
98 "type": "system",
99 },
100 ],
101 "result": "failure",
102 }
diff --git a/landscape/client/manager/usermanager.py b/landscape/client/manager/usermanager.py
index 8984ccf..5e0608d 100644
--- a/landscape/client/manager/usermanager.py
+++ b/landscape/client/manager/usermanager.py
@@ -1,10 +1,12 @@
1import logging1import logging
22
3from landscape.client import IS_CORE
3from landscape.client.amp import ComponentConnector4from landscape.client.amp import ComponentConnector
4from landscape.client.amp import ComponentPublisher5from landscape.client.amp import ComponentPublisher
5from landscape.client.amp import remote6from landscape.client.amp import remote
6from landscape.client.manager.plugin import ManagerPlugin7from landscape.client.manager.plugin import ManagerPlugin
7from landscape.client.monitor.usermonitor import RemoteUserMonitorConnector8from landscape.client.monitor.usermonitor import RemoteUserMonitorConnector
9from landscape.client.user.management import SnapdUserManagement
8from landscape.client.user.management import UserManagement10from landscape.client.user.management import UserManagement
911
1012
@@ -13,7 +15,14 @@ class UserManager(ManagerPlugin):
13 name = "usermanager"15 name = "usermanager"
1416
15 def __init__(self, management=None, shadow_file="/etc/shadow"):17 def __init__(self, management=None, shadow_file="/etc/shadow"):
16 self._management = management or UserManagement()18 if IS_CORE:
19 management = management or SnapdUserManagement()
20 shadow_file = shadow_file or "/var/lib/extrausers/shadow"
21 else:
22 management = management or UserManagement()
23 shadow_file = shadow_file
24
25 self._management = management
17 self._shadow_file = shadow_file26 self._shadow_file = shadow_file
18 self._message_types = {27 self._message_types = {
19 "add-user": self._add_user,28 "add-user": self._add_user,
@@ -107,16 +116,7 @@ class UserManager(ManagerPlugin):
107116
108 def _add_user(self, message):117 def _add_user(self, message):
109 """Run an C{add-user} operation."""118 """Run an C{add-user} operation."""
110 return self._management.add_user(119 return self._management.add_user(message)
111 message["username"],
112 message["name"],
113 message["password"],
114 message["require-password-reset"],
115 message["primary-group-name"],
116 message["location"],
117 message["work-number"],
118 message["home-number"],
119 )
120120
121 def _edit_user(self, message):121 def _edit_user(self, message):
122 """Run an C{edit-user} operation."""122 """Run an C{edit-user} operation."""
@@ -140,10 +140,7 @@ class UserManager(ManagerPlugin):
140140
141 def _remove_user(self, message):141 def _remove_user(self, message):
142 """Run a C{remove-user} operation."""142 """Run a C{remove-user} operation."""
143 return self._management.remove_user(143 return self._management.remove_user(message)
144 message["username"],
145 message["delete-home"],
146 )
147144
148 def _add_group(self, message):145 def _add_group(self, message):
149 """Run an C{add-group} operation."""146 """Run an C{add-group} operation."""
diff --git a/landscape/client/monitor/computerinfo.py b/landscape/client/monitor/computerinfo.py
index dfda40b..c1b353e 100644
--- a/landscape/client/monitor/computerinfo.py
+++ b/landscape/client/monitor/computerinfo.py
@@ -5,12 +5,13 @@ from twisted.internet.defer import inlineCallbacks
5from twisted.internet.defer import returnValue5from twisted.internet.defer import returnValue
66
7from landscape.client.monitor.plugin import MonitorPlugin7from landscape.client.monitor.plugin import MonitorPlugin
8from landscape.client.snap_utils import get_snap_info
8from landscape.lib.cloud import fetch_ec2_meta_data9from landscape.lib.cloud import fetch_ec2_meta_data
9from landscape.lib.fetch import fetch_async10from landscape.lib.fetch import fetch_async
10from landscape.lib.fs import read_text_file11from landscape.lib.fs import read_text_file
11from landscape.lib.lsb_release import LSB_RELEASE_FILENAME
12from landscape.lib.lsb_release import parse_lsb_release
13from landscape.lib.network import get_fqdn12from landscape.lib.network import get_fqdn
13from landscape.lib.os_release import get_os_filename
14from landscape.lib.os_release import parse_os_release
1415
15METADATA_RETRY_MAX = 3 # Number of retries to get EC2 meta-data16METADATA_RETRY_MAX = 3 # Number of retries to get EC2 meta-data
1617
@@ -29,13 +30,13 @@ class ComputerInfo(MonitorPlugin):
29 self,30 self,
30 get_fqdn=get_fqdn,31 get_fqdn=get_fqdn,
31 meminfo_filename="/proc/meminfo",32 meminfo_filename="/proc/meminfo",
32 lsb_release_filename=LSB_RELEASE_FILENAME,33 os_release_filename=get_os_filename(),
33 root_path="/",34 root_path="/",
34 fetch_async=fetch_async,35 fetch_async=fetch_async,
35 ):36 ):
36 self._get_fqdn = get_fqdn37 self._get_fqdn = get_fqdn
37 self._meminfo_filename = meminfo_filename38 self._meminfo_filename = meminfo_filename
38 self._lsb_release_filename = lsb_release_filename39 self._os_release_filename = os_release_filename
39 self._root_path = root_path40 self._root_path = root_path
40 self._cloud_instance_metadata = None41 self._cloud_instance_metadata = None
41 self._cloud_retries = 042 self._cloud_retries = 0
@@ -59,6 +60,11 @@ class ComputerInfo(MonitorPlugin):
59 self.send_cloud_instance_metadata_message,60 self.send_cloud_instance_metadata_message,
60 True,61 True,
61 )62 )
63 self.call_on_accepted(
64 "snap-info",
65 self.send_snap_message,
66 True,
67 )
6268
63 def send_computer_message(self, urgent=False):69 def send_computer_message(self, urgent=False):
64 message = self._create_computer_info_message()70 message = self._create_computer_info_message()
@@ -96,6 +102,17 @@ class ComputerInfo(MonitorPlugin):
96 urgent=urgent,102 urgent=urgent,
97 )103 )
98104
105 def send_snap_message(self, urgent=False):
106 message = self._create_snap_info_message()
107 if message:
108 message["type"] = "snap-info"
109 logging.info("Queueing message with updated snap info.")
110 self.registry.broker.send_message(
111 message,
112 self._session_id,
113 urgent=urgent,
114 )
115
99 def exchange(self, urgent=False):116 def exchange(self, urgent=False):
100 broker = self.registry.broker117 broker = self.registry.broker
101 broker.call_if_accepted(118 broker.call_if_accepted(
@@ -113,6 +130,11 @@ class ComputerInfo(MonitorPlugin):
113 self.send_cloud_instance_metadata_message,130 self.send_cloud_instance_metadata_message,
114 urgent,131 urgent,
115 )132 )
133 broker.call_if_accepted(
134 "snap-info",
135 self.send_snap_message,
136 urgent,
137 )
116138
117 def _create_computer_info_message(self):139 def _create_computer_info_message(self):
118 message = {}140 message = {}
@@ -160,7 +182,7 @@ class ComputerInfo(MonitorPlugin):
160 def _get_distribution_info(self):182 def _get_distribution_info(self):
161 """Get details about the distribution."""183 """Get details about the distribution."""
162 message = {}184 message = {}
163 message.update(parse_lsb_release(self._lsb_release_filename))185 message.update(parse_os_release(self._os_release_filename))
164 return message186 return message
165187
166 @inlineCallbacks188 @inlineCallbacks
@@ -171,7 +193,6 @@ class ComputerInfo(MonitorPlugin):
171 self._cloud_instance_metadata is None193 self._cloud_instance_metadata is None
172 and self._cloud_retries < METADATA_RETRY_MAX194 and self._cloud_retries < METADATA_RETRY_MAX
173 ):195 ):
174
175 self._cloud_instance_metadata = yield self._fetch_ec2_meta_data()196 self._cloud_instance_metadata = yield self._fetch_ec2_meta_data()
176 message = self._cloud_instance_metadata197 message = self._cloud_instance_metadata
177 returnValue(message)198 returnValue(message)
@@ -196,3 +217,13 @@ class ComputerInfo(MonitorPlugin):
196 deferred.addCallback(log_success)217 deferred.addCallback(log_success)
197 deferred.addErrback(log_no_meta_data_found)218 deferred.addErrback(log_no_meta_data_found)
198 return deferred219 return deferred
220
221 def _create_snap_info_message(self):
222 """Create message with the snapd serial metadata."""
223 message = {}
224 snap_info = get_snap_info()
225 if snap_info:
226 self._add_if_new(message, "brand", snap_info["brand"])
227 self._add_if_new(message, "model", snap_info["model"])
228 self._add_if_new(message, "serial", snap_info["serial"])
229 return message
diff --git a/landscape/client/monitor/config.py b/landscape/client/monitor/config.py
index e6c347a..ebf54eb 100644
--- a/landscape/client/monitor/config.py
+++ b/landscape/client/monitor/config.py
@@ -20,10 +20,10 @@ ALL_PLUGINS = [
20 "SwiftUsage",20 "SwiftUsage",
21 "CephUsage",21 "CephUsage",
22 "ComputerTags",22 "ComputerTags",
23 "UbuntuProInfo",
24 "LivePatch",23 "LivePatch",
25 "UbuntuProRebootRequired",24 "UbuntuProRebootRequired",
26 "SnapMonitor",25 "SnapMonitor",
26 "SnapServicesMonitor",
27]27]
2828
2929
diff --git a/landscape/client/monitor/processorinfo.py b/landscape/client/monitor/processorinfo.py
index 2fdacc8..1081ced 100644
--- a/landscape/client/monitor/processorinfo.py
+++ b/landscape/client/monitor/processorinfo.py
@@ -181,30 +181,43 @@ class ARMMessageFactory:
181 def create_message(self):181 def create_message(self):
182 """Returns a list containing information about each processor."""182 """Returns a list containing information about each processor."""
183 processors = []183 processors = []
184 file = open(self._source_filename)184 regexp = re.compile(r"(?P<key>.*?)\s*:\s*(?P<value>.*)")
185 current = {}
185186
186 try:187 with open(self._source_filename) as fp:
187 regexp = re.compile(r"(?P<key>.*?)\s*:\s*(?P<value>.*)")188 for line in fp:
188 current = {}189 line = line.strip()
190
191 if not line:
192 if current:
193 processors.append(current.copy())
194 current = {}
195
196 continue
189197
190 for line in file:
191 match = regexp.match(line.strip())198 match = regexp.match(line.strip())
199
192 if match:200 if match:
193 key = match.group("key")201 key = match.group("key")
194 value = match.group("value")202 value = match.group("value")
195203
196 if key == "Processor":204 if key == "processor":
197 # ARM doesn't support SMP, thus no processor-id in205 current["processor-id"] = int(value)
198 # the cpuinfo206 if "model" not in current:
199 current["processor-id"] = 0207 current["model"] = "arm"
208 elif key == "Processor":
200 current["model"] = value209 current["model"] = value
201 elif key == "Cache size":210 elif key == "Cache size":
202 current["cache-size"] = int(value)211 current["cache-size"] = int(value)
203212
204 if current:213 if current:
205 processors.append(current)214 processors.append(current)
206 finally:215
207 file.close()216 # Older ARM machines may not have processor-ids, but we need them, so
217 # we set missing ones to 0.
218 for processor in processors:
219 if "processor-id" not in processor:
220 processor["processor-id"] = 0
208221
209 return processors222 return processors
210223
@@ -357,6 +370,8 @@ class RISCVMessageFactory:
357370
358 if key == "processor":371 if key == "processor":
359 current = {"processor-id": int(parts[1].strip())}372 current = {"processor-id": int(parts[1].strip())}
373 # A placeholder in case there is no model provided.
374 current["model"] = "riscv"
360 processors.append(current)375 processors.append(current)
361 elif key == "isa":376 elif key == "isa":
362 current["vendor"] = parts[1].strip()377 current["vendor"] = parts[1].strip()
diff --git a/landscape/client/monitor/service.py b/landscape/client/monitor/service.py
index 1822360..c0971f3 100644
--- a/landscape/client/monitor/service.py
+++ b/landscape/client/monitor/service.py
@@ -46,14 +46,17 @@ class MonitorService(LandscapeService):
46 try:46 try:
47 plugin = namedClass(47 plugin = namedClass(
48 "landscape.client.monitor."48 "landscape.client.monitor."
49 f"{plugin_name.lower()}.{plugin_name}"49 f"{plugin_name.lower()}.{plugin_name}",
50 )50 )
51 plugins.append(plugin())51 plugins.append(plugin())
52 except ModuleNotFoundError:52 except ModuleNotFoundError:
53 logging.warning(53 logging.warning(
54 "Invalid monitor plugin specified: '{}'. "54 f"Invalid monitor plugin specified: '{plugin_name}'. "
55 "See `example.conf` for a full list of monitor plugins.",55 "See `example.conf` for a full list of monitor plugins.",
56 plugin_name,56 )
57 except Exception as exc:
58 logging.warning(
59 f"Unable to load monitor plugin '{plugin_name}': {exc}",
57 )60 )
5861
59 return plugins62 return plugins
diff --git a/landscape/client/monitor/snapmonitor.py b/landscape/client/monitor/snapmonitor.py
index 6a55a68..ef5c94e 100644
--- a/landscape/client/monitor/snapmonitor.py
+++ b/landscape/client/monitor/snapmonitor.py
@@ -1,8 +1,9 @@
1import json
1import logging2import logging
23
4from landscape.client import snap_http
3from landscape.client.monitor.plugin import DataWatcher5from landscape.client.monitor.plugin import DataWatcher
4from landscape.client.snap.http import SnapdHttpException6from landscape.client.snap_http import SnapdHttpException
5from landscape.client.snap.http import SnapHttp
6from landscape.message_schemas.server_bound import SNAPS7from landscape.message_schemas.server_bound import SNAPS
78
89
@@ -13,31 +14,41 @@ class SnapMonitor(DataWatcher):
13 persist_name = message_type14 persist_name = message_type
14 scope = "snaps"15 scope = "snaps"
1516
16 def __init__(self, *args, **kwargs):
17 super().__init__(*args, **kwargs)
18
19 self._snap_http = SnapHttp()
20
21 def register(self, registry):17 def register(self, registry):
22 self.config = registry.config18 self.config = registry.config
23 # The default interval is 30 minutes.19 # The default interval is 30 minutes.
24 self.run_interval = self.config.snap_monitor_interval20 self.run_interval = self.config.snap_monitor_interval
2521
26 super(SnapMonitor, self).register(registry)22 super().register(registry)
2723
28 def get_data(self):24 def get_data(self):
29 try:25 try:
30 snaps = self._snap_http.get_snaps()26 snaps = snap_http.list().result
31 except SnapdHttpException as e:27 except SnapdHttpException as e:
32 logging.error(f"Unable to list installed snaps: {e}")28 logging.error(f"Unable to list installed snaps: {e}")
33 return29 return
3430
31 for i in range(len(snaps)):
32 snap_name = snaps[i]["name"]
33 try:
34 config = snap_http.get_conf(snap_name).result
35 except SnapdHttpException as e:
36 logging.warning(
37 f"Unable to get config for snap {snap_name}: {e}",
38 )
39 config = {}
40
41 snaps[i]["config"] = json.dumps(config)
42
35 # We get a lot of extra info from snapd. To avoid caching it all43 # We get a lot of extra info from snapd. To avoid caching it all
36 # or invalidating the cache on timestamp changes, we use Message44 # or invalidating the cache on timestamp changes, we use Message
37 # coercion to strip out the unnecessaries, then sort on the snap45 # coercion to strip out the unnecessaries, then sort on the snap
38 # IDs to order the list.46 # IDs to order the list.
39 data = SNAPS.coerce(47 data = SNAPS.coerce(
40 {"type": "snaps", "snaps": {"installed": snaps["result"]}},48 {
49 "type": "snaps",
50 "snaps": {"installed": snaps},
51 },
41 )52 )
42 data["snaps"]["installed"].sort(key=lambda x: x["id"])53 data["snaps"]["installed"].sort(key=lambda x: x["id"])
4354
diff --git a/landscape/client/monitor/snapservicesmonitor.py b/landscape/client/monitor/snapservicesmonitor.py
44new file mode 10064455new file mode 100644
index 0000000..66d5655
--- /dev/null
+++ b/landscape/client/monitor/snapservicesmonitor.py
@@ -0,0 +1,28 @@
1import logging
2
3from landscape.client import snap_http
4from landscape.client.monitor.plugin import DataWatcher
5from landscape.client.snap_http import SnapdHttpException
6
7
8class SnapServicesMonitor(DataWatcher):
9
10 message_type = "snap-services"
11 message_key = "services"
12 persist_name = message_type
13 scope = "snaps"
14
15 def register(self, registry):
16 self.config = registry.config
17 self.run_interval = 60 # 1 minute
18 super().register(registry)
19
20 def get_data(self):
21 try:
22 services = snap_http.get_apps(services_only=True).result
23 except SnapdHttpException as e:
24 logging.warning(f"Unable to list services: {e}")
25 services = []
26 services.sort(key=lambda x: x["name"])
27
28 return {"running": services}
diff --git a/landscape/client/monitor/tests/test_computerinfo.py b/landscape/client/monitor/tests/test_computerinfo.py
index d43a9b7..0742316 100644
--- a/landscape/client/monitor/tests/test_computerinfo.py
+++ b/landscape/client/monitor/tests/test_computerinfo.py
@@ -14,12 +14,19 @@ from landscape.lib.fetch import HTTPCodeError
14from landscape.lib.fetch import PyCurlError14from landscape.lib.fetch import PyCurlError
15from landscape.lib.fs import create_text_file15from landscape.lib.fs import create_text_file
1616
17SAMPLE_LSB_RELEASE = (17SAMPLE_OS_RELEASE = """PRETTY_NAME="Ubuntu 22.04.3 LTS"
18 "DISTRIB_ID=Ubuntu\n"18NAME="Ubuntu"
19 "DISTRIB_RELEASE=6.06\n"19VERSION_ID="22.04"
20 "DISTRIB_CODENAME=dapper\n"20VERSION="22.04.3 LTS (Jammy Jellyfish)"
21 'DISTRIB_DESCRIPTION="Ubuntu 6.06.1 LTS"\n'21VERSION_CODENAME=codename
22)22ID=ubuntu
23ID_LIKE=debian
24HOME_URL="https://www.ubuntu.com/"
25SUPPORT_URL="https://help.ubuntu.com/"
26BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
27PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
28UBUNTU_CODENAME=codename
29"""
2330
2431
25def get_fqdn():32def get_fqdn():
@@ -27,7 +34,6 @@ def get_fqdn():
2734
2835
29class ComputerInfoTest(LandscapeTest):36class ComputerInfoTest(LandscapeTest):
30
31 helpers = [MonitorHelper]37 helpers = [MonitorHelper]
3238
33 sample_memory_info = """39 sample_memory_info = """
@@ -58,7 +64,7 @@ VmallocChunk: 107432 kB
5864
59 def setUp(self):65 def setUp(self):
60 LandscapeTest.setUp(self)66 LandscapeTest.setUp(self)
61 self.lsb_release_filename = self.makeFile(SAMPLE_LSB_RELEASE)67 self.os_release_filename = self.makeFile(SAMPLE_OS_RELEASE)
62 self.query_results = {}68 self.query_results = {}
6369
64 def fetch_stub(url, **kwargs):70 def fetch_stub(url, **kwargs):
@@ -100,7 +106,7 @@ VmallocChunk: 107432 kB
100 messages = self.mstore.get_pending_messages()106 messages = self.mstore.get_pending_messages()
101 self.assertEqual(len(messages), 1)107 self.assertEqual(len(messages), 1)
102 self.assertEqual(messages[0]["type"], "computer-info")108 self.assertEqual(messages[0]["type"], "computer-info")
103 self.assertNotEquals(len(messages[0]["hostname"]), 0)109 self.assertNotEqual(len(messages[0]["hostname"]), 0)
104 self.assertTrue(re.search(r"\w", messages[0]["hostname"]))110 self.assertTrue(re.search(r"\w", messages[0]["hostname"]))
105111
106 def test_only_report_changed_hostnames(self):112 def test_only_report_changed_hostnames(self):
@@ -223,16 +229,16 @@ VmallocChunk: 107432 kB
223 the distribution data reported by the plugin.229 the distribution data reported by the plugin.
224 """230 """
225 self.mstore.set_accepted_types(["distribution-info"])231 self.mstore.set_accepted_types(["distribution-info"])
226 plugin = ComputerInfo(lsb_release_filename=self.lsb_release_filename)232 plugin = ComputerInfo(os_release_filename=self.os_release_filename)
227 self.monitor.add(plugin)233 self.monitor.add(plugin)
228234
229 plugin.exchange()235 plugin.exchange()
230 message = self.mstore.get_pending_messages()[0]236 message = self.mstore.get_pending_messages()[0]
231 self.assertEqual(message["type"], "distribution-info")237 self.assertEqual(message["type"], "distribution-info")
232 self.assertEqual(message["distributor-id"], "Ubuntu")238 self.assertEqual(message["distributor-id"], "Ubuntu")
233 self.assertEqual(message["description"], "Ubuntu 6.06.1 LTS")239 self.assertEqual(message["description"], "Ubuntu 22.04.3 LTS")
234 self.assertEqual(message["release"], "6.06")240 self.assertEqual(message["release"], "22.04")
235 self.assertEqual(message["code-name"], "dapper")241 self.assertEqual(message["code-name"], "codename")
236242
237 def test_distribution_reported_only_once(self):243 def test_distribution_reported_only_once(self):
238 """244 """
@@ -258,23 +264,23 @@ VmallocChunk: 107432 kB
258 the server.264 the server.
259 """265 """
260 self.mstore.set_accepted_types(["distribution-info"])266 self.mstore.set_accepted_types(["distribution-info"])
261 plugin = ComputerInfo(lsb_release_filename=self.lsb_release_filename)267 plugin = ComputerInfo(os_release_filename=self.os_release_filename)
262 self.monitor.add(plugin)268 self.monitor.add(plugin)
263269
264 plugin.exchange()270 plugin.exchange()
265 message = self.mstore.get_pending_messages()[0]271 message = self.mstore.get_pending_messages()[0]
266 self.assertEqual(message["type"], "distribution-info")272 self.assertEqual(message["type"], "distribution-info")
267 self.assertEqual(message["distributor-id"], "Ubuntu")273 self.assertEqual(message["distributor-id"], "Ubuntu")
268 self.assertEqual(message["description"], "Ubuntu 6.06.1 LTS")274 self.assertEqual(message["description"], "Ubuntu 22.04.3 LTS")
269 self.assertEqual(message["release"], "6.06")275 self.assertEqual(message["release"], "22.04")
270 self.assertEqual(message["code-name"], "dapper")276 self.assertEqual(message["code-name"], "codename")
271277
272 plugin._lsb_release_filename = self.makeFile(278 plugin._os_release_filename = self.makeFile(
273 """\279 """\
274DISTRIB_ID=Ubuntu280NAME=Ubuntu
275DISTRIB_RELEASE=6.10281VERSION_ID=6.10
276DISTRIB_CODENAME=edgy282VERSION_CODENAME=edgy
277DISTRIB_DESCRIPTION="Ubuntu 6.10"283PRETTY_NAME="Ubuntu 6.10"
278""",284""",
279 )285 )
280 plugin.exchange()286 plugin.exchange()
@@ -287,25 +293,25 @@ DISTRIB_DESCRIPTION="Ubuntu 6.10"
287293
288 def test_unknown_distribution_key(self):294 def test_unknown_distribution_key(self):
289 self.mstore.set_accepted_types(["distribution-info"])295 self.mstore.set_accepted_types(["distribution-info"])
290 lsb_release_filename = self.makeFile(296 os_release_filename = self.makeFile(
291 """\297 """\
292DISTRIB_ID=Ubuntu298NAME=Ubuntu
293DISTRIB_RELEASE=6.10299VERSION_ID=22.04
294DISTRIB_CODENAME=edgy300VERSION_CODENAME=codename
295DISTRIB_DESCRIPTION="Ubuntu 6.10"301PRETTY_NAME="Ubuntu 22.04.3 LTS"
296DISTRIB_NEW_UNEXPECTED_KEY=ooga302DISTRIB_NEW_UNEXPECTED_KEY=ooga
297""",303""",
298 )304 )
299 plugin = ComputerInfo(lsb_release_filename=lsb_release_filename)305 plugin = ComputerInfo(os_release_filename=os_release_filename)
300 self.monitor.add(plugin)306 self.monitor.add(plugin)
301307
302 plugin.exchange()308 plugin.exchange()
303 message = self.mstore.get_pending_messages()[0]309 message = self.mstore.get_pending_messages()[0]
304 self.assertEqual(message["type"], "distribution-info")310 self.assertEqual(message["type"], "distribution-info")
305 self.assertEqual(message["distributor-id"], "Ubuntu")311 self.assertEqual(message["distributor-id"], "Ubuntu")
306 self.assertEqual(message["description"], "Ubuntu 6.10")312 self.assertEqual(message["description"], "Ubuntu 22.04.3 LTS")
307 self.assertEqual(message["release"], "6.10")313 self.assertEqual(message["release"], "22.04")
308 self.assertEqual(message["code-name"], "edgy")314 self.assertEqual(message["code-name"], "codename")
309315
310 def test_resynchronize(self):316 def test_resynchronize(self):
311 """317 """
@@ -317,7 +323,7 @@ DISTRIB_NEW_UNEXPECTED_KEY=ooga
317 plugin = ComputerInfo(323 plugin = ComputerInfo(
318 get_fqdn=get_fqdn,324 get_fqdn=get_fqdn,
319 meminfo_filename=meminfo_filename,325 meminfo_filename=meminfo_filename,
320 lsb_release_filename=self.lsb_release_filename,326 os_release_filename=self.os_release_filename,
321 root_path=self.makeDir(),327 root_path=self.makeDir(),
322 fetch_async=self.fetch_func,328 fetch_async=self.fetch_func,
323 )329 )
@@ -335,10 +341,10 @@ DISTRIB_NEW_UNEXPECTED_KEY=ooga
335341
336 dist_info = {342 dist_info = {
337 "type": "distribution-info",343 "type": "distribution-info",
338 "code-name": "dapper",344 "code-name": "codename",
339 "description": "Ubuntu 6.06.1 LTS",345 "description": "Ubuntu 22.04.3 LTS",
340 "distributor-id": "Ubuntu",346 "distributor-id": "Ubuntu",
341 "release": "6.06",347 "release": "22.04",
342 }348 }
343 self.assertMessages(349 self.assertMessages(
344 self.mstore.get_pending_messages(),350 self.mstore.get_pending_messages(),
@@ -553,3 +559,46 @@ DISTRIB_NEW_UNEXPECTED_KEY=ooga
553 },559 },
554 result,560 result,
555 )561 )
562
563 @mock.patch("landscape.client.snap_utils.get_assertions")
564 def test_snap_info(self, mock_get_assertions):
565 """Test getting the snap info message."""
566 mock_get_assertions.return_value = [
567 {
568 "authority-id": "canonical",
569 "brand-id": "canonical",
570 "model": "pc-amd64",
571 "serial": "03961d5d-26e5-443f-838d-6db046126bea",
572 },
573 ]
574
575 self.mstore.set_accepted_types(["snap-info"])
576 plugin = ComputerInfo(fetch_async=self.fetch_func)
577 self.monitor.add(plugin)
578 plugin.exchange()
579 messages = self.mstore.get_pending_messages()
580 self.assertEqual(len(messages), 1)
581 self.assertEqual(messages[0]["type"], "snap-info")
582 self.assertEqual(messages[0]["brand"], "canonical")
583 self.assertEqual(messages[0]["model"], "pc-amd64")
584 self.assertEqual(
585 messages[0]["serial"],
586 "03961d5d-26e5-443f-838d-6db046126bea",
587 )
588
589 @mock.patch("landscape.client.snap_utils.get_assertions")
590 def test_snap_info_no_results(self, mock_get_assertions):
591 """Test getting the snap info message when there are no results.
592
593 No results can happen when:
594 - A SnapdHttpException occurs
595 - No serial assertion is found
596 """
597 mock_get_assertions.return_value = None
598
599 self.mstore.set_accepted_types(["snap-info"])
600 plugin = ComputerInfo(fetch_async=self.fetch_func)
601 self.monitor.add(plugin)
602 plugin.exchange()
603 messages = self.mstore.get_pending_messages()
604 self.assertEqual(len(messages), 0)
diff --git a/landscape/client/monitor/tests/test_processorinfo.py b/landscape/client/monitor/tests/test_processorinfo.py
index 4a2bb8e..ffe47ce 100644
--- a/landscape/client/monitor/tests/test_processorinfo.py
+++ b/landscape/client/monitor/tests/test_processorinfo.py
@@ -30,8 +30,7 @@ class ProcessorInfoTest(LandscapeTest):
30 self.assertTrue(len(message["processors"]) > 0)30 self.assertTrue(len(message["processors"]) > 0)
3131
32 for processor in message["processors"]:32 for processor in message["processors"]:
33 self.assertTrue("processor-id" in processor)33 self.assertIn("processor-id", processor)
34 self.assertTrue("model" in processor)
3534
36 def test_call_on_accepted(self):35 def test_call_on_accepted(self):
37 """36 """
@@ -326,6 +325,22 @@ Hardware : Foundation-v8A
326 )325 )
327 self.assertEqual(processor_0["processor-id"], 0)326 self.assertEqual(processor_0["processor-id"], 0)
328327
328 def test_no_model_default(self):
329 """If there is no 'Processor' field, then the model defaults to
330 'arm'.
331 """
332 filename = self.makeFile("processor : 0\n")
333 plugin = ProcessorInfo(
334 machine_name="aarch64",
335 source_filename=filename,
336 )
337 message = plugin.create_message()
338 self.assertEqual(message["type"], "processor-info")
339 self.assertTrue(len(message["processors"]) == 1)
340
341 processor_0 = message["processors"][0]
342 self.assertEqual(processor_0["model"], "arm")
343
329344
330class SparcMessageTest(LandscapeTest):345class SparcMessageTest(LandscapeTest):
331 """Tests for sparc-specific message builder."""346 """Tests for sparc-specific message builder."""
@@ -631,3 +646,60 @@ bogomips : 1198.25
631646
632 self.mstore.set_accepted_types(["processor-info"])647 self.mstore.set_accepted_types(["processor-info"])
633 self.assertMessages(list(self.mstore.get_pending_messages()), [])648 self.assertMessages(list(self.mstore.get_pending_messages()), [])
649
650
651class RISCVMessageTest(LandscapeTest):
652 """Test for RISCV-specific message handling."""
653
654 helpers = [MonitorHelper]
655
656 VISION_FIVE = """
657processor : 0
658hart : 0
659isa : rv64imafdc
660mmu : sv39
661uarch : sifive,u74-mc
662mvendorid : 0x489
663marchid : 0x8000000000000007
664mimpid : 0x20190531
665
666processor : 1
667hart : 1
668isa : rv64imafdc
669mmu : sv39
670uarch : sifive,u74-mc
671mvendorid : 0x489
672marchid : 0x8000000000000007
673mimpid : 0x20190531
674
675 """
676
677 def setUp(self):
678 super().setUp()
679
680 self.mstore.set_accepted_types(["processor-info"])
681
682 def test_read_sample_data(self):
683 """Ensure the plugin can parse a simple /proc/cpuinfo from a VisionFive
684 v1.
685 """
686 filename = self.makeFile(self.VISION_FIVE)
687 plugin = ProcessorInfo(
688 machine_name="riscv64",
689 source_filename=filename,
690 )
691 message = plugin.create_message()
692 self.assertEqual(message["type"], "processor-info")
693 self.assertEqual(len(message["processors"]), 2)
694
695 processor_0, processor_1 = message["processors"]
696
697 self.assertEqual(len(processor_0), 3)
698 self.assertEqual(processor_0["processor-id"], 0)
699 self.assertEqual(processor_0["model"], "sifive,u74-mc")
700 self.assertEqual(processor_0["vendor"], "rv64imafdc")
701
702 self.assertEqual(len(processor_1), 3)
703 self.assertEqual(processor_1["processor-id"], 1)
704 self.assertEqual(processor_1["model"], "sifive,u74-mc")
705 self.assertEqual(processor_1["vendor"], "rv64imafdc")
diff --git a/landscape/client/monitor/tests/test_service.py b/landscape/client/monitor/tests/test_service.py
index 67289b6..8104ba6 100644
--- a/landscape/client/monitor/tests/test_service.py
+++ b/landscape/client/monitor/tests/test_service.py
@@ -1,4 +1,5 @@
1from unittest.mock import Mock1from unittest.mock import Mock
2from unittest.mock import patch
23
3from landscape.client.monitor.computerinfo import ComputerInfo4from landscape.client.monitor.computerinfo import ComputerInfo
4from landscape.client.monitor.config import ALL_PLUGINS5from landscape.client.monitor.config import ALL_PLUGINS
@@ -44,6 +45,34 @@ class MonitorServiceTest(LandscapeTest):
44 self.assertTrue(isinstance(plugins[0], ComputerInfo))45 self.assertTrue(isinstance(plugins[0], ComputerInfo))
45 self.assertTrue(isinstance(plugins[1], LoadAverage))46 self.assertTrue(isinstance(plugins[1], LoadAverage))
4647
48 def test_get_plugins_module_not_found(self):
49 """If a module is not found, a warning is logged."""
50 self.service.config.load(["--monitor-plugins", "TotallyDoesNotExist"])
51
52 with self.assertLogs(level="WARN") as cm:
53 plugins = self.service.get_plugins()
54
55 self.assertEqual(len(plugins), 0)
56 self.assertIn("Invalid monitor plugin", cm.output[0])
57 self.assertIn("TotallyDoesNotExist", cm.output[0])
58
59 def test_get_plugins_other_exception(self):
60 """If loading a plugin fails for another reason, a warning is logged,
61 with the exception.
62 """
63 self.service.config.load(["--monitor-plugins", "ComputerInfo"])
64
65 with self.assertLogs(level="WARN") as cm:
66 with patch(
67 "landscape.client.monitor.service.namedClass",
68 ) as namedClass:
69 namedClass.side_effect = Exception("Is there life on Mars?")
70 plugins = self.service.get_plugins()
71
72 self.assertEqual(len(plugins), 0)
73 self.assertIn("Unable to load", cm.output[0])
74 self.assertIn("Mars?", cm.output[0])
75
47 def test_start_service(self):76 def test_start_service(self):
48 """77 """
49 The L{MonitorService.startService} method connects to the broker,78 The L{MonitorService.startService} method connects to the broker,
diff --git a/landscape/client/monitor/tests/test_snapmonitor.py b/landscape/client/monitor/tests/test_snapmonitor.py
index 086aec6..b112702 100644
--- a/landscape/client/monitor/tests/test_snapmonitor.py
+++ b/landscape/client/monitor/tests/test_snapmonitor.py
@@ -1,8 +1,10 @@
1from unittest.mock import Mock1from unittest.mock import patch
22
3from landscape.client.monitor.snapmonitor import SnapMonitor3from landscape.client.monitor.snapmonitor import SnapMonitor
4from landscape.client.snap.http import SnapdHttpException, SnapHttp4from landscape.client.snap_http import SnapdHttpException
5from landscape.client.tests.helpers import LandscapeTest, MonitorHelper5from landscape.client.snap_http import SnapdResponse
6from landscape.client.tests.helpers import LandscapeTest
7from landscape.client.tests.helpers import MonitorHelper
68
79
8class SnapMonitorTest(LandscapeTest):10class SnapMonitorTest(LandscapeTest):
@@ -11,11 +13,19 @@ class SnapMonitorTest(LandscapeTest):
11 helpers = [MonitorHelper]13 helpers = [MonitorHelper]
1214
13 def setUp(self):15 def setUp(self):
14 super(SnapMonitorTest, self).setUp()16 super().setUp()
15 self.mstore.set_accepted_types(["snaps"])17 self.mstore.set_accepted_types(["snaps"])
1618
17 def test_get_data(self):19 @patch("landscape.client.monitor.snapmonitor.snap_http")
20 def test_get_data(self, snap_http_mock):
18 """Tests getting installed snap data."""21 """Tests getting installed snap data."""
22 snap_http_mock.list.return_value = SnapdResponse("sync", 200, "OK", [])
23 snap_http_mock.get_apps.return_value = SnapdResponse(
24 "sync",
25 200,
26 "OK",
27 [],
28 )
19 plugin = SnapMonitor()29 plugin = SnapMonitor()
20 self.monitor.add(plugin)30 self.monitor.add(plugin)
2131
@@ -30,15 +40,13 @@ class SnapMonitorTest(LandscapeTest):
30 """40 """
31 Tests that we return no data if there is an error getting it.41 Tests that we return no data if there is an error getting it.
32 """42 """
33 snap_http_mock = Mock(
34 spec=SnapHttp,
35 get_snaps=Mock(side_effect=SnapdHttpException)
36 )
37 plugin = SnapMonitor()43 plugin = SnapMonitor()
38 plugin._snap_http = snap_http_mock
39 self.monitor.add(plugin)44 self.monitor.add(plugin)
4045
41 with self.assertLogs(level="ERROR") as cm:46 with patch(
47 "landscape.client.monitor.snapmonitor.snap_http",
48 ) as snap_http_mock, self.assertLogs(level="ERROR") as cm:
49 snap_http_mock.list.side_effect = SnapdHttpException
42 plugin.exchange()50 plugin.exchange()
4351
44 messages = self.mstore.get_pending_messages()52 messages = self.mstore.get_pending_messages()
@@ -46,5 +54,60 @@ class SnapMonitorTest(LandscapeTest):
46 self.assertEqual(len(messages), 0)54 self.assertEqual(len(messages), 0)
47 self.assertEqual(55 self.assertEqual(
48 cm.output,56 cm.output,
49 ["ERROR:root:Unable to list installed snaps: "]57 ["ERROR:root:Unable to list installed snaps: "],
58 )
59
60 @patch("landscape.client.monitor.snapmonitor.snap_http")
61 def test_get_snap_config(self, snap_http_mock):
62 """Tests that we can get and coerce snap config."""
63 plugin = SnapMonitor()
64 self.monitor.add(plugin)
65
66 snap_http_mock.list.return_value = SnapdResponse(
67 "sync",
68 200,
69 "OK",
70 [
71 {
72 "name": "test-snap",
73 "revision": "1",
74 "confinement": "strict",
75 "version": "v1.0",
76 "id": "123",
77 },
78 ],
79 )
80 snap_http_mock.get_conf.return_value = SnapdResponse(
81 "sync",
82 200,
83 "OK",
84 {
85 "foo": {"baz": "default", "qux": [1, True, 2.0]},
86 "bar": "enabled",
87 },
88 )
89 snap_http_mock.get_apps.return_value = SnapdResponse(
90 "sync",
91 200,
92 "OK",
93 [],
94 )
95 plugin.exchange()
96
97 messages = self.mstore.get_pending_messages()
98
99 self.assertTrue(len(messages) > 0)
100 self.assertDictEqual(
101 messages[0]["snaps"]["installed"][0],
102 {
103 "name": "test-snap",
104 "revision": "1",
105 "confinement": "strict",
106 "version": "v1.0",
107 "id": "123",
108 "config": (
109 '{"foo": {"baz": "default", "qux": [1, true, 2.0]}, '
110 '"bar": "enabled"}'
111 ),
112 },
50 )113 )
diff --git a/landscape/client/monitor/tests/test_snapservicesmonitor.py b/landscape/client/monitor/tests/test_snapservicesmonitor.py
51new file mode 100644114new file mode 100644
index 0000000..4c86cb3
--- /dev/null
+++ b/landscape/client/monitor/tests/test_snapservicesmonitor.py
@@ -0,0 +1,127 @@
1from unittest.mock import patch
2
3from landscape.client.monitor.snapservicesmonitor import SnapServicesMonitor
4from landscape.client.snap_http import SnapdHttpException
5from landscape.client.snap_http import SnapdResponse
6from landscape.client.tests.helpers import LandscapeTest
7from landscape.client.tests.helpers import MonitorHelper
8
9
10class SnapServicesMonitorTest(LandscapeTest):
11
12 helpers = [MonitorHelper]
13
14 def setUp(self):
15 super().setUp()
16 self.mstore.set_accepted_types(["snap-services"])
17
18 @patch("landscape.client.monitor.snapservicesmonitor.snap_http")
19 def test_get_data(self, snap_http_mock):
20 """Tests getting running snap services data."""
21 snap_http_mock.get_apps.return_value = SnapdResponse(
22 "sync",
23 200,
24 "OK",
25 [
26 {
27 "snap": "test-snap",
28 "name": "bye-svc",
29 "daemon": "simple",
30 "daemon-scope": "system",
31 },
32 ],
33 )
34
35 plugin = SnapServicesMonitor()
36 self.monitor.add(plugin)
37
38 plugin.exchange()
39
40 messages = self.mstore.get_pending_messages()
41
42 self.assertTrue(len(messages) > 0)
43 self.assertIn("running", messages[0]["services"])
44
45 @patch("landscape.client.monitor.snapservicesmonitor.snap_http")
46 def test_get_snap_services(self, snap_http_mock):
47 """Tests that we can get and coerce snap services."""
48 plugin = SnapServicesMonitor()
49 self.monitor.add(plugin)
50 self.maxDiff = None
51
52 services = [
53 {
54 "snap": "test-snap",
55 "name": "bye-svc",
56 "daemon": "simple",
57 "daemon-scope": "system",
58 },
59 {
60 "snap": "test-snap",
61 "name": "hello-svc",
62 "daemon": "simple",
63 "daemon-scope": "system",
64 "active": True,
65 },
66 {
67 "activators": [
68 {
69 "Active": True,
70 "Enabled": True,
71 "Name": "unix",
72 "Type": "socket",
73 },
74 ],
75 "daemon": "simple",
76 "daemon-scope": "system",
77 "enabled": True,
78 "name": "user-daemon",
79 "snap": "lxd",
80 },
81 ]
82 snap_http_mock.list.return_value = SnapdResponse("sync", 200, "OK", [])
83 snap_http_mock.get_conf.return_value = SnapdResponse(
84 "sync",
85 200,
86 "OK",
87 {},
88 )
89 snap_http_mock.get_apps.return_value = SnapdResponse(
90 "sync",
91 200,
92 "OK",
93 services,
94 )
95 plugin.exchange()
96
97 messages = self.mstore.get_pending_messages()
98
99 self.assertTrue(len(messages) > 0)
100 self.assertCountEqual(messages[0]["services"]["running"], services)
101
102 @patch("landscape.client.monitor.snapservicesmonitor.snap_http")
103 def test_get_snap_services_error(self, snap_http_mock):
104 """Tests that we can get and coerce snap services."""
105 plugin = SnapServicesMonitor()
106 self.monitor.add(plugin)
107
108 snap_http_mock.list.return_value = SnapdResponse("sync", 200, "OK", [])
109 snap_http_mock.get_conf.return_value = SnapdResponse(
110 "sync",
111 200,
112 "OK",
113 {},
114 )
115
116 with self.assertLogs(level="WARNING") as cm:
117 snap_http_mock.get_apps.side_effect = SnapdHttpException
118 plugin.exchange()
119
120 messages = self.mstore.get_pending_messages()
121
122 self.assertTrue(len(messages) > 0)
123 self.assertEqual(
124 cm.output,
125 ["WARNING:root:Unable to list services: "],
126 )
127 self.assertCountEqual(messages[0]["services"]["running"], [])
diff --git a/landscape/client/monitor/tests/test_temperature.py b/landscape/client/monitor/tests/test_temperature.py
index 93e3b6f..472fa46 100644
--- a/landscape/client/monitor/tests/test_temperature.py
+++ b/landscape/client/monitor/tests/test_temperature.py
@@ -5,10 +5,10 @@ from unittest import mock
5from landscape.client.monitor.temperature import Temperature5from landscape.client.monitor.temperature import Temperature
6from landscape.client.tests.helpers import LandscapeTest6from landscape.client.tests.helpers import LandscapeTest
7from landscape.client.tests.helpers import MonitorHelper7from landscape.client.tests.helpers import MonitorHelper
8from landscape.lib.tests.test_sysstats import ThermalZoneTest8from landscape.lib.tests.test_sysstats import SysfsThermalZoneTest
99
1010
11class TemperatureTestWithSampleData(ThermalZoneTest, LandscapeTest):11class TemperatureTestWithSampleData(SysfsThermalZoneTest, LandscapeTest):
12 """Tests for the temperature plugin."""12 """Tests for the temperature plugin."""
1313
14 helpers = [MonitorHelper]14 helpers = [MonitorHelper]
diff --git a/landscape/client/monitor/tests/test_ubuntuproinfo.py b/landscape/client/monitor/tests/test_ubuntuproinfo.py
15deleted file mode 10064415deleted file mode 100644
index 1b8675f..0000000
--- a/landscape/client/monitor/tests/test_ubuntuproinfo.py
+++ /dev/null
@@ -1,47 +0,0 @@
1from unittest import mock
2
3from landscape.client.monitor.ubuntuproinfo import UbuntuProInfo
4from landscape.client.tests.helpers import LandscapeTest
5from landscape.client.tests.helpers import MonitorHelper
6
7
8class UbuntuProInfoTest(LandscapeTest):
9 """Ubuntu Pro info plugin tests."""
10
11 helpers = [MonitorHelper]
12
13 def setUp(self):
14 super().setUp()
15 self.mstore.set_accepted_types(["ubuntu-pro-info"])
16
17 def test_ubuntu_pro_info(self):
18 """Tests calling `ua status`."""
19 plugin = UbuntuProInfo()
20 self.monitor.add(plugin)
21
22 with mock.patch("subprocess.run") as run_mock:
23 run_mock.return_value = mock.Mock(
24 stdout='"This is a test"',
25 )
26 plugin.exchange()
27
28 messages = self.mstore.get_pending_messages()
29 run_mock.assert_called_once()
30 self.assertTrue(len(messages) > 0)
31 self.assertTrue("ubuntu-pro-info" in messages[0])
32 self.assertEqual(messages[0]["ubuntu-pro-info"], '"This is a test"')
33
34 def test_ubuntu_pro_info_no_ua(self):
35 """Tests calling `ua status` when it is not installed."""
36 plugin = UbuntuProInfo()
37 self.monitor.add(plugin)
38
39 with mock.patch("subprocess.run") as run_mock:
40 run_mock.side_effect = FileNotFoundError()
41 plugin.exchange()
42
43 messages = self.mstore.get_pending_messages()
44 run_mock.assert_called_once()
45 self.assertTrue(len(messages) > 0)
46 self.assertTrue("ubuntu-pro-info" in messages[0])
47 self.assertIn("errors", messages[0]["ubuntu-pro-info"])
diff --git a/landscape/client/monitor/tests/test_usermonitor.py b/landscape/client/monitor/tests/test_usermonitor.py
index 1fddd9a..5a009d5 100644
--- a/landscape/client/monitor/tests/test_usermonitor.py
+++ b/landscape/client/monitor/tests/test_usermonitor.py
@@ -1,6 +1,7 @@
1import os1import os
2from unittest.mock import ANY2from unittest.mock import ANY
3from unittest.mock import Mock3from unittest.mock import Mock
4from unittest.mock import patch
45
5from twisted.internet.defer import fail6from twisted.internet.defer import fail
67
@@ -208,6 +209,50 @@ class UserMonitorTest(LandscapeTest):
208 ],209 ],
209 )210 )
210211
212 @patch("landscape.client.monitor.usermonitor.IS_CORE", "1")
213 def test_new_message_after_resynchronize_event_on_core(self):
214 """
215 When a 'resynchronize' reactor event is fired, a new session is
216 created and the UserMonitor creates a new message.
217 """
218 self.provider.users = [
219 (
220 "john-doe",
The diff has been truncated for viewing.

Subscribers

People subscribed via source and target branches