Merge ~jslarraz/review-tools:add-component-support into review-tools:master

Proposed by Jorge Sancho Larraz
Status: Rejected
Rejected by: Jorge Sancho Larraz
Proposed branch: ~jslarraz/review-tools:add-component-support
Merge into: review-tools:master
Diff against target: 1223 lines (+1145/-0)
11 files modified
.launchpad.yaml (+1/-0)
bin/component-review (+120/-0)
check-names.list (+4/-0)
reviewtools/cr_component.py (+487/-0)
reviewtools/schemas/component.json (+63/-0)
reviewtools/schemas/hook.json (+50/-0)
reviewtools/sr_common.py (+1/-0)
reviewtools/tests/test_cr_component.py (+159/-0)
reviewtools/tests/test_schema_base.py (+44/-0)
reviewtools/tests/test_schema_component.py (+145/-0)
reviewtools/tests/test_schema_hook.py (+71/-0)
Reviewer Review Type Date Requested Status
Alex Murray Needs Fixing
Review via email: mp+462423@code.launchpad.net

Commit message

components: add basic support for components

Description of the change

Add initial support for components.

Example commands:

# Run tests to validate the component.json schema
python3 -m pytest reviewtools/tests/test_schema_component.py

# Run tests to validate ReviewComponent checks
python3 -m pytest reviewtools/tests/test_sr_component.py

# Run a basic review and print the report
bin/component-review tests/test.comp tests/test.snap

To post a comment you must log in.
Revision history for this message
Alex Murray (alexmurray) wrote :

Thanks @jslarraz - some initial comments - will do a more thorough review next week but so far this looks really good - just some minor things.

lpci is failing:

  :: ModuleNotFoundError: No module named 'jsonschema'

  - I think you can probably just add python3-jsonschema to the list of packages in the .launchpad.yaml to fix this

also see inline comments.

review: Needs Fixing
Revision history for this message
Alex Murray (alexmurray) wrote :

I took a more thorough look at this MR this morning - no additional comments at this time other than those previous - thanks @jslarraz.

9973adb... by Jorge Sancho Larraz

Add jsonschema to .launchpad.yaml and fix typos

Revision history for this message
Jorge Sancho Larraz (jslarraz) wrote :

New version released addressing your commentaries.

Regarding the idea of defining hooks inline in the main schema rather than using a separate file, my initial intention was to reuse the hook definition in the snap.yaml schema, so we only need to update it on one place in the future (if/when new attributes are defined for hooks). Also the validation is seamless as jsonschema follows the reference automatically.
That said, I would also be happy to include it in the main component schema if you think it would be better

5ca14a4... by Jorge Sancho Larraz

Remove test.snap and test.comp from tests/ as they trigger unintended functional tests. Updating test_cr_component.py accordingly

9172d08... by Jorge Sancho Larraz

Fix style for pylint and flake8

cc8ad0e... by Jorge Sancho Larraz

Fix check names

Revision history for this message
Callahan Kovacs (mr-cal) :
99a807c... by Jorge Sancho Larraz

Update hooks to support command_chain, environment and passthrough parameters

5d76be6... by Jorge Sancho Larraz

Fix format

a34173e... by Jorge Sancho Larraz

- Add `components` to snap optional parameters
- Change component layout check from `warn` to `info` as it is pending implementation
- Add `check_squashfs_resquash` test as a copy pasta from sr_security until `containers` are available

ae63f1b... by Jorge Sancho Larraz

- component-review output more similar to snap-review

09fd0b4... by Jorge Sancho Larraz

- simple check for file type

Unmerged commits

09fd0b4... by Jorge Sancho Larraz

- simple check for file type

Succeeded
[SUCCEEDED] test:0 (build)
[SUCCEEDED] coverage:0 (build)
12 of 2 results
ae63f1b... by Jorge Sancho Larraz

- component-review output more similar to snap-review

Succeeded
[SUCCEEDED] test:0 (build)
[SUCCEEDED] coverage:0 (build)
12 of 2 results
a34173e... by Jorge Sancho Larraz

- Add `components` to snap optional parameters
- Change component layout check from `warn` to `info` as it is pending implementation
- Add `check_squashfs_resquash` test as a copy pasta from sr_security until `containers` are available

Succeeded
[SUCCEEDED] test:0 (build)
[SUCCEEDED] coverage:0 (build)
12 of 2 results
5d76be6... by Jorge Sancho Larraz

Fix format

Succeeded
[SUCCEEDED] test:0 (build)
[SUCCEEDED] coverage:0 (build)
12 of 2 results
99a807c... by Jorge Sancho Larraz

Update hooks to support command_chain, environment and passthrough parameters

Failed
[FAILED] test:0 (build)
[WAITING] coverage:0 (build)
12 of 2 results
cc8ad0e... by Jorge Sancho Larraz

Fix check names

Succeeded
[SUCCEEDED] test:0 (build)
[SUCCEEDED] coverage:0 (build)
12 of 2 results
9172d08... by Jorge Sancho Larraz

Fix style for pylint and flake8

Failed
[FAILED] test:0 (build)
[WAITING] coverage:0 (build)
12 of 2 results
5ca14a4... by Jorge Sancho Larraz

Remove test.snap and test.comp from tests/ as they trigger unintended functional tests. Updating test_cr_component.py accordingly

Failed
[FAILED] test:0 (build)
[WAITING] coverage:0 (build)
12 of 2 results
9973adb... by Jorge Sancho Larraz

Add jsonschema to .launchpad.yaml and fix typos

Failed
[FAILED] test:0 (build)
[WAITING] coverage:0 (build)
12 of 2 results
d5aef76... by Jorge Sancho Larraz

components: add basic support for components

