pb-plugin: Implement plugin ABI versioning
[petitboot] / utils / pb-plugin
1 #!/bin/sh
2
3 __dest=/
4 __pb_mount_dir=/var/petitboot/mnt/dev/
5 plugin_abi=1
6 plugin_ext=pb-plugin
7 plugin_meta=pb-plugin.conf
8 plugin_meta_dir=etc/preboot-plugins/
9 plugin_meta_path=$plugin_meta_dir$plugin_meta
10 plugin_wrapper_dir=/var/lib/pb-plugins/bin
11
12 usage()
13 {
14         cat <<EOF
15 Usage: $0 <command>
16
17 Where <command> is one of:
18   install <FILE|URL> - install plugin from FILE/URL
19   scan               - look for available plugins on attached devices
20   create <DIR>       - create a new plugin archive from DIR
21 EOF
22 }
23
24 is_url()
25 {
26         local url tmp
27         url=$1
28         tmp=${url#*://}
29         [ "$url" != "$tmp" ]
30 }
31
32 download()
33 {
34         local url file proto
35         url=$1
36         file=$2
37         proto=${url%://*}
38
39         case "$proto" in
40         http)
41                 wget -O - "$url" > $file
42                 ;;
43         ftp)
44                 ncftpget -c "$url" > $file
45                 ;;
46         *)
47                 echo "error: Unsuported protocol $proto" >&2
48                 false
49         esac
50 }
51
52 plugin_info()
53 {
54         local title
55         if [ "$PLUGIN_VENDOR" ]
56         then
57                 title="$PLUGIN_VENDOR: $PLUGIN_NAME"
58         else
59                 title="$PLUGIN_NAME"
60         fi
61
62         echo "$title"
63         echo "  (version $PLUGIN_VERSION)"
64 }
65
66 # How the ABI versioning works:
67 #
68 #  - This script has an ABI defined ($plugin_abi)
69 #
70 #  - Plugins have a current ABI number ($PLUGIN_ABI), and a minimum supported
71 #    ABI number ($PLUGIN_ABI_MIN).
72 #
73 #  - A plugin is OK to run if:
74 #    - the plugin's ABI matches the script ABI, or
75 #    - the plugin's minimum ABI is lower than or equal to the script ABI
76 plugin_abi_check()
77 {
78         [ -n "$PLUGIN_ABI" ] &&
79                 ( [ $PLUGIN_ABI -eq $plugin_abi ] ||
80                   [ $PLUGIN_ABI_MIN -le $plugin_abi ] )
81 }
82
83 do_wrap()
84 {
85         local base binary dir
86
87         base=$1
88         binary=$2
89         shift 2
90
91         for dir in etc dev sys proc var
92         do
93                 [ -e "$base/$dir" ] || mkdir -p "$base/$dir"
94         done
95
96         cp /etc/resolv.conf $base/etc
97         mount -o bind /dev $base/dev
98         mount -o bind /sys $base/sys
99         mount -o bind /proc $base/proc
100         mount -o bind /var $base/var
101
102         chroot "$base" "$binary" "$@"
103
104         umount $base/dev
105         umount $base/sys
106         umount $base/proc
107         umount $base/var
108 }
109
110 __create_wrapper()
111 {
112         local base binary wrapper
113
114         base=$1
115         binary=$2
116         wrapper=$plugin_wrapper_dir/$(basename $binary)
117
118         mkdir -p $plugin_wrapper_dir
119
120         cat <<EOF > $wrapper
121 #!/bin/sh
122
123 exec $(realpath $0) __wrap '$base' '$binary' "\$@"
124 EOF
125
126         chmod a+x $wrapper
127 }
128
129 do_install()
130 {
131         local url name file __dest
132
133         url=$1
134
135         if [ -z "$url" ]
136         then
137                 echo "error: install requires a file/URL argument." >&2
138                 exit 1
139         fi
140
141         name=${url##*/}
142
143         if is_url "$url"
144         then
145                 file=$(mktemp)
146                 trap "rm '$file'" EXIT
147                 download "$url" "$file"
148                 if [ $? -ne 0 ]
149                 then
150                         echo "error: failed to download $url" >&2
151                         exit 1
152                 fi
153         else
154                 file=$url
155                 if [ ! -r "$file" ]
156                 then
157                         echo "error: $file doesn't exist or is not readable" >&2
158                         exit 1
159                 fi
160         fi
161
162         echo "File '$name' has the following sha256 checksum:"
163         echo
164         sha256sum "$file" | cut -f1 -d' '
165         echo
166
167         echo "Do you want to install this plugin? (y/N)"
168         read resp
169
170         case $resp in
171         [yY]|[yY][eE][sS])
172                 ;;
173         *)
174                 echo "Cancelled"
175                 exit 0
176                 ;;
177         esac
178
179         __dest=$(mktemp -d)
180         gunzip -c "$file" | ( cd $__dest && cpio -i -d)
181
182         if [ $? -ne 0 ]
183         then
184                 echo "error: Failed to extract archive $url, exiting"
185                 rm -rf $__dest
186                 exit 1
187         fi
188
189         . $__dest/$plugin_meta_path
190
191         if ! plugin_abi_check
192         then
193                 echo "Plugin at $url is incompatible with this firmware," \
194                         "exiting."
195                 rm -rf $__dest
196                 exit 1
197         fi
198
199         for binary in ${PLUGIN_EXECUTABLES}
200         do
201                 __create_wrapper "$__dest" "$binary"
202         done
203
204         echo "Plugin installed"
205         plugin_info
206 }
207
208 do_scan_mount()
209 {
210         local mnt dev plugin_path __meta_tmp
211         mnt=$1
212         dev=$(basename $mnt)
213
214         for plugin_path in $mnt/*.$plugin_ext
215         do
216                 [ -e "$plugin_path" ] || continue
217
218                 # extract plugin metadata to a temporary directory
219                 __meta_tmp=$(mktemp -d)
220                 [ -d $__meta_tmp ] || continue
221                 gunzip -c "$plugin_path" 2>/dev/null |
222                         (cd $__meta_tmp &&
223                                 cpio -i -d $plugin_meta_path 2>/dev/null)
224                 if ! [ $? = 0 -a -e "$plugin_path" ]
225                 then
226                         rm -rf $__meta_tmp
227                         continue
228                 fi
229
230                 (
231                         . $__meta_tmp/$plugin_meta_path
232
233                         plugin_abi_check || exit 1
234
235                         printf "Plugin found on %s:\n" $dev
236                         plugin_info
237                         printf "\n"
238                         printf "To run this plugin:\n"
239                         printf "  $0 run $plugin_path\n"
240                         printf "\n"
241                 )
242                 if [ $? = 0 ]
243                 then
244                         found=1
245                 fi
246                 rm -rf $__meta_tmp
247         done
248 }
249
250 do_scan()
251 {
252         local found mnt
253         found=0
254         for mnt in $__pb_mount_dir/*
255         do
256                 do_scan_mount $mnt
257         done
258
259         if [ "$found" = 0 ]
260         then
261                 echo "No plugins found"
262         fi
263 }
264
265 guided_meta()
266 {
267         local vendorname vendorshortname
268         local pluginname pluginnhortname
269         local version date executable
270         local file
271
272         file=$1
273
274 cat <<EOF
275
276 Enter the vendor company / author name. This can contain spaces.
277 (eg. 'Example Corporation')
278 EOF
279         read vendorname
280 cat <<EOF
281
282 Enter the vendor shortname. This should be a single-word abbreviation, in all
283 lower-case. This is only used in internal filenames.
284
285 Typically, a stock-ticker name is used for this (eg 'exco')
286 EOF
287         read vendorshortname
288
289 cat <<EOF
290
291 Enter the descriptive plugin name. This can contain spaces, but should only be
292 a few words in length (eg 'RAID configuration utility')
293 EOF
294         read pluginname
295
296 cat <<EOF
297
298 Enter the plugin shortname. This should not contain spaces, but hyphens are
299 fine (eg 'raid-config'). This is only used in internal filnames.
300 EOF
301         read pluginshortname
302
303
304 cat <<EOF
305
306 Enter the plugin version. This should not contain spaces (eg 1.2):
307 EOF
308         read version
309
310 cat <<EOF
311
312 Enter the full path (within the plugin root) to the plugin executable file(s).
313 These will be exposed as wrapper scripts, to be run from the standard petitboot
314 shell environment (eg, /usr/bin/my-raid-config).
315
316 If multiple executables are provided, separate with a space.
317 EOF
318         read executables
319
320         date=$(date +%Y-%m-%d)
321
322         mkdir -p $(dirname $file)
323
324         cat <<EOF > $file
325 PLUGIN_ABI='$plugin_abi'
326 PLUGIN_ABI_MIN='1'
327 PLUGIN_VENDOR='$vendorname'
328 PLUGIN_VENDOR_ID='$vendorshortname'
329 PLUGIN_NAME='$pluginname'
330 PLUGIN_ID='$pluginshortname'
331 PLUGIN_VERSION='$version'
332 PLUGIN_DATE='$date'
333 PLUGIN_EXECUTABLES='$executables'
334 EOF
335
336 }
337
338 do_create()
339 {
340         local src meta_dir_abs meta_file
341         src=$1
342
343         if [ -z "$src" ]
344         then
345                 echo "error: missing source directory" >&2
346                 usage
347                 exit 1
348         fi
349
350         if [ ! -d "$src" ]
351         then
352                 echo "error: source directory missing" >&2
353                 exit 1
354         fi
355
356         meta_file=$src/$plugin_meta_path
357
358         if [ ! -e $meta_file ]
359         then
360                 echo "No plugin metadata file found. " \
361                         "Would you like to create one? (Y/n)"
362                 read resp
363                 case "$resp" in
364                 [nN]|[nN][oO])
365                         echo "Cancelled, exiting"
366                         exit 1
367                         ;;
368                 esac
369                 guided_meta $meta_file || exit
370         fi
371
372         # Sanity check metadata file
373         . $meta_file
374         if [ ! -n "$PLUGIN_ABI" ]
375         then
376                 echo "error: no PLUGIN_ABI defined in metadata" &>2
377                 exit 1
378         fi
379         if [ "$PLUGIN_ABI" != "$plugin_abi" ]
380         then
381                 echo "warning: PLUGIN_ABI (=$PLUGIN_ABI) is not $plugin_abi" &>2
382         fi
383         if [ ! -n "$PLUGIN_VENDOR" ]
384         then
385                 echo "error: no PLUGIN_VENDOR defined in metadata" &>2
386                 exit 1
387         fi
388         if [ ! -n "$PLUGIN_VENDOR_ID" ]
389         then
390                 echo "error: no PLUGIN_VENDOR_ID defined in metadata" &>2
391                 exit 1
392         fi
393         if [ ! -n "$PLUGIN_NAME" ]
394         then
395                 echo "error: no PLUGIN_NAME defined in metadata" &>2
396                 exit 1
397         fi
398         if [ ! -n "$PLUGIN_ID" ]
399         then
400                 echo "error: no PLUGIN_ID defined in metadata" &>2
401                 exit 1
402         fi
403         if [ ! -n "$PLUGIN_VERSION" ]
404         then
405                 echo "error: no PLUGIN_VERSION defined in metadata" &>2
406                 exit 1
407         fi
408         if [ ! -n "$PLUGIN_DATE" ]
409         then
410                 echo "error: no PLUGIN_DATE defined in metadata" &>2
411                 exit 1
412         fi
413         if [ ! -n "$PLUGIN_EXECUTABLES" ]
414         then
415                 echo "error: no PLUGIN_EXECUTABLES defined in metadata" &>2
416                 exit 1
417         fi
418
419         outfile=${PLUGIN_ID}-${PLUGIN_VERSION}.${plugin_ext}
420
421         (
422                 cd $src
423                 find -mindepth 1 | cpio -o -Hnewc -v
424         ) | gzip -c > $outfile
425
426         echo
427         echo "Plugin metadata:"
428         sed -e 's/^/  /' $meta_file
429         echo
430
431         echo "User-visible metadata:"
432         plugin_info | sed -e 's/^/  /'
433
434         echo
435
436 cat <<EOF
437 Plugin created in:
438   $outfile
439
440 Ship this file in the top-level-directory of a USB device or CD to have it
441 automatically discoverable by 'pb-plugin scan'. This file can be re-named,
442 but must retain the .$plugin_ext extension to be discoverable.
443 EOF
444 }
445
446 test_http_download()
447 {
448         local tmp ref
449
450         tmp=$(mktemp -p $test_tmpdir)
451         ref=$(mktemp -p $test_tmpdir)
452
453         echo $RANDOM > $ref
454
455         wget()
456         {
457                 cat $ref
458         }
459
460         download http://example.com/test $tmp
461         cmp -s "$ref" "$tmp"
462 }
463
464 test_ftp_download()
465 {
466         local tmp ref
467
468         tmp=$(mktemp -p $test_tmpdir)
469         ref=$(mktemp -p $test_tmpdir)
470
471         echo $RANDOM > $ref
472
473         ncftpget()
474         {
475                 cat $ref
476         }
477
478         download ftp://example.com/test $tmp
479         cmp -s "$ref" "$tmp"
480 }
481
482 test_abi_check()
483 {
484         (
485                 plugin_abi=$1
486                 PLUGIN_ABI=$2
487                 PLUGIN_ABI_MIN=$3
488                 plugin_abi_check
489         )
490 }
491
492 test_scan()
493 {
494         __pb_mount_dir="$test_tmpdir/mnt"
495         mnt_dir="$__pb_mount_dir/sda"
496         mkdir -p $mnt_dir/$plugin_meta_dir
497         (
498                 echo "PLUGIN_ABI=$plugin_abi"
499                 echo "PLUGIN_NAME=test"
500                 echo "PLUGIN_VERSION=1"
501                 echo "PLUGIN_EXECUTABLES=/bin/sh"
502         ) > $mnt_dir/$plugin_meta_path
503         (
504                 cd $mnt_dir;
505                 find -mindepth 1 | cpio -o -Hnewc 2>/dev/null
506         ) | gzip -c > $mnt_dir/test.$plugin_ext
507
508         do_scan | grep -q 'test'
509 }
510
511 test_scan_nogzip()
512 {
513         __pb_mount_dir="$test_tmpdir/mnt"
514         mnt_dir="$__pb_mount_dir/sda"
515         stderr_file="$test_tmpdir/stderr"
516
517         mkdir -p $mnt_dir
518         echo "invalid" > $mnt_dir/nogzip.$plugin_ext
519
520         do_scan 2>$stderr_file | grep -q 'No plugins'
521
522         [ $? = 0 ] || return 1
523
524         if [ -s "$stderr_file" ]
525         then
526                 echo "Scan with invalid (non-gzip) file produced error output" \
527                         >&2
528                 cat "$stderr_file"
529                 return 1
530         fi
531         true
532 }
533
534 test_scan_nocpio()
535 {
536         __pb_mount_dir="$test_tmpdir/mnt"
537         mnt_dir="$__pb_mount_dir/sda"
538         stderr_file="$test_tmpdir/stderr"
539
540         mkdir -p $mnt_dir
541         echo "invalid" | gzip -c > $mnt_dir/nogzip.$plugin_ext
542
543         do_scan 2>$stderr_file | grep -q 'No plugins'
544
545         [ $? = 0 ] || return 1
546
547         if [ -s "$stderr_file" ]
548         then
549                 echo "Scan with invalid (non-cpio) file produced error output" \
550                         >&2
551                 cat "$stderr_file"
552                 return 1
553         fi
554         true
555 }
556
557 test_scan_multiple()
558 {
559         __pb_mount_dir="$test_tmpdir/mnt"
560         mnt_dir="$__pb_mount_dir/sda"
561         outfile=$test_tmpdir/scan.out
562
563         for i in 1 2
564         do
565                 mkdir -p $mnt_dir/$plugin_meta_dir
566                 (
567                         echo "PLUGIN_ABI=$plugin_abi"
568                         echo "PLUGIN_NAME=test-$i"
569                         echo "PLUGIN_VERSION=1"
570                         echo "PLUGIN_EXECUTABLES=/bin/sh"
571                 ) > $mnt_dir/$plugin_meta_path
572                 (
573                         cd $mnt_dir;
574                         find -mindepth 1 | cpio -o -Hnewc 2>/dev/null
575                 ) | gzip -c > $mnt_dir/test-${i}.$plugin_ext
576                 rm -rf $mnt_dir/$plugin_meta_dir
577         done
578
579         do_scan >$outfile
580
581         grep -q 'test-1' $outfile && grep -q 'test-2' $outfile
582 }
583
584 test_scan_wrongabi()
585 {
586         __pb_mount_dir="$test_tmpdir/mnt"
587         mnt_dir="$__pb_mount_dir/sda"
588         mkdir -p $mnt_dir/$plugin_meta_dir
589         (
590                 echo "PLUGIN_ABI=$(($plugin_abi + 1))"
591                 echo "PLUGIN_ABI_MIN=$(($plugin_abi + 1))"
592                 echo "PLUGIN_NAME=test"
593                 echo "PLUGIN_VERSION=1"
594                 echo "PLUGIN_EXECUTABLES=/bin/sh"
595         ) > $mnt_dir/$plugin_meta_path
596         (
597                 cd $mnt_dir;
598                 find -mindepth 1 | cpio -o -Hnewc 2>/dev/null
599         ) | gzip -c > $mnt_dir/test.$plugin_ext
600
601         do_scan | grep -q 'No plugins'
602 }
603
604 test_empty_scan()
605 {
606         __pb_mount_dir="$test_tmpdir/mnt"
607         mkdir -p $__pb_mount_dir
608         do_scan | grep -q "No plugins"
609 }
610
611 test_setup()
612 {
613         n=$(($n+1))
614
615         test_tmpdir="$tests_tmpdir/$n"
616         mkdir "$test_tmpdir"
617 }
618
619 test_teardown()
620 {
621         true
622 }
623
624 test_failed=0
625 do_test()
626 {
627         local tstr op
628
629         tstr="$@"
630         op=-eq
631
632         if [ "x$1" = "x!" ]
633         then
634                 op=-ne
635                 shift
636         fi
637
638         test_setup
639         ( $@ )
640         local rc=$?
641         test_teardown
642
643         if [ $rc $op 0 ]
644         then
645                 echo PASS: "$tstr"
646         else
647                 echo FAIL: "$tstr"
648                 test_failed=1
649                 false
650         fi
651 }
652
653 do_tests()
654 {
655         local tests_tmpdir n
656
657         tests_tmpdir=$(mktemp -d)
658         n=0
659
660         do_test ! is_url "/test"
661         do_test ! is_url "./test"
662         do_test ! is_url "../test"
663         do_test ! is_url "test"
664         do_test is_url "http://example.com/path"
665         do_test is_url "git+ssh://example.com/path"
666         do_test test_http_download
667         do_test test_ftp_download
668         do_test ! test_abi_check
669         do_test ! test_abi_check 1
670         do_test test_abi_check 1 1
671         do_test test_abi_check 1 1 1
672         do_test test_abi_check 1 2 0
673         do_test test_abi_check 1 2 1
674         do_test ! test_abi_check 1 2 2
675         do_test test_scan
676         do_test test_scan_nogzip
677         do_test test_scan_nocpio
678         do_test test_scan_multiple
679         do_test test_scan_wrongabi
680         do_test test_empty_scan
681
682         if [ $test_failed = 0 ]
683         then
684                 echo "$n tests passed"
685         else
686                 echo "Tests failed"
687         fi
688         rm -rf "$tests_tmpdir"
689
690         [ $test_failed = 0 ]
691 }
692
693 case "$1" in
694 install)
695         shift
696         do_install $@
697         ;;
698 scan)
699         shift
700         do_scan $@
701         ;;
702 create)
703         shift
704         do_create $@
705         ;;
706 __wrap)
707         shift
708         do_wrap $@
709         ;;
710 __test)
711         shift
712         do_tests $@
713         ;;
714 "")
715         echo "error: Missing command" >&2
716         usage
717         exit 1
718         ;;
719 *)
720         echo "Invalid command: $s" >&2
721         usage
722         exit 1
723 esac
724
725