comparison relmon.tcl.in @ 3:6d87242c537e

Add Makefile Use make to allow changing the tclsh path and for easy installation.
author Guido Berhoerster <guido+relmon@berhoerster.name>
date Mon, 20 Oct 2014 19:31:20 +0200
parents relmon.tcl@cba4887feb2c
children 86a0c5d11f05
comparison
equal deleted inserted replaced
2:0203fffb4d74 3:6d87242c537e
1 #!/usr/bin/tclsh
2 #
3 # Copyright (C) 2014 Guido Berhoerster <guido+relmon@berhoerster.name>
4 #
5 # Permission is hereby granted, free of charge, to any person obtaining
6 # a copy of this software and associated documentation files (the
7 # "Software"), to deal in the Software without restriction, including
8 # without limitation the rights to use, copy, modify, merge, publish,
9 # distribute, sublicense, and/or sell copies of the Software, and to
10 # permit persons to whom the Software is furnished to do so, subject to
11 # the following conditions:
12 #
13 # The above copyright notice and this permission notice shall be included
14 # in all copies or substantial portions of the Software.
15 #
16 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17 # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18 # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19 # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
20 # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
21 # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
22 # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
23
24 package require Tcl 8.5
25 package require http
26 package require tls
27 package require tdom
28 package require try
29 package require cmdline
30 package require control
31 package require html
32 package require htmlparse
33 package require json
34 package require json::write
35 package require logger
36 package require logger::utils
37 package require textutil::split
38 package require uri
39 package require uri::urn
40
41
42 namespace eval ::relmon {
43 # version
44 variable VERSION @VERSION@
45 }
46
47
48 namespace eval ::relmon::common {
49 namespace export cmpVersions isUrlValid urlGetHost normalizeHttpHeaders \
50 parseStateFile
51 }
52
53 # implementation of the Debian version comparison algorithm described at
54 # http://www.debian.org/doc/debian-policy/ch-controlfields.html#s-f-Version
55 proc ::relmon::common::cmpVersion {v1 v2} {
56 set v1Len [string length $v1]
57 set v2Len [string length $v2]
58 set v1Pos 0
59 set v2Pos 0
60 while {($v1Pos < $v1Len) || ($v2Pos < $v2Len)} {
61 set firstNumDiff 0
62 # until reaching ASCII digits in both version strings compare character
63 # values which are modified as so they are sorted in the following
64 # order:
65 # - "~"
66 # - missing character or ASCII digits
67 # - ASCII alphabet
68 # - everything else in the order of their unicode value
69 while {(($v1Pos < $v1Len) &&
70 ![string match {[0123456789]} [string index $v1 $v1Pos]]) ||
71 (($v2Pos < $v2Len) &&
72 ![string match {[0123456789]} [string index $v2 $v2Pos]])} {
73 foreach char [list [string index $v1 $v1Pos] \
74 [string index $v2 $v2Pos]] charValueName \
75 {v1CharValue v2CharValue} {
76 if {$char eq "~"} {
77 set $charValueName -1
78 } elseif {$char eq ""} {
79 set $charValueName 0
80 } elseif {[string match {[0123456789]} $char]} {
81 set $charValueName 0
82 } elseif {[string match -nocase {[abcdefghijklmnopqrstuvwxyz]} \
83 $char]} {
84 set $charValueName [scan $char "%c"]
85 } else {
86 set $charValueName [expr {[scan $char "%c"] + 0x7f + 1}]
87 }
88 }
89 if {$v1CharValue != $v2CharValue} {
90 return [expr {$v1CharValue - $v2CharValue}]
91 }
92 incr v1Pos
93 incr v2Pos
94 }
95
96 # strip leading zeros
97 while {[string index $v1 $v1Pos] eq "0"} {
98 incr v1Pos
99 }
100 while {[string index $v2 $v2Pos] eq "0"} {
101 incr v2Pos
102 }
103
104 # process digits until reaching a non-digit
105 while {[string match {[0123456789]} [string index $v1 $v1Pos]] &&
106 [string match {[0123456789]} [string index $v2 $v2Pos]]} {
107 # record the first difference between the two numbers
108 if {$firstNumDiff == 0} {
109 set firstNumDiff [expr {[string index $v1 $v1Pos] -
110 [string index $v2 $v2Pos]}]
111 }
112 incr v1Pos
113 incr v2Pos
114 }
115
116 # return if the number of one version has more digits than the other
117 # since the one with more digits is the larger number
118 if {[string match {[0123456789]} [string index $v1 $v1Pos]]} {
119 return 1
120 } elseif {[string match {[0123456789]} [string index $v2 $v2Pos]]} {
121 return -1
122 }
123
124 # return the difference if the digits differed above
125 if {$firstNumDiff != 0} {
126 return $firstNumDiff
127 }
128 }
129
130 return 0
131 }
132
133 proc ::relmon::common::isUrlValid {url} {
134 return [expr {![catch {dict create {*}[uri::split $url]} urlParts] &&
135 ([dict get $urlParts "scheme"] in {"http" "https"}) &&
136 ([dict get $urlParts "host"] ne "")}]
137 }
138
139 proc ::relmon::common::urlGetHost {url} {
140 return [expr {(![catch {dict create {*}[uri::split $url]} urlParts]) ?
141 [dict get $urlParts "host"] : ""}]
142 }
143
144 proc ::relmon::common::normalizeHttpHeaders {headers} {
145 set httpHeaders [dict create]
146 foreach {header value} $headers {
147 set words {}
148 foreach word [split $header "-"] {
149 lappend words [string totitle $word]
150 }
151 dict set httpHeaders [join $words "-"] $value
152 }
153 return $httpHeaders
154 }
155
156 proc ::relmon::common::parseStateFile {stateFile} {
157 try {
158 set f [open $stateFile "r"]
159 } trap {POSIX} {errorMsg errorOptions} {
160 return -options $errorOptions \
161 "failed to open state file \"$stateFile\": $errorMsg"
162 }
163 try {
164 set state [json::json2dict [chan read $f]]
165 } trap {POSIX} {errorMsg errorOptions} {
166 return -options $errorOptions \
167 "failed to read from state file \"$stateFile\": $errorMsg"
168 } on error {errorMsg errorOptions} {
169 # the json package does not set an error code
170 dict set errorOptions "-errorcode" {RELMON JSON_PARSE_ERROR}
171 return -options $errorOptions \
172 "failed to parse state file \"$stateFile\": $errorMsg"
173 } finally {
174 close $f
175 }
176
177 return $state
178 }
179
180
181 namespace eval ::relmon::update {
182 # commandline option help text
183 variable usage "usage: relmon update \[-dev\] \[-c max_connections\] \[-C\
184 ca_dir\] \[-D delay\]\n\
185 \ \[-H max_host_connections\] \[-i\
186 item\[,...\]\] \[-l logfile\]\n\
187 \ \[-r retries\] \[-t min_time\] watchlist\
188 statefile"
189
190 # configuration options
191 variable Config [dict create \
192 "log_file" "" \
193 "log_level" "notice" \
194 "history_limit" 20 \
195 "connection_limit" 16 \
196 "host_connection_limit" 4 \
197 "transfer_time_limit" 60000 \
198 "retry_limit" 3 \
199 "host_delay" 0 \
200 "timestamp_filter" 0 \
201 "error_filter" 0 \
202 "item_filter" {} \
203 "ca_dir" "" \
204 "state_file" "" \
205 "watchlist_file" ""]
206
207 # exit status
208 variable ExitStatus
209
210 # transfer statistics
211 variable Statistics [dict create \
212 "start_time" 0 \
213 "end_time" 0 \
214 "requests" 0 \
215 "items" 0]
216
217 # watchlist
218 variable Watchlist
219
220 # ID of a delayed run of ManageTransfers
221 variable ManageTransfersId ""
222
223 # queue of pending transfers
224 variable Queue
225
226 # number of active connections per host
227 variable HostConnections
228
229 # delays before opening a new connection to a host
230 variable HostDelays
231
232 # active transfers
233 variable ActiveTransfers
234
235 # buffer for tracking the state of unfinished items
236 variable StateBuffer
237
238 # buffer needed by htmlparse::parse for constructing the preprocessed HTML
239 # document
240 variable PreprocessedHtmlBuffer
241
242 # logger handle
243 variable Log
244
245 # logfile handle
246 variable Lf
247 }
248
249 proc ::relmon::update::OnError {message returnOptions} {
250 # internal error, abort
251 puts stderr [dict get $returnOptions "-errorinfo"]
252
253 exit 1
254 }
255
256 proc ::relmon::update::CleanupBeforeExit {commandString operation} {
257 variable Lf
258
259 # close logfile
260 if {($Lf ne "") && ($Lf ni {stdin stderr})} {
261 close $Lf
262 set Lf ""
263 }
264
265 return
266 }
267
268 proc ::relmon::update::ParseWatchlist {watchlistFilename} {
269 variable Watchlist
270
271 set lineno 0
272 set f [open $watchlistFilename "r"]
273 try {
274 while {[chan gets $f line] != -1} {
275 set fields [textutil::split::splitx [string trim $line] {[\t ]+}]
276 incr lineno
277
278 if {([llength $fields] == 0) ||
279 ([string index [lindex $fields 0] 0] eq "#")} {
280 # skip empty lines and comments
281 continue
282 } elseif {[llength $fields] < 3} {
283 # a line consists of a name, base URL and at least one
284 # version-matching pattern
285 return -code error -errorcode {RELMON WATCHLIST_PARSE_ERRROR} \
286 "syntax error in \"$watchlistFilename\" line $lineno"
287 }
288
289 set patterns [lassign $fields name baseUrl]
290
291 # validate URL
292 if {![::relmon::common::isUrlValid $baseUrl]} {
293 return -code error -errorcode {RELMON WATCHLIST_PARSE_ERRROR} \
294 "syntax error in \"$watchlistFilename\" line $lineno:\
295 invalid base URL"
296 }
297
298 # process patterns
299 set processedPatterns {}
300 set patternIndex 0
301 foreach pattern $patterns {
302 incr patternIndex
303
304 # make trailing slashes optional except in the last
305 # version-matching pattern
306 if {($patternIndex != [llength $patterns]) &&
307 ([string index $pattern end] eq "/")} {
308 append pattern {?}
309 }
310
311 # ensure patterns are anchored to the end of the line
312 if {[string index $pattern end] ne "$"} {
313 append pattern {$}
314 }
315
316 # actually validate the regular expression
317 try {
318 set reInfo [regexp -about -- $pattern ""]
319 } on error {errorMsg} {
320 return -code error \
321 -errorcode {RELMON WATCHLIST_PARSE_ERRROR} \
322 "error in \"$watchlistFilename\" line $lineno:\
323 $errorMsg"
324 }
325 lappend processedPatterns $pattern
326 }
327 if {[lindex $reInfo 0] < 1} {
328 return -code error -errorcode {RELMON WATCHLIST_PARSE_ERRROR} \
329 "syntax error in \"$watchlistFilename\" line $lineno:\
330 the last regular expression must contain at least one
331 capturing group"
332 }
333
334 dict set Watchlist $name "base_url" $baseUrl
335 dict set Watchlist $name "patterns" $processedPatterns
336 }
337 } finally {
338 close $f
339 }
340
341 return
342 }
343
344 proc ::relmon::update::ProcessHtmlElement {tag slash param textBehindTheTag} {
345 variable PreprocessedHtmlBuffer
346
347 # copy every "<a>" element into PreprocessedHtmlBuffer
348 if {($slash eq "") && ([string tolower $tag] eq "a")} {
349 append PreprocessedHtmlBuffer "<$tag $param></$tag>"
350 }
351
352 return
353 }
354
355 proc ::relmon::update::PreprocessHtml {bodyDataName} {
356 upvar 1 $bodyDataName bodyData
357 variable PreprocessedHtmlBuffer
358
359 # preprocess the document with htmlparse by constructing a new document
360 # consisting only of found "<a>" elements which then can be fed into tdom
361 # again; this is useful if parsing via tdom fails; however, htmlparse
362 # should only be used as a last resort because it is just too limited, it
363 # gets easily confused within "<script>" elements and lacks attribute
364 # parsing
365 set PreprocessedHtmlBuffer "<html><body>"
366 htmlparse::parse -cmd [namespace code ProcessHtmlElement] $bodyData
367 append PreprocessedHtmlBuffer "</body></html>"
368 }
369
370 proc ::relmon::update::ExtractUrls {bodyDataName contentType baseUrl
371 rePattern} {
372 upvar 1 $bodyDataName bodyData
373 set extractedUrls {}
374 set resultUrls [dict create]
375 # extract all URLs or URL fragments
376 switch -- $contentType {
377 {text/html} -
378 {application/xhtml+xml} {
379 # HTML/XHTML
380 # if tdom parsing has failed or not found any "<a>" element,
381 # preprocess the document with htmlparse and try again
382 if {[catch {dom parse -html $bodyData} doc] ||
383 ([set rootElement [$doc documentElement]] eq "") ||
384 ([llength [set aElements \
385 [$rootElement selectNodes {descendant::a}]]] == 0)} {
386 try {
387 set doc [dom parse -html [PreprocessHtml bodyData]]
388 } on error {errorMsg errorOptions} {
389 dict set errorOptions "-errorcode" \
390 {RELMON TDOM_PARSE_ERROR}
391 return -options $errorOptions $errorMsg
392 }
393 set rootElement [$doc documentElement]
394 set aElements [$rootElement selectNodes {descendant::a}]
395 }
396 foreach node $aElements {
397 set href [$node getAttribute "href" ""]
398 if {$href ne ""} {
399 lappend extractedUrls $href
400 }
401 }
402 $doc delete
403 }
404 {application/rss+xml} {
405 # RSS 2.0
406 try {
407 set doc [dom parse $bodyData]
408 } on error {errorMsg errorOptions} {
409 dict set errorOptions "-errorcode" {RELMON TDOM_PARSE_ERROR}
410 return -options $errorOptions $errorMsg
411 }
412 set rootElement [$doc documentElement]
413 if {$rootElement ne ""} {
414 foreach node [$rootElement selectNodes {descendant::link}] {
415 set linkText [$node text]
416 if {$linkText ne ""} {
417 lappend extractedUrls $linkText
418 }
419 }
420 }
421 $doc delete
422 }
423 {application/atom+xml} {
424 # Atom 1.0
425 try {
426 set doc [dom parse $bodyData]
427 } on error {errorMsg errorOptions} {
428 dict set errorOptions "-errorcode" {RELMON TDOM_PARSE_ERROR}
429 return -options $errorOptions $errorMsg
430 }
431 set rootElement [$doc documentElement]
432 if {$rootElement ne ""} {
433 foreach node [$rootElement selectNodes {descendant::link}] {
434 set href [$node getAttribute "href" ""]
435 if {$href ne ""} {
436 lappend extractedUrls $href
437 }
438 }
439 }
440 $doc delete
441 }
442 {text/plain} {
443 # plain text
444 foreach line [split $bodyData "\n"] {
445 if {$line ne ""} {
446 lappend extractedUrls $line
447 }
448 }
449 }
450 default {
451 return -code error \
452 -errorcode {RELMON UNSUPPORTED_CONTENT_TYPE_ERROR} \
453 "unsupported content type \"$contentType\""
454 }
455 }
456 foreach url $extractedUrls {
457 set normalizedUrl [uri::canonicalize [uri::resolve $baseUrl $url]]
458 dict set resultUrls $normalizedUrl \
459 [expr {[regexp -line -- $rePattern $normalizedUrl] ? 1 : 0}]
460 }
461
462 return $resultUrls
463 }
464
465 proc ::relmon::update::StateItemAppendError {name logMsg} {
466 variable StateBuffer
467
468 dict update StateBuffer $name stateItem {
469 dict lappend stateItem "errors" $logMsg
470 }
471
472 return
473 }
474
475 proc ::relmon::update::HandleSuccessfulTransfer {item httpBodyName} {
476 upvar 1 $httpBodyName httpBody
477 variable Log
478 variable StateBuffer
479 variable Queue
480 variable Watchlist
481
482 set name [dict get $item "name"]
483 set url [dict get $item "url"]
484 set patternIndex [dict get $item "pattern_index"]
485 set pattern [lindex [dict get $Watchlist $name "patterns"] $patternIndex]
486
487 ${Log}::info "\"$name\": \"$url\": transfer finished"
488
489 # parse data
490 try {
491 set urls [ExtractUrls httpBody [dict get $item "content_type"] $url \
492 $pattern]
493 } trap {RELMON} {errorMsg} {
494 # continue on tdom parsing errors or when receiving documents with an
495 # unsupported content type
496 set urls [dict create]
497 set warningMsg "\"$name\": \"$url\": failed to parse data: $errorMsg"
498 ${Log}::warn $warningMsg
499 StateItemAppendError $name $warningMsg
500 }
501
502 if {$patternIndex < ([llength \
503 [dict get $Watchlist $name "patterns"]] - 1)} {
504 # if this is not the last, version-matching pattern, queue matched URLs
505 dict for {newUrl matched} $urls {
506 if {$matched} {
507 if {![::relmon::common::isUrlValid $newUrl]} {
508 ${Log}::debug "\"$name\": \"$url\": ignoring matched but\
509 invalid URL \"$newUrl\""
510 continue
511 }
512
513 ${Log}::debug "\"$name\": \"$url\": queuing \"$newUrl\""
514
515 dict lappend Queue [::relmon::common::urlGetHost $newUrl] \
516 [dict create "name" $name "url" $newUrl \
517 "pattern_index" [expr {$patternIndex + 1}] \
518 "content_type" "" "num_redirects" 0 "num_retries" 0]
519 } else {
520 ${Log}::debug "\"$name\": \"$url\": ignoring \"$newUrl\""
521 }
522 }
523 } else {
524 # otherwise this branch has finished, try to extract the versions and
525 # store them in the buffer
526 dict for {finalUrl matched} $urls {
527 if {$matched} {
528 regexp -line -- $pattern $finalUrl -> version
529 if {$version ne ""} {
530 ${Log}::debug "\"$name\": \"$url\": extracted version\
531 \"$version\" from \"$finalUrl\" found on\
532 \"$url\""
533 dict set StateBuffer $name "versions" $version $finalUrl
534 } else {
535 ${og}::debug "\"$name\": \"$url\": could not extract a\
536 version from \"$finalUrl\""
537 }
538 } else {
539 ${Log}::debug "\"$name\": \"$url\": ignoring \"$finalUrl\""
540 }
541 }
542 }
543
544 return
545 }
546
547 proc ::relmon::update::HandleRedirect {item httpCode httpHeaders} {
548 variable Log
549 variable Queue
550
551 set name [dict get $item "name"]
552 set url [dict get $item "url"]
553
554 if {![dict exists $httpHeaders "Location"]} {
555 # bail out in case of an invalid HTTP response
556 set warningMsg "\"$name\": \"$url\": transfer failed: invalid HTTP\
557 response"
558 ${Log}::warn $warningMsg
559 StateItemAppendError $name $warningMsg
560 return
561 }
562 set location [dict get $httpHeaders "Location"]
563
564 # sanitize URL from Location header
565 if {[uri::isrelative $location]} {
566 set redirectUrl [uri::canonicalize [uri::resolve \
567 $url $location]]
568 } else {
569 if {![::relmon::common::isUrlValid $location]} {
570 # bail out in case of an invalid redirect URL
571 set warningMsg "\"$name\": \"$url\": received invalid redirect URL\
572 \"$location\""
573 ${Log}::warn $warningMsg
574 StateItemAppendError $name $warningMsg
575 return
576 }
577 set redirectUrl [uri::canonicalize $location]
578 }
579
580 ${Log}::notice "\"$name\": \"$url\": received redirect to \"$redirectUrl\""
581
582 # handle up to 10 redirects by re-queuing the target URL
583 if {[dict get $item "num_redirects"] < 10} {
584 ${Log}::debug "\"$name\": \"$url\": queuing \"$redirectUrl\" due to\
585 redirect"
586
587 dict lappend Queue [::relmon::common::urlGetHost $redirectUrl] \
588 [dict replace $item "url" $redirectUrl "content_type" "" \
589 "num_redirects" [expr {[dict get $item "num_redirects"] + 1}] \
590 "num_retries" 0]
591 } else {
592 set warningMsg "\"$name\": \"$url\": exceeded maximum number of\
593 redirects"
594 ${Log}::warn $warningMsg
595 StateItemAppendError $name $warningMsg
596 }
597
598 return
599 }
600
601 proc ::relmon::update::HandleProtocolError {item httpCode} {
602 variable Log
603 set name [dict get $item "name"]
604 set url [dict get $item "url"]
605 set warningMsg "\"$name\": \"$url\": transfer failed: $httpCode"
606 ${Log}::warn $warningMsg
607 StateItemAppendError $name $warningMsg
608 return
609 }
610
611 proc ::relmon::update::HandleTimeoutReset {item} {
612 variable Log
613 variable Config
614 variable Queue
615 set name [dict get $item "name"]
616 set url [dict get $item "url"]
617
618 # retry by re-queuing the target URL until reaching the limit
619 if {[dict get $item "num_retries"] < [dict get $Config "retry_limit"]} {
620 ${Log}::warn "\"$name\": \"$url\": connection timed out or was reset,\
621 retrying"
622 dict lappend Queue [::relmon::common::urlGetHost $url] \
623 [dict replace $item \
624 "num_retries" [expr {[dict get $item "num_retries"] + 1}]]
625 } else {
626 set warningMsg "\"$name\": \"$url\": exceeded maximum number of\
627 retries"
628 ${Log}::warn $warningMsg
629 StateItemAppendError $name $warningMsg
630 }
631
632 return
633 }
634
635 proc ::relmon::update::HandleConnectionError {item errorMsg} {
636 variable Log
637 set name [dict get $item "name"]
638 set url [dict get $item "url"]
639 set warningMsg "\"$name\": \"$url\": transfer failed: $errorMsg"
640 ${Log}::warn $warningMsg
641 StateItemAppendError $name $warningMsg
642 return
643 }
644
645 proc ::relmon::update::TransferCallbackWrapper {callbackCmd args} {
646 # ensure that exceptions get raised, by default http catches all errors and
647 # silently ignores them, see https://core.tcl.tk/tcl/tktview?name=1414262
648 if {[catch {eval $callbackCmd $args} -> errorOptions]} {
649 OnError [dict get $errorOptions "-errorinfo"] $errorOptions
650 }
651 return
652 }
653
654 proc ::relmon::update::ManageTransfers {} {
655 variable Config
656 variable ManageTransfersId
657 variable Queue
658 variable HostConnections
659 variable HostDelays
660 variable ActiveTransfers
661 variable ExitStatus
662 variable Log
663
664 after cancel $ManageTransfersId
665
666 # try to initiate new transfers
667 while {([dict size $ActiveTransfers] <
668 [dict get $Config "connection_limit"]) &&
669 ([dict size $Queue] > 0)} {
670 # find URLs in the queue with a host for which we have not reached the
671 # per-host connection limit yet and for which no delay is in effect
672 set item {}
673 dict for {host items} $Queue {
674 set now [clock milliseconds]
675
676 if {![dict exists $HostConnections $host]} {
677 dict set HostConnections $host 0
678 }
679
680 if {![dict exists $HostDelays $host]} {
681 dict set HostDelays $host $now
682 }
683
684 if {([dict get $HostConnections $host] <
685 [dict get $Config "host_connection_limit"]) &&
686 ([dict get $HostDelays $host] <= $now)} {
687 # pop item from the queue
688 set items [lassign $items item]
689 if {[llength $items] > 0} {
690 dict set Queue $host $items
691 } else {
692 dict unset Queue $host
693 }
694
695 dict incr HostConnections $host
696 # set a random delay before the next connection to this host
697 # can be made
698 dict set HostDelays $host \
699 [expr {[clock milliseconds] + int((rand() + 0.5) *
700 [dict get $Config "host_delay"])}]
701 break
702 }
703 }
704 # if no item could be found, the per-host connection limit for all
705 # queued URLs has been reached and no new transfers may be started
706 # at this point
707 if {$item eq {}} {
708 break
709 }
710 # otherwise start a new transfer
711 set url [dict get $item "url"]
712 set name [dict get $item "name"]
713 try {
714 set token [http::geturl $url \
715 -timeout [dict get $Config "transfer_time_limit"] \
716 -progress [namespace code {TransferCallbackWrapper \
717 OnTransferProgress}] \
718 -command [namespace code {TransferCallbackWrapper \
719 OnTransferFinished}]]
720 } on ok {} {
721 dict set ActiveTransfers $token $item
722
723 ${Log}::info "\"$name\": \"$url\": starting transfer"
724 } on error {errorMsg} {
725 # an error occured during socket setup, e.g. a DNS lookup failure
726 set warningMsg "\"$name\": \"$url\": transfer failed: $errorMsg"
727 ${Log}::warn $warningMsg
728 StateItemAppendError $name $warningMsg
729 }
730 }
731
732 # terminate the event loop if there are no remaining transfers
733 if {([dict size $ActiveTransfers]) == 0 && ([dict size $Queue] == 0)} {
734 set ExitStatus 0
735 return
736 }
737
738 # due to per-host connection limits and per-host delays the maximum number
739 # of connections may not be reached although there are still items in the
740 # queue, in this case schedule ManageTransfers again after the smallest of
741 # the current per-host delays
742 set delay 0
743 if {([dict size $ActiveTransfers] <
744 [dict get $Config "connection_limit"]) &&
745 ([dict size $Queue] > 0)} {
746 dict for {host items} $Queue {
747 if {(![dict exists $HostConnections $host] ||
748 ([dict get $HostConnections $host] <
749 [dict get $Config "host_connection_limit"])) &&
750 ([dict exists $HostDelays $host] &&
751 ([dict get $HostDelays $host] > $now))} {
752 set hostDelay [expr {[dict get $HostDelays $host] - $now + 1}]
753 if {(($delay == 0) ||
754 ($hostDelay < $delay))} {
755 set delay $hostDelay
756 }
757 }
758 }
759 if {$delay > 0} {
760 set ManageTransfersId \
761 [after $delay [namespace code ManageTransfers]]
762 }
763 }
764
765 return
766 }
767
768 proc ::relmon::update::OnTransferProgress {token total current} {
769 upvar #0 $token httpState
770 variable ActiveTransfers
771 variable Log
772
773 # try to determine content type and abort transfer if content type is not
774 # one that can be parsed, this is primarily to prevent accidental downloads
775 if {[dict get $ActiveTransfers $token "content_type"] eq ""} {
776 set httpHeaders [relmon::common::normalizeHttpHeaders \
777 $httpState(meta)]
778
779 if {[dict exists $httpHeaders "Content-Type"]} {
780 set contentType [string trim [lindex [split \
781 [dict get $httpHeaders "Content-Type"] ";"] 0]]
782 dict set ActiveTransfers $token "content_type" $contentType
783 if {$contentType ni {"text/html" "application/xhtml+xml"
784 "application/atom+xml" "application/rss+xml"
785 "text/plain"}} {
786 ${Log}::warn "\"[dict get $ActiveTransfers $token "name"]\":\
787 \"[dict get $ActiveTransfers $token "url"]\": content\
788 type \"$contentType\" is not acceptable"
789 http::reset $token
790 }
791 }
792 }
793 }
794
795 proc ::relmon::update::OnTransferFinished {token} {
796 upvar #0 $token httpState
797 variable Config
798 variable HostConnections
799 variable Queue
800 variable ActiveTransfers
801 variable Statistics
802 variable StateBuffer
803 variable State
804 variable Log
805
806 set item [dict get $ActiveTransfers $token]
807 set name [dict get $item "name"]
808 set host [relmon::common::urlGetHost [dict get $item "url"]]
809
810 # update list of per-host connections, and number of remaining transfers
811 # for this item
812 dict unset ActiveTransfers $token
813 dict incr HostConnections $host -1
814
815 switch -- $httpState(status) {
816 {ok} {
817 # normalize headers
818 set httpHeaders [relmon::common::normalizeHttpHeaders \
819 $httpState(meta)]
820
821 # try to determine content type
822 if {([dict get $item "content_type"] eq "") &&
823 [dict exists $httpHeaders "Content-Type"]} {
824 dict set item "content_type" [string trim [lindex [split \
825 [dict get $httpHeaders "Content-Type"] ";"] 0]]
826 }
827
828 # dispatch based on HTTP status code
829 set httpCode [http::ncode $token]
830 switch -glob -- $httpCode {
831 {30[12378]} {
832 HandleRedirect $item $httpCode $httpHeaders
833 }
834 {200} {
835 HandleSuccessfulTransfer $item httpState(body)
836 }
837 default {
838 HandleProtocolError $item $httpState(http)
839 }
840 }
841 }
842 {reset} {
843 # aborted due to wrong content type
844 }
845 {eof} -
846 {timeout} {
847 # timeout or connection reset
848 HandleTimeoutReset $item
849 }
850 {error} {
851 # connection may have failed or been refused
852 HandleConnectionError $item [lindex $httpState(error) 0]
853 }
854 }
855
856 # check if all transfers of this item are finished
857 set itemFinished 1
858 dict for {queueHost queueItems} $Queue {
859 foreach queueItem $queueItems {
860 if {[dict get $queueItem "name"] eq $name} {
861 set itemFinished 0
862 }
863 }
864 }
865 dict for {activeToken activeItem} $ActiveTransfers {
866 if {[dict get $activeItem "name"] eq $name} {
867 set itemFinished 0
868 }
869 }
870 if {$itemFinished} {
871 set timestamp [clock milliseconds]
872
873 # create httpState item if it does not exist yet
874 if {![dict exists $State $name]} {
875 dict set State $name [dict create "versions" [dict create] \
876 "history" [list] "timestamp" 0 "errors" [list]]
877 }
878
879 # if there are no versions, log an error message since something must
880 # be wrong
881 if {[llength [dict get $StateBuffer $name "versions"]] == 0} {
882 set warningMsg "\"$name\": no versions found"
883 ${Log}::warn $warningMsg
884 StateItemAppendError $name $warningMsg
885 }
886
887 # update httpState item
888 dict set State $name "errors" [dict get $StateBuffer $name "errors"]
889 dict set State $name "timestamp" $timestamp
890 if {[llength [dict get $StateBuffer $name "errors"]] == 0} {
891 # expire old history entries
892 set history [lrange [dict get $State $name "history"] \
893 [expr {[llength [dict get $State $name "history"]] -
894 [dict get $Config "history_limit"] + 1}] end]
895
896 # add currently latest available version to history if it is either
897 # newer than the previous one or if the previous one is no longer
898 # available (e.g. if it has been removed or the watchlist pattern
899 # has been changed)
900 set prevLatestVersion [lindex $history end 0]
901 set curLatestVersion [lindex \
902 [lsort -command ::relmon::common::cmpVersion \
903 [dict keys [dict get $StateBuffer $name "versions"]]] end]
904 if {([::relmon::common::cmpVersion $curLatestVersion \
905 $prevLatestVersion] > 0) ||
906 ![dict exists $StateBuffer $name "versions" \
907 $prevLatestVersion]} {
908 lappend history [list $curLatestVersion $timestamp]
909 dict set State $name "history" $history
910 }
911 dict set State $name "versions" [dict get $StateBuffer $name \
912 "versions"]
913 }
914 dict unset StateBuffer $name
915
916 ${Log}::notice "progress: [dict size $StateBuffer]/[dict get \
917 $Statistics "items"] items left"
918 }
919
920 http::cleanup $token
921
922 ManageTransfers
923
924 return
925 }
926
927 # control certificate verification and log errors during TLS handshake
928 proc ::relmon::update::OnTlsHandshake {type args} {
929 variable Config
930 variable Log
931
932 switch -- ${type} {
933 {error} {
934 lassign $args {} tlsErrorMsg
935 ${Log}::error "TLS connection error: $tlsErrorMsg"
936 }
937 {verify} {
938 lassign $args {} {} {} status tlsErrorMsg
939 array set cert [lindex $args 2]
940 if {$status == 0} {
941 if {[dict get $Config "ca_dir"] eq ""} {
942 # do not verify certificates is ca-dir was not set
943 return 1
944 } else {
945 set errorMsg "$tlsErrorMsg\nCertificate details:"
946 foreach {key description} {"serial" "Serial Number"
947 "issuer" "Issuer" "notBefore" "Not Valid Before"
948 "notAfter" "Not Valid After" "subject" "Subject"
949 "sha1_hash" "SHA1 Hash"} {
950 append errorMsg "\n$description: $cert($key)"
951 }
952 ${Log}::error "TLS connection error: $errorMsg"
953 return 0
954 }
955 }
956 }
957 }
958 }
959
960 proc ::relmon::update::main {args} {
961 variable Config
962 variable usage
963 variable Statistics
964 variable Watchlist [dict create]
965 variable Queue [dict create]
966 variable HostConnections [dict create]
967 variable HostDelays [dict create]
968 variable ActiveTransfers [dict create]
969 variable State
970 variable StateBuffer [dict create]
971 variable PreprocessedHtmlBuffer
972 variable Log
973 variable Lf ""
974 variable ExitStatus
975
976 # parse commandline
977 while {[set GetoptRet [cmdline::getopt args \
978 {c.arg C.arg d D.arg e H.arg i.arg l.arg r.arg t.arg T.arg v} \
979 OptArg OptVal]] == 1} {
980 switch -glob -- $OptArg {
981 {c} {
982 if {![string is digit -strict $OptVal] || ($OptVal <= 0)} {
983 puts stderr "invalid value passed to \"-$OptArg\""
984 exit 1
985 }
986 dict set Config "host_connection_limit" $OptVal
987 }
988 {C} {
989 if {![file isdirectory $OptVal]} {
990 puts stderr "directory \"$OptVal\" is not a directory"
991 exit 1
992 } elseif {![file readable $OptVal] ||
993 ![file executable $OptVal]} {
994 puts stderr "directory \"$OptVal\" is not readable"
995 exit 1
996 }
997 dict set Config "ca_dir" $OptVal
998 }
999 {d} {
1000 dict set Config "log_level" "debug"
1001 }
1002 {D} {
1003 if {![string is digit -strict $OptVal] || ($OptVal < 0)} {
1004 puts stderr "invalid value passed to \"-$OptArg\""
1005 exit 1
1006 }
1007 dict set Config "host_delay" [expr {$OptVal * 1000}]
1008 }
1009 {e} {
1010 dict set Config "error_filter" 1
1011 }
1012 {H} {
1013 if {![string is digit -strict $OptVal] || ($OptVal <= 0)} {
1014 puts stderr "invalid value passed to \"-$OptArg\""
1015 exit 1
1016 }
1017 dict set Config "connection_limit" $OptVal
1018 }
1019 {i} {
1020 foreach item [split $OptVal " "] {
1021 set item [string trim $item]
1022 if {$item ne ""} {
1023 dict lappend Config "item_filter" $item
1024 }
1025 }
1026 }
1027 {l} {
1028 dict set Config "log_file" $OptVal
1029 set LogDir [file dirname $OptVal]
1030 if {![file writable $LogDir] || ![file executable $LogDir]} {
1031 puts stderr "directory \"$LogDir\" is not writable"
1032 exit 1
1033 }
1034 }
1035 {r} {
1036 if {![string is digit -strict $OptVal] || ($OptVal < 0)} {
1037 puts stderr "invalid value passed to \"-$OptArg\""
1038 exit 1
1039 }
1040 dict set Config "retry_limit" $OptVal
1041 }
1042 {t} {
1043 if {![string is digit -strict $OptVal] || ($OptVal < 0)} {
1044 puts stderr "invalid value passed to \"-$OptArg\""
1045 exit 1
1046 }
1047 dict set Config "timestamp_filter" [expr {$OptVal * 1000}]
1048 }
1049 {T} {
1050 if {![string is digit -strict $OptVal] || ($OptVal <= 0)} {
1051 puts stderr "invalid value passed to \"-$OptArg\""
1052 exit 1
1053 }
1054 dict set Config "transfer_time_limit" [expr {$OptVal * 1000}]
1055 }
1056 {v} {
1057 if {[dict get $Config "log_level"] ne "debug"} {
1058 dict set Config "log_level" "info"
1059 }
1060 }
1061 }
1062 }
1063 set argc [llength $args]
1064 if {$GetoptRet == -1} {
1065 puts stderr "unknown command line option \"-$OptArg\""
1066 puts stderr $usage
1067 exit 1
1068 }
1069 if {$argc != 2} {
1070 puts stderr $usage
1071 exit 1
1072 }
1073 dict set Config "watchlist_file" [lindex $args 0]
1074 if {![file readable [dict get $Config "watchlist_file"]]} {
1075 puts stderr "watchlist file \"[dict get $Config "watchlist_file"]\"\
1076 could not be read"
1077 exit 1
1078 }
1079 set stateFile [lindex $args 1]
1080 dict set Config "state_file" $stateFile
1081 set StateDir [file dirname $stateFile]
1082 if {![file writable $StateDir]} {
1083 puts stderr "directory \"$StateDir\" is not writable"
1084
1085 exit 1
1086 }
1087
1088 # install exit handler for closing the logfile, open the logfile and
1089 # initialize logger
1090 trace add execution exit enter CleanupBeforeExit
1091 if {[dict get $Config "log_file"] ne ""} {
1092 try {
1093 set Lf [open [dict get $Config "log_file"] "w"]
1094 } trap {POSIX} {errorMsg errorOptions} {
1095 puts stderr "failed to open logfile\
1096 \"[dict get $Config "log_file"]\": $errorMsg"
1097 exit 1
1098 }
1099 } else {
1100 set Lf stderr
1101 }
1102 set Log [logger::init global]
1103 if {[dict get $Config "log_level"] eq "debug"} {
1104 set logFormat {%d \[%p\] \[%M\] %m}
1105 } else {
1106 set logFormat {%d \[%p\] %m}
1107 }
1108 logger::utils::applyAppender -appender fileAppend -appenderArgs \
1109 [list -outputChannel $Lf -conversionPattern $logFormat] \
1110 -serviceCmd $Log
1111
1112 # set default logging level
1113 ${Log}::setlevel [dict get $Config "log_level"]
1114
1115 ${Log}::notice "relmon.tcl starting up"
1116
1117 # parse the watchlist
1118 try {
1119 ParseWatchlist [dict get $Config "watchlist_file"]
1120 } trap {POSIX} {errorMsg errorOptions} - \
1121 trap {RELMON} {errorMsg errorOptions} {
1122 ${Log}::error $errorMsg
1123 exit 1
1124 }
1125
1126 # read the state file
1127 try {
1128 set State [::relmon::common::parseStateFile $stateFile]
1129 } trap {POSIX ENOENT} {errorMsg} {
1130 ${Log}::debug "state file \"$stateFile\" does not exist"
1131 set State [dict create]
1132 } trap {POSIX} {errorMsg} - \
1133 trap {RELMON} {errorMsg} {
1134 ${Log}::error $errorMsg
1135 exit 1
1136 }
1137
1138 # initialize queue and state buffer from the watchlist
1139 dict set Statistics "start_time" [clock milliseconds]
1140 dict for {name watchlistItem} $Watchlist {
1141 # apply filters specified on the command line to watchlist items
1142 if {([llength [dict get $Config "item_filter"]] > 0) &&
1143 ($name ni [dict get $Config "item_filter"])} {
1144 continue
1145 }
1146
1147 if {[dict get $Config "error_filter"] &&
1148 [dict exists $State $name "errors"] &&
1149 ([llength [dict get $State $name "errors"]] == 0)} {
1150 continue
1151 }
1152
1153 if {[dict exists $State $name "timestamp"] &&
1154 ([dict get $State $name "timestamp"] >
1155 [dict get $Statistics "start_time"] -
1156 [dict get $Config "timestamp_filter"])} {
1157 continue
1158 }
1159
1160 dict lappend Queue [::relmon::common::urlGetHost \
1161 [dict get $watchlistItem "base_url"]] \
1162 [dict create \
1163 "name" $name \
1164 "url" [dict get $watchlistItem "base_url"] \
1165 "pattern_index" 0 \
1166 "content_type" "" \
1167 "num_redirects" 0 \
1168 "num_retries" 0]
1169 dict incr Statistics "items"
1170 dict set StateBuffer $name [dict create "versions" [dict create] \
1171 "errors" [list]]
1172 }
1173
1174 # configure http and tls
1175 http::register https 443 [list tls::socket \
1176 -cadir [dict get $Config "ca_dir"] -request 1 -require 1 \
1177 -command [namespace code OnTlsHandshake] -ssl2 0 -ssl3 1 -tls1 1]
1178 http::config -useragent "Mozilla/5.0 (Windows NT 6.1; rv:16.0)\
1179 Gecko/20100101 Firefox/16.0 relmon/$relmon::VERSION"
1180
1181 # handle errors while in the event loop
1182 interp bgerror {} [namespace code OnError]
1183
1184 # enter the main loop
1185 after idle [namespace code ManageTransfers]
1186 vwait [namespace which -variable ExitStatus]
1187
1188 dict set Statistics "end_time" [clock milliseconds]
1189
1190 # display statistics
1191 ${Log}::notice "items checked: [dict get $Statistics "items"]"
1192 ${Log}::notice "time elapsed: [expr {([dict get $Statistics "end_time"] -
1193 [dict get $Statistics "start_time"]) / 1000}]s"
1194
1195 # serialize the new state
1196 set JsonStateItems {}
1197 dict for {item data} $State {
1198 set versions {}
1199 dict for {version url} [dict get $data "versions"] {
1200 lappend versions $version [json::write string $url]
1201 }
1202 set history {}
1203 foreach historyItem [dict get $data "history"] {
1204 lassign $historyItem version timestamp
1205 lappend history [json::write array [json::write string $version] \
1206 $timestamp]
1207 }
1208 set errors {}
1209 foreach errorItem [dict get $data "errors"] {
1210 lappend errors [json::write string $errorItem]
1211 }
1212 lappend JsonStateItems $item [json::write object \
1213 "versions" [json::write object {*}$versions] \
1214 "history" [json::write array {*}$history] \
1215 "timestamp" [dict get $data "timestamp"] \
1216 "errors" [json::write array {*}$errors]]
1217 }
1218 set JsonState [json::write object {*}$JsonStateItems]
1219
1220 # try to preserve permissions and ownership
1221 try {
1222 set stateFileAttributes [file attributes $stateFile]
1223 } trap {POSIX ENOENT} {} {
1224 set stateFileAttributes {}
1225 } trap {POSIX} {errorMsg errorOptions} {
1226 ${Log}::error "failed to stat \"$stateFile\": $errorMsg"
1227 }
1228 # write the new state to a temporary file
1229 set tmpFile "$stateFile.[pid].tmp"
1230 try {
1231 set f [open $tmpFile {RDWR CREAT EXCL TRUNC} 0600]
1232 } trap {POSIX} {errorMsg errorOptions} {
1233 ${Log}::error "failed to open \"$tmpFile\": $errorMsg"
1234
1235 exit 1
1236 }
1237 try {
1238 chan puts -nonewline $f $JsonState
1239 } trap {POSIX} {errorMsg errorOptions} {
1240 catch {file delete $tmpFile}
1241
1242 ${Log}::error "failed to write to \"$tmpFile\": $errorMsg"
1243
1244 exit 1
1245 } finally {
1246 close $f
1247 }
1248 # make a backup of the previous state file
1249 try {
1250 file copy -force $stateFile "$stateFile~"
1251 } trap {POSIX ENOENT} {} {
1252 # ignore non-existing file
1253 } trap {POSIX} {errorMsg errorOptions} {
1254 ${Log}::error "failed to create a backup of \"$statFile\":\
1255 $errorMsg"
1256 }
1257 # rename the temporary file to the state file name
1258 try {
1259 file rename -force $tmpFile $stateFile
1260 } trap {POSIX} {errorMsg errorOptions} {
1261 catch {file delete $tmpFile}
1262
1263 ${Log}::error "failed to rename \"$tmpFile\" to \"$stateFile\":\
1264 $errorMsg"
1265
1266 exit 1
1267 }
1268 # restore ownership and permissions
1269 try {
1270 file attributes $stateFile {*}$stateFileAttributes
1271 } trap {POSIX} {errorMsg errorOptions} {
1272 ${Log}::error "failed to set permissions and ownership on\
1273 \"$stateFile\": $errorMsg"
1274
1275 exit 1
1276 }
1277
1278 # clean up
1279 ${Log}::delete
1280
1281 exit $ExitStatus
1282 }
1283
1284
1285 namespace eval ::relmon::show {
1286 # commandline option help text
1287 variable usage "usage: relmon show statefile name..."
1288 }
1289
1290 proc ::relmon::show::GetItem {stateName name} {
1291 upvar 1 $stateName state
1292 set item [dict get $state $name]
1293
1294 # format state data as plain-text
1295 set output ""
1296 append output "Name: $name\n"
1297 append output "Latest Version:\
1298 [lindex [lindex [dict get $item "history"] end] 0]\n"
1299 append output "Refreshed: [clock format \
1300 [expr {[dict get $item "timestamp"] / 1000}] \
1301 -format {%Y-%m-%dT%H:%M:%S%z}]\n"
1302 append output "Versions:\n"
1303 dict for {version url} [dict get $item "versions"] {
1304 append output "\t$version $url\n"
1305 }
1306 append output "Errors:\n"
1307 if {[dict get $item "errors"] eq ""} {
1308 append output "\tNone\n"
1309 } else {
1310 foreach errorMsg [dict get $item "errors"] {
1311 append output "\t[string map {\n \n\t} [string trim $errorMsg]]\n"
1312 }
1313 }
1314 append output "History:\n"
1315 foreach historyItem [dict get $item "history"] {
1316 append output "\t[lindex $historyItem 0] [clock format \
1317 [expr {[lindex $historyItem 1] / 1000}] \
1318 -format {%Y-%m-%dT%H:%M:%S%z}]\n"
1319 }
1320 return $output
1321 }
1322
1323 proc ::relmon::show::main {args} {
1324 variable usage
1325
1326 # parse commandline
1327 if {[cmdline::getopt args {} OptArg OptVal] == -1} {
1328 puts stderr "unknown command line option \"-$OptArg\""
1329 puts stderr $usage
1330 exit 1
1331 }
1332 if {[llength $args] < 2} {
1333 puts stderr $usage
1334 exit 1
1335 }
1336 set stateFile [lindex $args 0]
1337 set names [lrange $args 1 end]
1338
1339 try {
1340 set state [::relmon::common::parseStateFile $stateFile]
1341 } trap {POSIX} {errorMsg} - trap {RELMON} {errorMsg} {
1342 puts stderr $errorMsg
1343 exit 1
1344 }
1345
1346 # show each item
1347 foreach name $names {
1348 puts -nonewline [GetItem state $name]
1349 }
1350
1351 exit 0
1352 }
1353
1354
1355 namespace eval ::relmon::list {
1356 # commandline option help text
1357 variable usage "usage: relmon list \[-H\] \[-f html|parseable|text\]\
1358 \[-F url\]\n\
1359 \ \[-n number_items\] statefile\n\
1360 \ relmon list -f atom -F url \[-n number_items\] statefile"
1361
1362 # configuration options
1363 variable Config [dict create \
1364 "format" "text" \
1365 "show_history" 0 \
1366 "history_limit" 100 \
1367 "feed_url" ""]
1368 }
1369
1370 proc ::relmon::list::FormatText {stateName includeHistory historyLimit} {
1371 upvar 1 $stateName state
1372 set output ""
1373 append output [format "%-35s %-15s %-24s %-3s\n" "Project" "Version" \
1374 "Refreshed" "St."]
1375 append output [string repeat "-" 80]
1376 append output "\n"
1377
1378 set history {}
1379 dict for {name item} $state {
1380 foreach historyItem [dict get $item "history"] {
1381 lappend history [list [lindex $historyItem 1] $name \
1382 [lindex $historyItem 0]]
1383 }
1384 set latestVersion [lindex [lindex [dict get $item "history"] end] 0]
1385 set timestamp [clock format [expr {[dict get $item "timestamp"] /
1386 1000}] -format {%Y-%m-%dT%H:%M:%S%z}]
1387 set status [expr {[llength [dict get $item "errors"]] > 0 ? "E" : ""}]
1388 append output [format "%-35s %15s %-24s %-1s\n" $name $latestVersion \
1389 $timestamp $status]
1390 }
1391 if {$includeHistory} {
1392 append output "\nHistory\n"
1393 append output [string repeat "-" 80]
1394 append output "\n"
1395 set history [lsort -decreasing -integer -index 0 $history]
1396 foreach historyItem [lrange $history 0 $historyLimit] {
1397 append output [format "%-24s %-35s %15s\n" \
1398 [clock format [expr {[lindex $historyItem 0] / 1000}] \
1399 -format {%Y-%m-%dT%H:%M:%S%z}] [lindex $historyItem 1] \
1400 [lindex $historyItem 2]]
1401 }
1402 }
1403
1404 return $output
1405 }
1406
1407 proc ::relmon::list::FormatParseable {stateName includeHistory historyLimit} {
1408 upvar 1 $stateName state
1409 set output ""
1410 set history {}
1411 dict for {name item} $state {
1412 foreach historyItem [dict get $item "history"] {
1413 lappend history [list [lindex $historyItem 1] $name \
1414 [lindex $historyItem 0]]
1415 }
1416 set latestVersion [lindex [lindex [dict get $item "history"] end] 0]
1417 if {$latestVersion eq ""} {
1418 set latestVersion -
1419 }
1420 set timestamp [clock format [expr {[dict get $item "timestamp"] /
1421 1000}] -timezone :UTC -format {%Y-%m-%dT%H:%M:%SZ}]
1422 set status [expr {[llength [dict get $item "errors"]] > 0 ? "ERROR" :
1423 "OK"}]
1424 append output [format "%s\t%s\t%s\t%s\n" $name $latestVersion \
1425 $timestamp $status]
1426 }
1427 if {$includeHistory} {
1428 append output "\n"
1429 set history [lsort -decreasing -integer -index 0 $history]
1430 foreach historyItem [lrange $history 0 $historyLimit] {
1431 append output [format "%s\t%s\t%s\n" [clock format \
1432 [expr {[lindex $historyItem 0] / 1000}] -timezone :UTC \
1433 -format {%Y-%m-%dT%H:%M:%SZ}] [lindex $historyItem 1] \
1434 [lindex $historyItem 2]]
1435 }
1436 }
1437 return $output
1438 }
1439
1440 proc ::relmon::list::FormatHtml {stateName includeHistory historyLimit
1441 feedUrl} {
1442 upvar 1 $stateName state
1443
1444 set output "<html>\n"
1445 append output "<head>\n"
1446 append output "<title>Current Releases</title>\n"
1447 if {$feedUrl ne ""} {
1448 append output "<link type=\"application/atom+xml\" rel=\"alternate\"\
1449 title=\"Release History\"\
1450 href=\"[html::html_entities $feedUrl]\"/>\n"
1451 }
1452 append output "</head>\n"
1453 append output "<body>\n"
1454 append output "<h1>Current Releases</h1>\n<table>\n<tr>\n<th>Project</th>\
1455 \n<th>Version</th>\n<th>Refreshed</th>\n<th>Status</th>\n</tr>\n"
1456 set history {}
1457 dict for {name item} $state {
1458 foreach historyItem [dict get $item "history"] {
1459 lappend history [list [lindex $historyItem 1] $name \
1460 [lindex $historyItem 0]]
1461 }
1462 set latestVersion [lindex [lindex [dict get $item "history"] end] 0]
1463 set timestamp [clock format [expr {[dict get $item "timestamp"] /
1464 1000}] -format {%Y-%m-%dT%H:%M:%S%z}]
1465 set status [expr {[llength [dict get $item "errors"]] > 0 ? "Error" :
1466 "OK"}]
1467
1468 append output "<tr>\n<td>[html::html_entities $name]</td>\n"
1469 if {$latestVersion ne ""} {
1470 if {[dict exists $item "versions" $latestVersion]} {
1471 set url [dict get $item "versions" $latestVersion]
1472 append output "<td><a\
1473 href=\"[html::html_entities $url]\"\
1474 title=\"[html::html_entities\
1475 "$name $latestVersion"]\">[html::html_entities \
1476 $latestVersion]</a></td>\n"
1477 } else {
1478 append output "<td>[html::html_entities \
1479 $latestVersion]</td>\n"
1480 }
1481 } else {
1482 append output "<td></td>\n"
1483 }
1484 append output "<td>$timestamp</td>\n"
1485 append output "<td>[html::html_entities $status]</td>\n</tr>\n"
1486 }
1487 append output "</table>\n"
1488
1489 if {$includeHistory} {
1490 set history [lsort -decreasing -integer -index 0 $history]
1491 append output "<h1>Release History</h1>\n<table>\n"
1492 append output "<tr><th>Time</th><th>Project</th><th>Version</th></tr>\n"
1493 foreach historyItem [lrange $history 0 $historyLimit] {
1494 set timestamp [clock format [expr {[lindex $historyItem 0] /
1495 1000}] -format {%Y-%m-%dT%H:%M:%S%z}]
1496 set name [lindex $historyItem 1]
1497 set version [lindex $historyItem 2]
1498 append output "<tr>\n<td>$timestamp</td>\n"
1499 append output "<td>[html::html_entities $name]</td>\n"
1500 append output "<td>[html::html_entities $version]</td></tr>\n"
1501 }
1502 append output "</table>\n"
1503 }
1504
1505 append output "</body>\n</html>\n"
1506
1507 return $output
1508 }
1509
1510 proc ::relmon::list::FormatAtom {stateName historyLimit feedUrl} {
1511 upvar 1 $stateName state
1512 set host [::relmon::common::urlGetHost $feedUrl]
1513 set output "<?xml version=\"1.0\" encoding=\"utf-8\"?>\
1514 \n<feed xmlns=\"http://www.w3.org/2005/Atom\">\n"
1515 append output "<author><name>relmon</name></author>\n"
1516 append output "<title>Release History</title>\n"
1517 append output "<id>[html::html_entities $feedUrl]</id>\n"
1518 set history {}
1519 dict for {name item} $state {
1520 foreach historyItem [dict get $item "history"] {
1521 lappend history [list [lindex $historyItem 1] $name \
1522 [lindex $historyItem 0]]
1523 }
1524 }
1525 set history [lsort -decreasing -integer -index 0 $history]
1526 set updated [lindex [lindex $history end] 0]
1527 if {$updated eq ""} {
1528 set updated [clock seconds]
1529 }
1530 append output "<updated>[clock format $updated \
1531 -format {%Y-%m-%dT%H:%M:%S%z}]</updated>\n"
1532 foreach historyItem [lrange $history 0 $historyLimit] {
1533 set name [lindex $historyItem 1]
1534 set version [lindex $historyItem 2]
1535 set timestamp [clock format [expr {[lindex $historyItem 0] / 1000}] \
1536 -format {%Y-%m-%dT%H:%M:%S%z}]
1537 set id "tag:$host,[clock format [lindex $historyItem 0] \
1538 -format {%Y-%m-%d}]:[uri::urn::quote $name-$version]"
1539 append output "<entry>\n"
1540 append output "<id>[html::html_entities $id]</id>\n"
1541 append output "<updated>$timestamp</updated>\n"
1542 append output "<title>[html::html_entities "$name $version"]</title>"
1543 append output "<content>[html::html_entities \
1544 "$name $version"]</content>\n"
1545 append output "</entry>\n"
1546 }
1547 append output "</feed>\n"
1548 return $output
1549 }
1550
1551 proc ::relmon::list::main {args} {
1552 variable usage
1553 variable Config
1554
1555 # parse commandline
1556 while {[set GetoptRet [cmdline::getopt args {f.arg F.arg H n.arg} OptArg \
1557 OptVal]] == 1} {
1558 switch -glob -- $OptArg {
1559 {f} {
1560 if {$OptVal ni {atom html parseable text}} {
1561 puts stderr "invalid value passed to \"-$OptArg\""
1562 exit 1
1563 }
1564 dict set Config "format" $OptVal
1565 }
1566 {F} {
1567 if {[catch {dict create {*}[uri::split $OptVal]} UrlParts] ||
1568 ([dict get $UrlParts "host"] eq "")} {
1569 puts stderr "invalid value passed to \"-$OptArg\""
1570 exit 1
1571 }
1572 dict set Config "feed_url" $OptVal
1573 }
1574 {H} {
1575 dict set Config "show_history" 1
1576 }
1577 {n} {
1578 if {![string is digit -strict $OptVal] || ($OptVal <= 0)} {
1579 puts stderr "invalid value passed to \"-$OptArg\""
1580 exit 1
1581 }
1582 dict set Config "history_limit" [expr {$OptVal - 1}]
1583 }
1584 }
1585 }
1586 set argc [llength $args]
1587 if {$GetoptRet == -1} {
1588 puts stderr "unknown command line option \"-$OptArg\""
1589 puts stderr $usage
1590 exit 1
1591 }
1592 if {$argc != 1} {
1593 puts stderr $usage
1594 exit 1
1595 }
1596 if {([dict get $Config "format"] eq "atom") &&
1597 ([dict get $Config "feed_url"] eq "")} {
1598 puts stderr "mandatory \"-F\" option is missing"
1599 puts stderr $usage
1600 exit 1
1601 }
1602 set StateFile [lindex $args 0]
1603
1604 # read the state file
1605 try {
1606 set State [::relmon::common::parseStateFile $StateFile]
1607 } trap {POSIX} {errorMsg} - trap {RELMON} {errorMsg} {
1608 puts stderr $errorMsg
1609 exit 1
1610 }
1611
1612 # call formatter
1613 switch -- [dict get $Config "format"] {
1614 {atom} {
1615 puts -nonewline [FormatAtom State \
1616 [dict get $Config "history_limit"] \
1617 [dict get $Config "feed_url"]]
1618 }
1619 {html} {
1620 puts -nonewline [FormatHtml State \
1621 [dict get $Config "show_history"] \
1622 [dict get $Config "history_limit"] \
1623 [dict get $Config "feed_url"]]
1624 }
1625 {parseable} {
1626 puts -nonewline [FormatParseable State \
1627 [dict get $Config "show_history"] \
1628 [dict get $Config "history_limit"]]
1629 }
1630 {default} {
1631 puts -nonewline [FormatText State \
1632 [dict get $Config "show_history"] \
1633 [dict get $Config "history_limit"]]
1634 }
1635 }
1636
1637 exit 0
1638 }
1639
1640
1641 namespace eval ::relmon::help {
1642 # commandline option help text
1643 variable usage "usage: relmon help \[subcommand\]"
1644 }
1645
1646 proc ::relmon::help::main {args} {
1647 variable usage
1648
1649 # parse commandline
1650 if {[cmdline::getopt args {} OptArg OptVal] == -1} {
1651 puts stderr "unknown command line option \"-$OptArg\""
1652 puts stderr $usage
1653 exit 1
1654 }
1655 set argc [llength $args]
1656 if {$argc > 1} {
1657 puts stderr $usage
1658 exit 1
1659 }
1660 set subCommand [lindex $args 0]
1661 if {$subCommand ne ""} {
1662 if {[info procs ::relmon::${subCommand}::main] ne ""} {
1663 puts stderr [set ::relmon::${subCommand}::usage]
1664 } else {
1665 puts stderr "unknown subcommand \"$subCommand\""
1666 puts stderr $usage
1667 exit 1
1668 }
1669 } else {
1670 foreach subCommandNs [namespace children ::relmon] {
1671 if {[info procs ${subCommandNs}::main] ne ""} {
1672 puts stderr [set ${subCommandNs}::usage]
1673 }
1674 }
1675 }
1676 exit 0
1677 }
1678
1679
1680 proc ::relmon::main {args} {
1681 variable usage
1682 set subArgs [lassign $args subCommand]
1683
1684 # generate list of subcommands
1685 set subCommands {}
1686 foreach subCommandNs [namespace children ::relmon] {
1687 if {[info procs ${subCommandNs}::main] ne ""} {
1688 lappend subCommands [namespace tail $subCommandNs]
1689 }
1690 }
1691 if {$subCommand ni $subCommands} {
1692 if {$subCommand ne ""} {
1693 puts stderr "unknown subcommand \"$subCommand\""
1694 }
1695 foreach command $subCommands {
1696 puts stderr [set relmon::${command}::usage]
1697 }
1698 exit 1
1699 }
1700
1701 # dispatch subcommand
1702 relmon::${subCommand}::main {*}$subArgs
1703 }
1704
1705
1706 relmon::main {*}$argv