Failed
[FAILED] test:0 (build)
[WAITING] coverage:0 (build)
12 of 2 results

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/.launchpad.yaml b/.launchpad.yaml
index e2699a2..18fa969 100644
--- a/.launchpad.yaml
+++ b/.launchpad.yaml
@@ -16,6 +16,7 @@ jobs:
16 - jq16 - jq
17 - pylint17 - pylint
18 - python3-coverage18 - python3-coverage
19 - python3-jsonschema
19 - python3-magic20 - python3-magic
20 - python3-requests21 - python3-requests
21 - python3-ruamel.yaml22 - python3-ruamel.yaml
diff --git a/bin/component-review b/bin/component-review
22new file mode 10075523new file mode 100755
index 0000000..48a1504
--- /dev/null
+++ b/bin/component-review
@@ -0,0 +1,120 @@
1#!/usr/bin/python3
2# Author: Jorge Sancho Larraz <jorge.sancho.larraz@canonical.com>
3# Copyright (C) 2024 Canonical Ltd.
4#
5# This program is free software: you can redistribute it and/or modify
6# it under the terms of the GNU General Public License as published by
7# the Free Software Foundation; version 3 of the License.
8#
9# This program is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU General Public License for more details.
13#
14# You should have received a copy of the GNU General Public License
15# along with this program. If not, see <http://www.gnu.org/licenses/>.
16
17import argparse
18
19import sys
20import textwrap
21from reviewtools.cr_component import ComponentReview
22import json
23
24
25def print_findings(results, description):
26 """
27 Print a summary of the issues found.
28 """
29
30 if not description or not results:
31 return ""
32 print(description)
33 print("".center(len(description), "-"))
34 for key in sorted(results.keys()):
35 print(" - %s" % key)
36 print("\t%s" % results[key]["text"])
37 if "link" in results[key]:
38 print("\t%s" % results[key]["link"])
39
40def main():
41 parser = argparse.ArgumentParser(
42 prog="component-review",
43 formatter_class=argparse.RawDescriptionHelpFormatter,
44 description="Check a component package for errors",
45 epilog=textwrap.dedent(
46 """\
47 RETURN CODES
48 0 found no errors or warnings
49 1 checks not run due to fatal error
50 2 found only errors or errors and warnings
51 3 found only warnings
52 """
53 ),
54 )
55 parser.add_argument("comp_fn", type=str, help="component file to be inspected")
56 parser.add_argument("snap_fn", type=str, help="snap file the component belongs to")
57 parser.add_argument(
58 "-v", "--verbose", help="increase output verbosity", action="store_true"
59 )
60 parser.add_argument("--json", help="print json output", action="store_true")
61 parser.add_argument(
62 "--sdk",
63 help="use output format suitable for the Ubuntu SDK",
64 action="store_true",
65 )
66 parser.add_argument(
67 "overrides",
68 type=str,
69 nargs="?",
70 help="overrides to apply (eg, framework, security " "policies, etc)",
71 default=None,
72 )
73
74 args = parser.parse_args()
75
76 rc = 0
77 componentReview = ComponentReview(args.comp_fn, args.snap_fn)
78 try:
79 componentReview.do_checks()
80 # componentReview.do_report()
81 except Exception:
82 rc = 1
83
84 if args.json:
85 print(
86 json.dumps(
87 componentReview.review_report, sort_keys=True, indent=2, separators=(",", ": ")
88 )
89 )
90 elif args.sdk:
91 print("= %s =" % "snap.v2_component")
92 print(
93 json.dumps(componentReview.review_report, sort_keys=True, indent=2, separators=(",", ": "))
94 )
95 else:
96 print_findings(componentReview.review_report["error"], "Errors")
97 print_findings(componentReview.review_report["warn"], "Warnings")
98 if args.verbose:
99 print_findings(componentReview.review_report["info"], "Info")
100 if rc == 1:
101 print("%s: RUNTIME ERROR" % args.comp_fn)
102 elif componentReview.review_report["warn"] or componentReview.review_report["error"]:
103 print("%s: FAIL" % args.comp_fn)
104 else:
105 print("%s: pass" % args.comp_fn)
106
107 if componentReview.review_report["error"]:
108 rc = 2
109 elif componentReview.review_report["warn"]:
110 rc = 3
111
112 sys.exit(rc)
113
114
115if __name__ == "__main__":
116 try:
117 main()
118 except KeyboardInterrupt:
119 print("Aborted.")
120 sys.exit(1)
diff --git a/check-names.list b/check-names.list
index deb608f..251ccd1 100644
--- a/check-names.list
+++ b/check-names.list
@@ -1,3 +1,7 @@
1component-v2:component_hooks|
2component-v2:component_to_snap_relation|
3component-v2:snap_to_component_relation|
4component-v2:validate_component_schema|
1declaration-snap-v2:app_slot_known|5declaration-snap-v2:app_slot_known|
2declaration-snap-v2:interface-reference|6declaration-snap-v2:interface-reference|
3declaration-snap-v2:plugs_connection|7declaration-snap-v2:plugs_connection|
diff --git a/reviewtools/cr_component.py b/reviewtools/cr_component.py
4new file mode 1006448new file mode 100644
index 0000000..f76121e
--- /dev/null
+++ b/reviewtools/cr_component.py
@@ -0,0 +1,487 @@
1# Author: Jorge Sancho Larraz <jorge.sancho.larraz@canonical.com>
2# Copyright (C) 2024 Canonical Ltd.
3#
4# This program is free software: you can redistribute it and/or modify
5# it under the terms of the GNU General Public License as published by
6# the Free Software Foundation; version 3 of the License.
7#
8# This program is distributed in the hope that it will be useful,
9# but WITHOUT ANY WARRANTY; without even the implied warranty of
10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11# GNU General Public License for more details.
12#
13# You should have received a copy of the GNU General Public License
14# along with this program. If not, see <http://www.gnu.org/licenses/>.
15
16import os
17import json
18import jsonschema
19import ruamel.yaml
20
21from reviewtools.common import Review, ReviewException, error, open_file_read, cleanup_unpack
22from reviewtools.sr_common import SnapReview
23from reviewtools.sr_lint import SnapReviewLint
24
25# Repack test
26import re
27import sys
28import shutil
29import copy
30from reviewtools.common import (
31 set_lang,
32 restore_lang,
33 cmd,
34 cmdIgnoreErrorStrings,
35 create_tempdir,
36 MKSQUASHFS_DEFAULT_COMPRESSION,
37 MKSQUASHFS_OPTS,
38 UNSQUASHFS_IGNORED_ERRORS,
39 unsquashfs_supports_ignore_errors,
40 StatLLN,
41 SNAP_MINIMUM_SIZE,
42)
43from reviewtools.overrides import (
44 sec_mode_overrides,
45 sec_resquashfs_overrides,
46)
47
48
49#
50# Utility classes
51#
52class ComponentReviewException(ReviewException):
53 """This class represents ComponentReview exceptions"""
54
55
56class ComponentReview(SnapReview):
57 """This class represents component reviews"""
58
59 def __init__(self, comp_fn, snap_fn, overrides=None):
60
61 # Run checks for snap.yaml
62 self.snapReviewLint = SnapReviewLint(snap_fn)
63 self.snapReviewLint.do_checks()
64 self.snap_yaml = self.snapReviewLint.snap_yaml
65 cleanup_unpack()
66
67 # Check 'fn' file exists and extract it
68 Review.__init__(self, comp_fn, "component-v2", overrides=overrides)
69
70 # initialize ruamel.yaml loader
71 yaml = ruamel.yaml.YAML(typ="safe")
72 yaml.allow_duplicate_keys = False
73
74 # Load meta/component.yaml
75 component_yaml = self._extract_component_yaml()
76 try:
77 self.component_yaml = yaml.load(component_yaml)
78 except ruamel.yaml.constructor.DuplicateKeyError as e:
79 raise ComponentReviewException(e.problem)
80 except Exception: # pragma: nocover
81 error("Could not load component.yaml. Does it exists?")
82 component_yaml.close()
83
84 # Load schema
85 with open("reviewtools/schemas/component.json") as fd:
86 self.component_schema = json.loads(fd.read())
87
88 # def check_component_file_name(self, fn):
89 # # Check component file name is valid. Update it if regex gets approved
90 # # ^(?=.{2,40}\+.{2,40}\_.*$)^((?:[a-z0-9]+-?)*[a-z](?:-?[a-z0-9])*)\+((?:[a-z0-9]+-?)*[a-z](?:-?[a-z0-9])*)\_\d\.comp)$
91 # aux = fn.split('.')
92 # if len(aux) != 2:
93 # error("Wrong component file name. It must be '<snap name>+<component name>_<component revision>.comp'.")
94 # if aux[1] != "comp":
95 # error("Wrong component file name. Component file extension must be '.comp'.")
96 # aux = aux[0].split('_')
97 # if len(aux) != 2:
98 # error("Wrong component file name. It must be '<snap name>+<component name>_<component revision>.comp'.")
99 # if not aux[1].isnumeric():
100 # error("Wrong component file name. Revision must be numeric")
101 # aux = aux[0].split('+')
102 # if len(aux) != 2:
103 # error("Wrong component file name. It must be '<snap name>+<component name>_<component revision>.comp'.")
104 # if not self._verify_pkgname(aux[1]):
105 # error("Wrong component file name. Component name must be in format ^(?:[a-z0-9]+-?)*[a-z](?:-?[a-z0-9])*$.")
106 # if not self._verify_pkgname(aux[0]):
107 # error("Wrong component file name. Snap name must be in format ^(?:[a-z0-9]+-?)*[a-z](?:-?[a-z0-9])*$.")
108
109 # Since coverage is looked at via the testsuite and the testsuite mocks
110 # this out, don't cover this
111 def _extract_component_yaml(self): # pragma: nocover
112 """Extract and read the component.yaml"""
113 y = os.path.join(self.unpack_dir, "meta/component.yaml")
114 if not os.path.isfile(y):
115 error("Could not find component.yaml.")
116 return open_file_read(y)
117
118 def check_component_schema(self):
119 # Load component schema and validate
120 t = "info"
121 n = self._get_check_name("validate_component_schema")
122 s = "OK"
123 try:
124 jsonschema.validate(self.component_yaml, self.component_schema)
125 except Exception as e:
126 t = "error"
127 s = e.message
128 self._add_result(t, n, s)
129
130 def check_component_to_snap_relation(self):
131 t = "info"
132 n = self._get_check_name("component_to_snap_relation")
133 s = "OK"
134 if not self.component_yaml["component"].startswith(self.snap_yaml["name"] + '+'):
135 t = "error"
136 s = ("'component' field in 'component.yaml' does not start with <snap name>. However, "
137 "its format must be '<snap name>+<component name>'.")
138 self._add_result(t, n, s)
139
140 def check_snap_to_component_relation(self):
141 t = "info"
142 n = self._get_check_name("snap_to_component_relation")
143 s = "OK"
144 if "components" not in self.snap_yaml or \
145 not self.component_yaml["component"].split("+")[1] in self.snap_yaml["components"]:
146 t = "error"
147 s = "Missing component '%s' in snap.yaml:components" % self.component_yaml["component"].split("+")[1]
148 self._add_result(t, n, s)
149
150 def check_hooks(self):
151 # Verify only allowed hooks exist
152 if os.path.isdir(self.unpack_dir + "/meta/hooks"):
153 for hook in os.listdir(self.unpack_dir + "/meta/hooks"):
154 if hook not in self.component_schema["properties"]["hooks"]["propertyNames"]["enum"] + [".", ".."]:
155 t = "error"
156 n = self._get_check_name("component_hooks")
157 s = "Unexpected hook '%s' found in /meta/hooks directory" % hook
158 self._add_result(t, n, s)
159
160 def check_component_layout(self):
161 t = "info"
162 n = self._get_check_name("component_layout")
163 s = "this check is not implemented yet"
164 self._add_result(t, n, s)
165
166 def check_file_types(self):
167 # todo: simple check for file type, improve it once containers are merged
168 errors = []
169 unsquashfs_lln_hdr, unsquashfs_lln_entries = self._unsquashfs_lln(self.pkg_filename)
170 for (line, item) in unsquashfs_lln_entries:
171 if item is None:
172 continue
173
174 fname = item[StatLLN.FILENAME]
175 ftype = item[StatLLN.FILETYPE]
176 if ftype not in ["d", "-", "l"]:
177 errors.append("file type '%s' not allowed (%s)" % (ftype, fname))
178 continue
179
180 t = "info"
181 n = self._get_check_name("squashfs_files")
182 s = "OK"
183 if len(errors) > 0:
184 t = "error"
185 s = "found errors in file output: %s" % ", ".join(errors)
186 self._add_result(t, n, s)
187
188 def _unsquashfs_stat(self, snap_pkg):
189 """Run unsquashfs -stat on a snap package"""
190 (origLANG, origLC_ALL) = set_lang("C.UTF-8", "C.UTF-8")
191 (rc, out) = cmd(["unsquashfs", "-stat", snap_pkg])
192 restore_lang(origLANG, origLC_ALL)
193 return rc, out
194
195 def check_squashfs_resquash(self):
196 """Check resquash of squashfs"""
197 fn = os.path.abspath(self.pkg_filename)
198
199 # Verify squashfs has no fragments. If it does, it will not resquash
200 # properly (LP: #1576763). This stat output for fragments has been
201 # stable for at least the last 7 years, so just parse it. If it changes
202 # we can consider examing the superblock directly.
203 (rc, out) = self._unsquashfs_stat(fn)
204 if rc != 0:
205 t = "error"
206 n = self._get_check_name("squashfs_stat")
207 s = "could not stat squashfs"
208 self._add_result(t, n, s)
209 return
210
211 comp = None
212 comp_pat = re.compile(r"^Compression [a-z0-9]+$")
213 for line in out.splitlines():
214 if comp_pat.search(line):
215 comp = line.split()[1]
216 if comp is None:
217 t = "error"
218 n = self._get_check_name("squashfs_compression")
219 s = "could not determine compression algorithm"
220 self._add_result(t, n, s)
221 return
222 elif comp not in self.supported_compression_algorithms:
223 t = "error"
224 n = self._get_check_name("squashfs_compression")
225 s = "unsupported compression algorithm '%s'" % comp
226 self._add_result(t, n, s)
227 return
228
229 if "\nNumber of fragments 0\n" not in out and (
230 "SNAP_ENFORCE_RESQUASHFS" not in os.environ
231 or (
232 "SNAP_ENFORCE_RESQUASHFS" in os.environ
233 and os.environ["SNAP_ENFORCE_RESQUASHFS"] != "0"
234 )
235 ):
236 link = "https://forum.snapcraft.io/t/automated-reviews-and-snapcraft-2-38/4982/17"
237 t = "error"
238 n = self._get_check_name("squashfs_fragments")
239 s = (
240 "The squashfs was built without '-no-fragments'. Please "
241 + "ensure the snap is created with either 'snapcraft pack "
242 + "<DIR>' (using snapcraft >= 2.38) or 'mksquashfs <dir> "
243 + "<snap> %s'" % " ".join(MKSQUASHFS_OPTS)
244 + ". If using electron-builder, "
245 "please upgrade to latest stable (>= 20.14.7). See %s "
246 "for details." % link
247 )
248
249 if self.snap_yaml["name"] in sec_resquashfs_overrides:
250 t = "info"
251 s = "OK (check not enforced for this snap): %s" % s
252
253 self._add_result(t, n, s)
254 return
255
256 # Verify squashfs supports the -fstime option, if not, warn (which
257 # blocks in store)
258 (rc, out) = cmd(["unsquashfs", "-fstime", fn])
259 if rc != 0:
260 t = "warn"
261 n = self._get_check_name("squashfs_supports_fstime")
262 s = "could not determine fstime of squashfs"
263 self._add_result(t, n, s)
264 return
265 fstime = out.strip()
266
267 if (
268 "SNAP_ENFORCE_RESQUASHFS" in os.environ
269 and os.environ["SNAP_ENFORCE_RESQUASHFS"] == "0"
270 ):
271 t = "info"
272 n = self._get_check_name("squashfs_repack_checksum")
273 s = "OK (check not enforced)"
274 self._add_result(t, n, s)
275 return
276
277 tmpdir = create_tempdir() # this is autocleaned
278 tmp_unpack = os.path.join(tmpdir, "squashfs-root")
279 tmp_repack = os.path.join(tmpdir, "repack.snap")
280 fakeroot_env = os.path.join(tmpdir, "fakeroot.env")
281
282 # Don't use -all-root since the snap might have other users in it
283 # NOTE: adding -no-xattrs here causes resquashfs to fail (unsquashfs
284 # and mksquash use -xattrs by default. By specifying -no-xattrs to
285 # unsquashfs/mksquashfs, we enforce not supportinging them since the
286 # checksums will always be different because the original squash would
287 # have them but the repack would not). If we ever decide to support
288 # xattrs in snaps, would have to see why thre requash fails with
289 # -xattrs.
290 mksquashfs_ignore_opts = ["-all-root"]
291
292 curdir = os.getcwd()
293 os.chdir(tmpdir)
294 # ensure we don't alter the permissions from the unsquashfs
295 old_umask = os.umask(000)
296
297 fakeroot_cmd = []
298 if "SNAP_FAKEROOT_RESQUASHFS" in os.environ:
299 # We could use -l $SNAP/usr/lib/... --faked $SNAP/usr/bin/faked if
300 # os.environ['SNAP'] is set, but instead we let the snap packaging
301 # make fakeroot work correctly and keep this simple.
302 fakeroot_cmd = ["fakeroot", "--unknown-is-real"]
303
304 if shutil.which(fakeroot_cmd[0]) is None: # pragma: nocover
305 t = "error"
306 n = self._get_check_name("has_fakeroot")
307 s = "Could not find 'fakeroot' command"
308 self._add_result(t, n, s)
309 return
310
311 try:
312 fakeroot_args = []
313 mksquash_opts = copy.copy(MKSQUASHFS_OPTS)
314 if comp != MKSQUASHFS_DEFAULT_COMPRESSION:
315 idx = mksquash_opts.index(MKSQUASHFS_DEFAULT_COMPRESSION)
316 mksquash_opts[idx] = comp
317
318 if "SNAP_FAKEROOT_RESQUASHFS" in os.environ:
319 # run unsquashfs under fakeroot, saving the session to be
320 # reused by mksquashfs and thus preserving
321 # uids/gids/devices/etc
322 fakeroot_args = ["-s", fakeroot_env]
323
324 mksquash_opts = []
325 for i in MKSQUASHFS_OPTS:
326 if i not in mksquashfs_ignore_opts:
327 mksquash_opts.append(i)
328
329 cmdline = (
330 fakeroot_cmd
331 + fakeroot_args
332 + ["unsquashfs", "-no-progress", "-d", tmp_unpack]
333 )
334 if unsquashfs_supports_ignore_errors():
335 cmdline.append("-ignore-errors")
336 cmdline.append("-quiet")
337 cmdline.append(fn)
338
339 (rc, out) = cmdIgnoreErrorStrings(cmdline, UNSQUASHFS_IGNORED_ERRORS)
340 if rc != 0:
341 raise ReviewException(
342 "could not unsquash '%s': %s" % (os.path.basename(fn), out)
343 )
344
345 fakeroot_args = []
346 if "SNAP_FAKEROOT_RESQUASHFS" in os.environ:
347 fakeroot_args = ["-i", fakeroot_env]
348
349 cmdline = (
350 fakeroot_cmd
351 + fakeroot_args
352 + ["mksquashfs", tmp_unpack, tmp_repack, "-fstime", fstime]
353 + mksquash_opts
354 )
355
356 (rc, out) = cmd(cmdline)
357 if rc != 0:
358 raise ReviewException(
359 "could not mksquashfs '%s': %s"
360 % (os.path.relpath(tmp_unpack, tmpdir), out)
361 )
362 except ReviewException as e:
363 t = "error"
364 n = self._get_check_name("squashfs_resquash")
365 self._add_result(t, n, str(e))
366 return
367 finally:
368 os.umask(old_umask)
369 os.chdir(curdir)
370
371 # Now calculate the hashes
372 t = "info"
373 n = self._get_check_name("squashfs_repack_checksum")
374 s = "OK"
375 link = None
376
377 (rc, out) = cmd(["sha512sum", fn])
378 if rc != 0:
379 t = "error"
380 s = "could not determine checksum of '%s'" % os.path.basename(fn)
381 self._add_result(t, n, s)
382 return
383 orig_sum = out.split()[0]
384
385 # If repacked package is smaller then original one, that means it's
386 # truncated with the minimum size.
387 if os.stat(fn).st_size == SNAP_MINIMUM_SIZE and (
388 os.stat(tmp_repack).st_size < os.stat(fn).st_size):
389 with open(tmp_repack, 'ab') as f:
390 f.truncate(SNAP_MINIMUM_SIZE)
391
392 (rc, out) = cmd(["sha512sum", tmp_repack])
393 if rc != 0:
394 t = "error"
395 s = "could not determine checksum of '%s'" % os.path.relpath(
396 tmp_repack, tmpdir
397 )
398 self._add_result(t, n, s)
399 return
400 repack_sum = out.split()[0]
401
402 if orig_sum != repack_sum:
403 if "SNAP_DEBUG_RESQUASHFS" in os.environ:
404 print(self._debug_resquashfs(tmpdir, fn, tmp_repack), file=sys.stderr)
405
406 if os.environ["SNAP_DEBUG_RESQUASHFS"] == "2": # pragma: nocover
407 import subprocess
408
409 print(
410 "\nIn debug shell. tmpdir=%s, orig=%s, repack=%s"
411 % (tmpdir, fn, tmp_repack)
412 )
413 subprocess.call(["bash"])
414
415 if "type" in self.snap_yaml and (
416 self.snap_yaml["type"] == "base" or self.snap_yaml["type"] == "os"
417 ):
418 mksquash_opts = []
419 for i in MKSQUASHFS_OPTS:
420 if i not in mksquashfs_ignore_opts:
421 mksquash_opts.append(i)
422 else:
423 mksquash_opts = MKSQUASHFS_OPTS
424
425 link = "https://forum.snapcraft.io/t/automated-reviews-and-snapcraft-2-38/4982/17"
426 t = "error"
427 s = (
428 "checksums do not match. Please ensure the snap is "
429 + "created with either 'snapcraft pack <DIR>' (using "
430 + "snapcraft >= 2.38) or 'mksquashfs <dir> <snap> %s'"
431 % " ".join(mksquash_opts)
432 + " (using squashfs-tools >= 4.3). If using electron-builder, "
433 "please upgrade to latest stable (>= 20.14.7). See %s "
434 "for details." % link
435 )
436
437 pkgname = self.snap_yaml["name"]
438 if pkgname in sec_resquashfs_overrides:
439 t = "info"
440 s = "OK (check not enforced for this snap): %s" % s
441
442 # FIXME: fakeroot sporadically fails and saves the wrong
443 # uid/gid/mode into its save file, thus causing the mksquashfs to
444 # create the wrong file/perms/ownership. We want to not ignore this
445 # error when using fakeroot. We need fakeroot or something like it
446 # for unsquashfs to create devices, perms and ownership as
447 # non-root, but only base and os snaps are allowed to have devices
448 # and not use -all-root. Certain app snaps may also have
449 # sec_mode_overrides for setuid/setgid files. Therefore, when not
450 # using fakeroot, only enforce resquash for non-os/base snaps
451 # and other snaps without sec_mode_overrides that specify
452 # setuid/setgid. Eventually we'll fix fakeroot or do something else
453 # so we can use this for all snaps, with or without -all-root.
454 if "SNAP_FAKEROOT_RESQUASHFS" not in os.environ:
455 if self.snap_yaml["type"] in ["base", "os"]:
456 t = "info"
457 s = "OK (check not enforced for base and os snaps)"
458 link = None
459 elif pkgname in sec_mode_overrides:
460 has_sugid_override = False
461 setugid_pat = re.compile(r"[sS]")
462 for k in sec_mode_overrides[pkgname]:
463 if isinstance(sec_mode_overrides[pkgname][k], list):
464 for m in sec_mode_overrides[pkgname][k]:
465 if setugid_pat.search(m):
466 has_sugid_override = True
467 break
468 if has_sugid_override:
469 break
470 elif setugid_pat.search(sec_mode_overrides[pkgname][k]):
471 has_sugid_override = True
472 break
473 if has_sugid_override:
474 t = "info"
475 s = (
476 "OK (check not enforced for app snaps with "
477 + "setuid/setgid overrides)"
478 )
479 link = None
480
481 self._add_result(t, n, s, link)
482
483
484if __name__ == "__main__":
485 # ComponentReview("tests/sample.comp", "tests/busybox-static-mvo_2.snap", None)
486 componentReview = ComponentReview("tests/wrong_arch.comp", "tests/busybox-static-mvo_2.snap", None)
487 print(componentReview.snap_yaml)
diff --git a/reviewtools/schemas/component.json b/reviewtools/schemas/component.json
0new file mode 100644488new file mode 100644
index 0000000..6822244
--- /dev/null
+++ b/reviewtools/schemas/component.json
@@ -0,0 +1,63 @@
1{
2 "type": "object",
3 "properties": {
4 "component": {
5 "type": "string",
6 "pattern": "^(?=.{2,40}\\+.{2,40}$)^((?:[a-z0-9]+-?)*[a-z](?:-?[a-z0-9])*)\\+((?:[a-z0-9]+-?)*[a-z](?:-?[a-z0-9])*)$"
7 },
8 "type": {
9 "type": "string",
10 "enum": ["kernel-modules", "test"]
11 },
12 "architectures": {
13 "type": "array",
14 "uniqueItems": true,
15 "items": {
16 "type": "string",
17 "enum": ["all", "amd64", "arm64", "armhf", "i386", "powerpc", "ppc64el", "riscv64", "s390x"],
18 "minLength": 1
19 },
20 "minContains": 1
21 },
22 "summary": {
23 "type": "string",
24 "maxLength": 128
25 },
26 "description": {
27 "type": "string",
28 "maxLength": 4096
29 },
30 "version": {
31 "type": "string",
32 "description": "Snap version according to https://forum.snapcraft.io/t/snapd-enforcing-snap-versions/3974",
33 "pattern": "^[a-zA-Z0-9](?:[a-zA-Z0-9:.+~-]{0,30}[a-zA-Z0-9+~])?$"
34 },
35 "hooks": {
36 "type": "object",
37 "propertyNames": {
38 "enum": ["install", "pre-refresh", "post-refresh", "remove"]
39 },
40 "additionalProperties": {
41 "$ref": "file:reviewtools/schemas/hook.json"
42 }
43 }
44 },
45 "additionalProperties": false,
46
47 "if": {
48 "properties": {
49 "type": {
50 "const": "kernel-modules"
51 }
52 },
53 "required": ["type"]
54 },
55 "then": {
56 "required": ["component", "type", "summary", "description", "architectures"]
57 },
58 "else": {
59 "required": ["component", "type", "summary", "description"]
60 }
61}
62
63
diff --git a/reviewtools/schemas/hook.json b/reviewtools/schemas/hook.json
0new file mode 10064464new file mode 100644
index 0000000..f2e21c8
--- /dev/null
+++ b/reviewtools/schemas/hook.json
@@ -0,0 +1,50 @@
1{
2 "type": "object",
3 "properties": {
4 "plugs": {
5 "type": "array",
6 "uniqueItems": true,
7 "items": {
8 "type": ["string", "object"],
9 "minLength": 1
10 },
11 "minItems": 1
12 },
13 "command_chain": {
14 "type": "array",
15 "uniqueItems": true,
16 "items": {
17 "type": "string",
18 "minLength": 1
19 },
20 "minItems": 1
21 },
22 "environment": {
23 "$comment": "See https://pubs.opengroup.org/onlinepubs/000095399/basedefs/xbd_chap08.html",
24 "type": "object",
25 "patternProperties": {
26 "^[a-zA-Z_][a-zA-Z0-9_]*$": {
27 "type": "string",
28 "pattern": "^[^\\0]*$",
29 "minLength": 1
30 }
31 },
32 "additionalProperties": false,
33 "minProperties": 1
34 },
35 "passthrough": {
36 "type": "object",
37 "additionalProperties": {
38 "type": "array",
39 "uniqueItems": true,
40 "items": {
41 "type": "string",
42 "minLength": 1
43 },
44 "minItems": 1
45 },
46 "minProperties": 1
47 }
48 },
49 "additionalProperties": false
50}
diff --git a/reviewtools/sr_common.py b/reviewtools/sr_common.py
index 4d33459..dc445bf 100644
--- a/reviewtools/sr_common.py
+++ b/reviewtools/sr_common.py
@@ -68,6 +68,7 @@ class SnapReview(Review):
68 "slots",68 "slots",
69 "system-usernames",69 "system-usernames",
70 "links",70 "links",
71 "components",
71 ]72 ]
7273
73 snap_manifest_required = {"build-packages": []}74 snap_manifest_required = {"build-packages": []}
diff --git a/reviewtools/tests/test_cr_component.py b/reviewtools/tests/test_cr_component.py
74new file mode 10064475new file mode 100644
index 0000000..4cfdd7c
--- /dev/null
+++ b/reviewtools/tests/test_cr_component.py
@@ -0,0 +1,159 @@
1# Author: Jorge Sancho Larraz <jorge.sancho.larraz@canonical.com>
2# Copyright (C) 2024 Canonical Ltd.
3#
4# This program is free software: you can redistribute it and/or modify
5# it under the terms of the GNU General Public License as published by
6# the Free Software Foundation; version 3 of the License.
7#
8# This program is distributed in the hope that it will be useful,
9# but WITHOUT ANY WARRANTY; without even the implied warranty of
10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11# GNU General Public License for more details.
12#
13# You should have received a copy of the GNU General Public License
14# along with this program. If not, see <http://www.gnu.org/licenses/>.
15
16import os
17import tempfile
18import shutil
19import yaml
20from reviewtools.cr_component import ComponentReview
21from reviewtools.common import cleanup_unpack
22from reviewtools.sr_tests import TestSnapReview
23from subprocess import run
24
25
26class TestComponent(TestSnapReview):
27
28 def setUp(self):
29
30 # Ensure YAMLs are properly initialized on each test
31 self.snap_yaml = {
32 "name": "test-snap",
33 "version": "0.1",
34 "description": "Test description",
35 "summary": "Test summary",
36 "architectures": ["all"],
37 "apps": {
38 "bar": {
39 "command": "bin/bar"
40 }
41 }
42 }
43
44 self.component_yaml = {
45 "component": "test-snap+test-component",
46 "type": "test",
47 "architectures": ["all"],
48 "summary": "Test summary",
49 "description": "Test description",
50 "version": "0.1",
51 }
52
53 self.tmp_dir = tempfile.mkdtemp()
54 self._create_snap(self.snap_yaml)
55 self._create_component(self.component_yaml)
56
57 def tearDown(self):
58 cleanup_unpack()
59 shutil.rmtree(self.tmp_dir)
60
61 def _create_snap(self, snap_yaml, hooks=[]):
62 if os.path.exists(os.path.join(self.tmp_dir, "snap")):
63 shutil.rmtree(os.path.join(self.tmp_dir, "snap"))
64 os.makedirs(os.path.join(self.tmp_dir, "snap/meta"))
65 with open(os.path.join(self.tmp_dir, "snap/meta/snap.yaml"), "w+") as fd:
66 fd.write(yaml.dump(snap_yaml))
67 os.makedirs(os.path.join(self.tmp_dir, "snap/meta/hooks"))
68 for hook in hooks:
69 with open(os.path.join(self.tmp_dir, "component/meta/hooks/", hook), "w+") as fd:
70 fd.write("#!/bin/bash\n")
71 if os.path.exists(os.path.join(self.tmp_dir, "test.snap")):
72 os.unlink(os.path.join(self.tmp_dir, "test.snap"))
73 run(["mksquashfs", os.path.join(self.tmp_dir, "snap"), os.path.join(self.tmp_dir, "test.snap")])
74
75 def _create_component(self, component_yaml, hooks=[]):
76 if os.path.exists(os.path.join(self.tmp_dir, "component")):
77 shutil.rmtree(os.path.join(self.tmp_dir, "component"))
78 os.makedirs(os.path.join(self.tmp_dir, "component/meta"))
79 with open(os.path.join(self.tmp_dir, "component/meta/component.yaml"), "w+") as fd:
80 fd.write(yaml.dump(component_yaml))
81 os.makedirs(os.path.join(self.tmp_dir, "component/meta/hooks"))
82 for hook in hooks:
83 with open(os.path.join(self.tmp_dir, "component/meta/hooks/", hook), "w+") as fd:
84 fd.write("#!/bin/bash\n")
85 if os.path.exists(os.path.join(self.tmp_dir, "test.comp")):
86 os.unlink(os.path.join(self.tmp_dir, "test.comp"))
87 run(["mksquashfs", os.path.join(self.tmp_dir, "component"), os.path.join(self.tmp_dir, "test.comp")])
88
89 def test_component_schema__happy(self):
90 c = ComponentReview(os.path.join(self.tmp_dir, "test.comp"), os.path.join(self.tmp_dir, "test.snap"))
91 c.check_component_schema()
92 r = c.review_report
93 expected_counts = {"info": 1, "warn": 0, "error": 0}
94 self.check_results(r, expected_counts)
95
96 def test_component_schema__invalid_title(self):
97 self.component_yaml["title"] = "a" * 41
98 self._create_component(self.snap_yaml)
99 c = ComponentReview(os.path.join(self.tmp_dir, "test.comp"), os.path.join(self.tmp_dir, "test.snap"))
100 c.check_component_schema()
101 r = c.review_report
102 expected_counts = {"info": 0, "warn": 0, "error": 1}
103 self.check_results(r, expected_counts)
104
105 def test_component_to_snap_relation__happy(self):
106 c = ComponentReview(os.path.join(self.tmp_dir, "test.comp"), os.path.join(self.tmp_dir, "test.snap"))
107 c.check_component_to_snap_relation()
108 r = c.review_report
109 expected_counts = {"info": 1, "warn": 0, "error": 0}
110 self.check_results(r, expected_counts)
111
112 def test_component_to_snap_relation__invalid(self):
113 self.snap_yaml["name"] = "invalid-name"
114 self._create_snap(self.snap_yaml)
115 c = ComponentReview(os.path.join(self.tmp_dir, "test.comp"), os.path.join(self.tmp_dir, "test.snap"))
116 c.check_component_to_snap_relation()
117 r = c.review_report
118 expected_counts = {"info": 0, "warn": 0, "error": 1}
119 self.check_results(r, expected_counts)
120
121 def test_snap_to_component_relation__happy(self):
122 self.snap_yaml["components"] = {}
123 self.snap_yaml["components"]["test-component"] = self.component_yaml
124 self._create_snap(self.snap_yaml)
125 c = ComponentReview(os.path.join(self.tmp_dir, "test.comp"), os.path.join(self.tmp_dir, "test.snap"))
126 c.check_snap_to_component_relation()
127 r = c.review_report
128 expected_counts = {"info": 1, "warn": 0, "error": 0}
129 self.check_results(r, expected_counts)
130
131 def test_snap_to_component_relation__missing(self):
132 c = ComponentReview(os.path.join(self.tmp_dir, "test.comp"), os.path.join(self.tmp_dir, "test.snap"))
133 c.check_snap_to_component_relation()
134 r = c.review_report
135 expected_counts = {"info": 0, "warn": 0, "error": 1}
136 self.check_results(r, expected_counts)
137
138 def test_hooks__no_hook(self):
139 c = ComponentReview(os.path.join(self.tmp_dir, "test.comp"), os.path.join(self.tmp_dir, "test.snap"))
140 c.check_hooks()
141 r = c.review_report
142 expected_counts = {"info": 0, "warn": 0, "error": 0}
143 self.check_results(r, expected_counts)
144
145 def test_hooks__good_hook(self):
146 self._create_component(self.component_yaml, hooks=["install"])
147 c = ComponentReview(os.path.join(self.tmp_dir, "test.comp"), os.path.join(self.tmp_dir, "test.snap"))
148 c.check_hooks()
149 r = c.review_report
150 expected_counts = {"info": 0, "warn": 0, "error": 0}
151 self.check_results(r, expected_counts)
152
153 def test_hooks__invalid_hook(self):
154 self._create_component(self.component_yaml, hooks=["invalid_hook"])
155 c = ComponentReview(os.path.join(self.tmp_dir, "test.comp"), os.path.join(self.tmp_dir, "test.snap"))
156 c.check_hooks()
157 r = c.review_report
158 expected_counts = {"info": 0, "warn": 0, "error": 1}
159 self.check_results(r, expected_counts)
diff --git a/reviewtools/tests/test_schema_base.py b/reviewtools/tests/test_schema_base.py
0new file mode 100644160new file mode 100644
index 0000000..ed5b7a3
--- /dev/null
+++ b/reviewtools/tests/test_schema_base.py
@@ -0,0 +1,44 @@
1import json
2import jsonschema
3import copy
4import unittest
5
6
7class SafeDict(dict):
8 def __missing__(self, key):
9 return '{' + key + '}'
10
11
12class TestSchemaBase(unittest.TestCase):
13
14 yaml = {}
15 schema_file = "reviewtools/schemas/****.json"
16
17 def setUp(self):
18
19 with open(self.schema_file) as fd:
20 self.schema = json.loads(fd.read())
21
22 def _validate(self, yaml, schema):
23 try:
24 jsonschema.validate(yaml, schema)
25 return None
26 except jsonschema.ValidationError as e:
27 return e.message
28
29 def _test_base(self):
30 error = self._validate(self.yaml, self.schema)
31 self.assertEqual(None, error)
32
33 def _test_value(self, key, value, expected_error):
34 yaml = copy.deepcopy(self.yaml)
35 if value is None:
36 if key in yaml:
37 del yaml[key]
38 else:
39 yaml[key] = value
40 error = self._validate(yaml, self.schema)
41 if expected_error is None:
42 self.assertIsNone(error)
43 else:
44 self.assertIn(expected_error, error)
diff --git a/reviewtools/tests/test_schema_component.py b/reviewtools/tests/test_schema_component.py
0new file mode 10064445new file mode 100644
index 0000000..ee2280f
--- /dev/null
+++ b/reviewtools/tests/test_schema_component.py
@@ -0,0 +1,145 @@
1
2from reviewtools.tests.test_schema_base import TestSchemaBase, SafeDict
3import copy
4
5
6class TestSchemaComponent(TestSchemaBase):
7
8 required_properties = ["component", "type", "summary", "description"]
9 optional_properties = ["architectures", "version", "hooks"]
10
11 schema_file = "reviewtools/schemas/component.json"
12 yaml = {
13 "component": "snap+component",
14 "type": "test",
15 "architectures": ["amd64", "arm64"],
16 "summary": "The super cat generator",
17 "description": "A more in-depth look at what your snap does and who may find it most useful.",
18 "version": "2.01",
19 "hooks": {
20 "install": {
21 "plugs": ["test_plug"]
22 },
23 "remove": {
24 "plugs": ["test_plug"]
25 }
26 }
27 }
28
29 def test_base(self):
30 self._test_base()
31
32 def test_component(self):
33 for value, error in [
34 ("test-snap+test-component", None),
35 ("a", "'{value}' does not match "),
36 ("a" * 41, "'{value}' does not match "),
37 ("-aaa", "'{value}' does not match "),
38 ("aaa-", "'{value}' does not match "),
39 ("000", "'{value}' does not match "),
40 ("aa--aa", "'{value}' does not match "),
41 ("aa%a", "'{value}' does not match "),
42 (2, "{value} is not of type 'string'"),
43 ([], "{value} is not of type 'string'")
44 ]:
45 with self.subTest(value=value):
46 error = error.format_map(SafeDict(value=value)) if error else None
47 self._test_value("component", value, error)
48
49 def test_type(self):
50 for value, error in [
51 ("kernel-modules", None),
52 ("invalid_type", "'{value}' is not one of "),
53 (2, "{value} is not of type 'string'"),
54 ([], "{value} is not of type 'string'")
55 ]:
56 with self.subTest(value=value):
57 error = error.format_map(SafeDict(value=value)) if error else None
58 self._test_value("type", value, error)
59
60 def test_architectures(self):
61 for value, error in [
62 (["amd64"], None),
63 (["invalid_architecture"], "'{value}' is not one of "),
64 ([2], "{value} is not of type 'string'"),
65 ([[]], "{value} is not of type 'string'"),
66 (2, "{value} is not of type 'array'"),
67 # ([], "{value} is not of type 'array'") # Should we allow empty lists?
68 ]:
69 with self.subTest(value=value):
70 error = error.format_map(SafeDict(value=value[0] if isinstance(value, list) and len(value) == 1 else value)) if error else None
71 self._test_value("architectures", value, error)
72
73 def test_summary(self):
74 for value, error in [
75 ("My good summary", None),
76 ("a" * 129, "'{value}' is too long"),
77 (2, "{value} is not of type 'string'"),
78 ([], "{value} is not of type 'string'")
79 ]:
80 with self.subTest(value=value):
81 error = error.format_map(SafeDict(value=value)) if error else None
82 self._test_value("summary", value, error)
83
84 def test_description(self):
85 for value, error in [
86 ("My good description", None),
87 ("a" * 4097, "'{value}' is too long"),
88 (2, "{value} is not of type 'string'"),
89 ([], "{value} is not of type 'string'")
90 ]:
91 with self.subTest(value=value):
92 error = error.format_map(SafeDict(value=value)) if error else None
93 self._test_value("description", value, error)
94
95 def test_version(self):
96 for value, error in [
97 ("2.0", None),
98 ("~2.90", "'{value}' does not match "),
99 (2, "{value} is not of type 'string'"),
100 ([], "{value} is not of type 'string'")
101 ]:
102 with self.subTest(value=value):
103 error = error.format_map(SafeDict(value=value)) if error else None
104 self._test_value("version", value, error)
105
106 def test_hook(self):
107 for value, error in [
108 ({"install": {"plugs": ["test_plug"]}}, None),
109 ({"invalid-hook": {"plugs": ["test_plug"]}}, "'invalid-hook' is not one of "),
110 ({"install": {"invalid-attribute": ["test_plug"]}}, "Additional properties are not allowed ('invalid-attribute' was unexpected)"),
111 (2, "{value} is not of type 'object'"),
112 ([], "{value} is not of type 'object'")
113 ]:
114 with self.subTest(value=value):
115 error = error.format_map(SafeDict(value=value)) if error else None
116 self._test_value("hooks", value, error)
117
118 def test_required_properties(self):
119 for property in self.required_properties:
120 with self.subTest(property=property):
121 yaml = copy.deepcopy(self.yaml)
122 del yaml[property]
123 error = self._validate(yaml, self.schema)
124 self.assertEqual("'%s' is a required property" % property, error)
125
126 def test_optional_properties(self):
127 for property in self.optional_properties:
128 with self.subTest(property=property):
129 yaml = copy.deepcopy(self.yaml)
130 del yaml[property]
131 error = self._validate(yaml, self.schema)
132 self.assertEqual(None, error)
133
134 def test_unsupported_properties(self):
135 yaml = copy.deepcopy(self.yaml)
136 yaml["wrong_property"] = "wrong_property"
137 error = self._validate(yaml, self.schema)
138 self.assertEqual("Additional properties are not allowed ('wrong_property' was unexpected)", error)
139
140 def test_missing_architectures_on_kernel_modules(self):
141 yaml = copy.deepcopy(self.yaml)
142 yaml["type"] = "kernel-modules"
143 del yaml["architectures"]
144 error = self._validate(yaml, self.schema)
145 self.assertEqual("'architectures' is a required property", error)
diff --git a/reviewtools/tests/test_schema_hook.py b/reviewtools/tests/test_schema_hook.py
0new file mode 100644146new file mode 100644
index 0000000..c98b2ad
--- /dev/null
+++ b/reviewtools/tests/test_schema_hook.py
@@ -0,0 +1,71 @@
1from reviewtools.tests.test_schema_base import TestSchemaBase, SafeDict
2
3
4class TestSchemaHook(TestSchemaBase):
5
6 schema_file = "reviewtools/schemas/hook.json"
7 schema = {}
8 yaml = {}
9
10 def test_base(self):
11 self._test_base()
12
13 # TDOO: Tests plugs better when interfaces schema will be implemented
14 def test_plugs(self):
15 for value, error in [
16 (["foo"], None),
17 ([{}], None),
18 ([], "{value} is too short"),
19 (2, "{value} is not of type 'array'"),
20 ([2], "{value} is not of type 'string', 'object'"),
21 (None, None),
22 ]:
23 with self.subTest(value=value):
24 error = error.format_map(SafeDict(value=value[0] if isinstance(value, list) and len(value) == 1 else value)) if error else None
25 self._test_value("plugs", value, error)
26
27 def test_command_chain(self):
28 for value, error in [
29 (["foo"], None),
30 ([], "{value} is too short"),
31 (2, "{value} is not of type 'array'"),
32 ([2], "{value} is not of type 'string'"),
33 (None, None),
34 ]:
35 with self.subTest(value=value):
36 error = error.format_map(SafeDict(value=value[0] if isinstance(value, list) and len(value) == 1 else value)) if error else None
37 self._test_value("command_chain", value, error)
38
39 def test_environment(self):
40 for value, error in [
41 ({}, "{value} does not have enough properties"),
42 ({"ENV_NAME": ""}, "'' is too short"),
43 ({"ENV-NAME": ""}, "'ENV-NAME' does not match any of the regexes: '^[a-zA-Z_][a-zA-Z0-9_]*$'"),
44 ({"ENV_NAME": "value"}, None),
45 ({"ENV_NAME": "val\0ue"}, "'val\\x00ue' does not match '^[^\\\\0]*$'"),
46 ([], "{value} is not of type 'object'"),
47 (2, "{value} is not of type 'object'"),
48 ([2], "{value} is not of type 'object'"),
49 (None, None),
50 ]:
51 with self.subTest(value=value):
52 error = error.format_map(SafeDict(value=value)) if error else None
53 self._test_value("environment", value, error)
54
55 def test_passthrough(self):
56 for value, error in [
57 ({}, "{value} does not have enough properties"),
58 ({"test": ''}, "'' is not of type 'array'"),
59 ({"test": []}, "[] is too short"),
60 ({"test": ['']}, "'' is too short"),
61 ({"test": ["value"]}, None),
62 ({"te%st": ["value"]}, None),
63 ({"test": ["val\0ue"]}, None),
64 ([], "{value} is not of type 'object'"),
65 (2, "{value} is not of type 'object'"),
66 ([2], "{value} is not of type 'object'"),
67 (None, None),
68 ]:
69 with self.subTest(value=value):
70 error = error.format_map(SafeDict(value=value)) if error else None
71 self._test_value("passthrough", value, error)

Subscribers

People subscribed via source and target branches