comparison relmon.tcl @ 0:8c5330f6e9e4

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