Make SSM copy use host path syntax
ober
6aaed8fd95674584cf0448faa0f24386cdc29ff2
--- a/README.md +++ b/README.md @@ -29,8 +29,9 @@ PSSM strips remote terminal controls in human output, supports `--literal-controls` for visible `\xNN` rendering, and honors `--no-color`. Use `--json` when lossless remote output is required. -`jerboa-aws ssmcp` and `jerboa-aws ssm cp` recursively upload a local file or -directory through SSM RunCommand using tar/base64 chunks. Targets must already +`jerboa-aws ssmcp` and `jerboa-aws ssm cp` recursively copy with +`host:path` syntax: `host:~/save ./foo` downloads and `./foo host:/tmp/foo` +uploads through SSM RunCommand using tar/base64 chunks. Targets must already have the SSM agent plus `sh`, `tar`, `base64`, `mkdir`, and `find`. Credential handling, logging expectations, and release requirements are --- a/src/jerboa-aws/cli/main.ss +++ b/src/jerboa-aws/cli/main.ss @@ -233,7 +233,7 @@ Services: sts STS identity and session operations iam IAM users, groups, roles, policies ssm Systems Manager parameters and commands - ssmcp Recursive upload over SSM SendCommand + ssmcp Recursive copy over SSM SendCommand Global options: --profile, -p NAME AWS profile name @@ -778,8 +778,10 @@ For low-level API commands use 's3api': ;; ---- SSM subcommands ---- (define (ssm-dispatch action args profile region output-fmt) - (let ((client (make-ssm-client profile region))) - (cond + (if (string=? action "cp") + (apply ssmcp-main (args-with-profile profile args)) + (let ((client (make-ssm-client profile region))) + (cond ((string=? action "put-parameter") (let ((name (or (get-opt args "--name") (error 'ssm "put-parameter requires --name"))) @@ -823,8 +825,6 @@ For low-level API commands use 's3api': 'document-name: doc 'comment: comment) output-fmt))) - ((string=? action "cp") - (apply ssmcp-main (args-with-profile profile args))) ((string=? action "get-command-invocation") (let ((cmd-id (or (get-opt args "--command-id") (error 'ssm "get-command-invocation requires --command-id"))) @@ -841,11 +841,11 @@ For low-level API commands use 's3api': delete-parameter --name NAME describe-instance-information send-command --instance-ids IDS --command CMD [--document-name DOC] [--comment TEXT] - cp [options] <pattern> <local-src> <remote-dest-dir> + cp [options] HOST:PATH LOCAL | LOCAL HOST:PATH get-command-invocation --command-id ID --instance-id ID -For parallel SSM execution, use the 'pssm' command. For recursive SSM upload, -use 'jerboa-aws ssm cp' or 'jerboa-aws ssmcp'. +For parallel SSM execution, use the 'pssm' command. For single-host recursive +SSM copy, use 'jerboa-aws ssm cp' or 'jerboa-aws ssmcp'. For SecureString values, --value-stdin is strongly recommended. Input is read exactly (including a trailing newline) and is limited to 8192 UTF-8 bytes. @@ -853,7 +853,7 @@ exactly (including a trailing newline) and is limited to 8192 UTF-8 bytes. (exit 0)) (else (display (format "Unknown ssm action: ~a\nRun 'jerboa-aws ssm help' for available commands.\n" action)) - (exit 1))))) + (exit 1)))))) ;; ---- Main entry point ---- --- a/src/jerboa-aws/ssmcp.ss +++ b/src/jerboa-aws/ssmcp.ss @@ -1,12 +1,15 @@ -;;; (jerboa-aws ssmcp) -- recursive upload over AWS SSM SendCommand +;;; (jerboa-aws ssmcp) -- recursive file copy over AWS SSM SendCommand (export ssmcp-main parse-ssmcp-args ssmcp-shell-quote) (import (chezscheme) - (jerboa-aws pssm)) + (jerboa-aws json) + (jerboa-aws ssm api) + (jerboa-aws ssm operations)) (define SSMCP-CHUNK-SIZE 6000) +(define SSMCP-B64-PREFIX "__JERBOA_SSMCP_B64__") (define (cfg-ref cfg key . default) (hashtable-ref cfg key (if (pair? default) (car default) #f))) @@ -24,6 +27,12 @@ (newline (current-error-port)) (exit 1)) +(define (condition->string e) + (if (condition? e) + (call-with-string-output-port + (lambda (p) (display-condition e p))) + (format "~a" e))) + (define (ssmcp-shell-quote text) (let ([out (open-output-string)]) (put-char out #\') @@ -43,6 +52,25 @@ [(char=? (string-ref text i) target) i] [else (loop (- i 1))]))) +(define (string-index text target) + (let ([len (string-length text)]) + (let loop ([i 0]) + (cond + [(= i len) #f] + [(char=? (string-ref text i) target) i] + [else (loop (+ i 1))])))) + +(define (string-prefix? prefix text) + (let ([n (string-length prefix)]) + (and (<= n (string-length text)) + (string=? prefix (substring text 0 n))))) + +(define (string-suffix? suffix text) + (let ([n (string-length suffix)] + [m (string-length text)]) + (and (<= n m) + (string=? suffix (substring text (- m n) m))))) + (define (trim-trailing-slashes path) (let loop ([n (string-length path)]) (cond @@ -67,10 +95,27 @@ (substring clean (+ slash 1) (string-length clean)) clean))) +(define (local-expand-path path) + (cond + [(string=? path "~") (getenv "HOME")] + [(string-prefix? "~/" path) + (string-append (getenv "HOME") (substring path 1 (string-length path)))] + [else path])) + +(define (local-dest-directory? path) + (let ([p (local-expand-path path)]) + (or (string-suffix? "/" p) + (and (file-exists? p) (file-directory? p))))) + (define (read-file-string path) (call-with-input-file path (lambda (port) (get-string-all port)))) +(define (write-file-string path text) + (call-with-output-file path + (lambda (port) (put-string port text)) + 'replace)) + (define (delete-file/quiet path) (guard (e [#t #f]) (delete-file path))) @@ -82,13 +127,155 @@ (let ([t (current-time 'time-utc)]) (format "~a-~a" (time-second t) (time-nanosecond t)))) -(define (local-payload-path) - (format "/tmp/jerboa-aws-ssmcp-~a-~a.b64" +(define (tmp-path suffix) + (format "/tmp/jerboa-aws-ssmcp-~a-~a.~a" (get-process-id) - (current-time-token))) + (current-time-token) + suffix)) + +(define (parse-remote-spec operand) + (let ([colon (string-index operand #\:)]) + (and colon + (> colon 0) + (< (+ colon 1) (string-length operand)) + (cons (substring operand 0 colon) + (substring operand (+ colon 1) (string-length operand)))))) + +(define (remote-path-shell path) + (cond + [(string=? path "~") "$HOME"] + [(string-prefix? "~/" path) + (string-append "$HOME/" + (ssmcp-shell-quote (substring path 2 (string-length path))))] + [else (ssmcp-shell-quote path)])) + +(define (remote-dir-shell path) + (remote-path-shell (path-dirname path))) + +(define (remote-base-shell path) + (ssmcp-shell-quote (path-basename path))) + +(define (default-cache-file) + (string-append (getenv "HOME") "/.aws-ec2-cache.json")) + +(define (ht-ref ht key . default) + (if (hashtable? ht) + (guard (e [#t (if (pair? default) (car default) #f)]) + (hashtable-ref ht key (if (pair? default) (car default) #f))) + (if (pair? default) (car default) #f))) + +(define (ht-ref/str ht key) + (let ([v (ht-ref ht key "")]) + (if (string? v) v ""))) -(define (local-tar-command source output-path) - (let ([src (trim-trailing-slashes source)]) +(define (load-instance-cache path) + (guard (e [#t (ssmcp-error "cannot read cache: ~a\nHint: run pssm --refresh or provide --cache" path)]) + (let ([data (call-with-input-file path read-json)]) + (filter (lambda (inst) + (string=? (ht-ref/str inst "state") "running")) + (cond + [(list? data) data] + [(vector? data) (vector->list data)] + [else '()]))))) + +(define (host-exact-match? host inst) + (or (string=? host (ht-ref/str inst "name")) + (string=? host (ht-ref/str inst "fqdn")) + (string=? host (ht-ref/str inst "host")) + (string=? host (ht-ref/str inst "private_ip")) + (string=? host (ht-ref/str inst "public_ip")) + (string=? host (ht-ref/str inst "instance_id")))) + +(define (glob-match? pattern text) + (let match ([pat (string->list pattern)] [str (string->list text)]) + (cond + [(and (null? pat) (null? str)) #t] + [(null? pat) #f] + [(char=? (car pat) #\*) + (or (and (null? (cdr pat)) #t) + (match (cdr pat) str) + (and (pair? str) (match pat (cdr str))))] + [(null? str) #f] + [(char=? (char-downcase (car pat)) (char-downcase (car str))) + (match (cdr pat) (cdr str))] + [else #f]))) + +(define (host-glob-match? host inst) + (glob-match? host (ht-ref/str inst "name"))) + +(define (resolve-single-host host cache-file) + (let* ([instances (load-instance-cache cache-file)] + [exact (filter (lambda (inst) (host-exact-match? host inst)) instances)] + [matches (if (pair? exact) + exact + (filter (lambda (inst) (host-glob-match? host inst)) instances))]) + (cond + [(null? matches) (ssmcp-error "no running cached host matches: ~a" host)] + [(pair? (cdr matches)) + (display "ssmcp: host matched multiple cached instances:\n" (current-error-port)) + (for-each + (lambda (inst) + (fprintf (current-error-port) " ~a ~a ~a\n" + (ht-ref/str inst "name") + (ht-ref/str inst "instance_id") + (ht-ref/str inst "region"))) + matches) + (ssmcp-error "ambiguous host: ~a" host)] + [else (car matches)]))) + +(define (current-seconds) + (let ([t (current-time 'time-utc)]) + (+ (time-second t) + (/ (time-nanosecond t) 1000000000.0)))) + +(define (sleep-seconds n) + (let ([secs (exact (floor n))] + [nsecs (exact (floor (* (- n (floor n)) 1000000000)))]) + (sleep (make-time 'time-duration nsecs secs)))) + +(define (make-ssm-client inst profile) + (apply SSMClient + (append + (list 'region: (ht-ref/str inst "region")) + (if profile (list 'profile: profile) '())))) + +(define (complete-status? status) + (member status '("Success" "Failed" "Cancelled" "TimedOut"))) + +(define (run-single-ssm inst config command) + (let* ([client (make-ssm-client inst (cfg-ref config "profile"))] + [inst-id (ht-ref/str inst "instance_id")] + [timeout (cfg-ref config "timeout")] + [poll-interval (cfg-ref config "poll-interval")] + [start (current-seconds)] + [resp (send-command client (list inst-id) command + 'document-name: "AWS-RunShellScript")] + [cmd (ht-ref resp "Command")] + [cmd-id (if cmd (ht-ref/str cmd "CommandId") "")]) + (when (string=? cmd-id "") + (ssmcp-error "SSM SendCommand returned no command id")) + (let loop () + (when (> (- (current-seconds) start) timeout) + (ssmcp-error "SSM command timed out on ~a" (ht-ref/str inst "name"))) + (sleep-seconds poll-interval) + (guard (e [#t (loop)]) + (let* ([out (get-command-invocation client cmd-id inst-id)] + [status (ht-ref/str out "Status")]) + (if (complete-status? status) + (let ([stdout (or (ht-ref out "StandardOutputContent") "")] + [stderr (or (ht-ref out "StandardErrorContent") "")] + [code (or (ht-ref out "ResponseCode") -1)]) + (unless (and (string=? status "Success") (= code 0)) + (ssmcp-error "remote command failed on ~a: ~a~a~a" + (ht-ref/str inst "name") + status + (if (string=? stderr "") "" ": ") + stderr)) + stdout) + (loop))))))) + +(define (local-package-command source output-path) + (let ([src (trim-trailing-slashes (local-expand-path source))]) (if (file-directory? src) (format "tar -C ~a -czf - . | base64 | tr -d '\\n' > ~a" (ssmcp-shell-quote src) @@ -99,17 +286,18 @@ (ssmcp-shell-quote output-path))))) (define (make-local-payload source) - (unless (file-exists? source) - (ssmcp-error "source does not exist: ~a" source)) - (let ([payload-path (local-payload-path)]) - (delete-file/quiet payload-path) - (let ([command (local-tar-command source payload-path)]) - (unless (system-ok? command) - (delete-file/quiet payload-path) - (ssmcp-error "failed to package source with tar/base64: ~a" source))) - (let ([payload (read-file-string payload-path)]) + (let ([src (local-expand-path source)]) + (unless (file-exists? src) + (ssmcp-error "local source does not exist: ~a" source)) + (let ([payload-path (tmp-path "b64")]) (delete-file/quiet payload-path) - payload))) + (let ([command (local-package-command src payload-path)]) + (unless (system-ok? command) + (delete-file/quiet payload-path) + (ssmcp-error "failed to package local source: ~a" source))) + (let ([payload (read-file-string payload-path)]) + (delete-file/quiet payload-path) + payload)))) (define (split-string text chunk-size) (let ([len (string-length text)]) @@ -119,56 +307,170 @@ (let ([end (min len (+ start chunk-size))]) (loop end (cons (substring text start end) chunks))))))) -(define (remote-payload-path) - (format "/tmp/jerboa-aws-ssmcp-~a-~a.b64" - (get-process-id) - (current-time-token))) - -(define (remote-init-command remote-tmp) +(define (remote-upload-init-command remote-tmp) (let ([qtmp (ssmcp-shell-quote remote-tmp)]) (format "set -eu; rm -f ~a; : > ~a; chmod 600 ~a" qtmp qtmp qtmp))) -(define (remote-append-command remote-tmp chunk) +(define (remote-upload-append-command remote-tmp chunk) (format "printf %s ~a >> ~a" (ssmcp-shell-quote chunk) (ssmcp-shell-quote remote-tmp))) -(define (remote-final-command remote-tmp remote-dest delete?) - (let ([qtmp (ssmcp-shell-quote remote-tmp)] - [qdest (ssmcp-shell-quote remote-dest)]) +(define (remote-upload-final-command remote-tmp local-source remote-dest delete?) + (let* ([qtmp (ssmcp-shell-quote remote-tmp)] + [qdest (remote-path-shell remote-dest)] + [qparent (remote-dir-shell remote-dest)] + [qbase (remote-base-shell remote-dest)] + [local-dir? (file-directory? (local-expand-path local-source))]) + (if local-dir? + (string-append + "set -eu; mkdir -p " qdest "; " + (if delete? + (string-append "find " qdest " -mindepth 1 -maxdepth 1 -exec rm -rf {} +; ") + "") + "base64 -d " qtmp " | tar -xzf - -C " qdest "; rm -f " qtmp) + (string-append + "set -eu; if [ -d " qdest " ]; then target=" qdest "; " + "else mkdir -p " qparent "; target=$(mktemp -d /tmp/jerboa-aws-ssmcp.XXXXXX); fi; " + "base64 -d " qtmp " | tar -xzf - -C $target; " + "if [ \"$target\" != " qdest " ]; then mv -f $target/" (remote-base-shell local-source) " " qdest "; rmdir $target; fi; " + "rm -f " qtmp)))) + +(define (remote-download-prepare-command remote-source remote-tmp) + (let ([qsrc (remote-path-shell remote-source)] + [qdir (remote-dir-shell remote-source)] + [qbase (remote-base-shell remote-source)] + [qtmp (ssmcp-shell-quote remote-tmp)]) (string-append - "set -eu; mkdir -p " qdest "; " - (if delete? - (string-append "find " qdest " -mindepth 1 -maxdepth 1 -exec rm -rf {} +; ") - "") - "base64 -d " qtmp " | tar -xzf - -C " qdest "; " - "rm -f " qtmp))) - -(define (pssm-args config command dry-run?) - (append - (if (cfg-ref config "profile") - (list "--profile" (cfg-ref/str config "profile")) - '()) - (if (cfg-ref config "timeout") - (list "--timeout" (number->string (cfg-ref config "timeout"))) - '()) - (if (cfg-ref config "poll-interval") - (list "--poll" (number->string (cfg-ref config "poll-interval"))) - '()) - (if (cfg-ref config "cache-file") - (list "--cache" (cfg-ref/str config "cache-file")) - '()) - (if (cfg-ref config "verbose") '("--verbose") '()) - (if (cfg-ref config "no-color") '("--no-color") '()) - (if (cfg-ref config "literal-controls") '("--literal-controls") '()) - (if dry-run? '("--dry-run") '()) - (list (cfg-ref/str config "pattern") command))) - -(define (run-pssm config command) - (apply pssm-main (pssm-args config command #f))) - -(define (run-pssm-dry-run config command) - (apply pssm-main (pssm-args config command #t))) + "set -eu; rm -f " qtmp "; " + "if [ -d " qsrc " ]; then " + "printf 'D\\n'; tar -C " qsrc " -czf - . | base64 | tr -d '\\n' > " qtmp "; " + "else printf 'F:%s\\n' " qbase "; " + "tar -C " qdir " -czf - " qbase " | base64 | tr -d '\\n' > " qtmp "; fi; " + "wc -c < " qtmp))) + +(define (remote-download-chunk-command remote-tmp chunk-size index) + (format "dd if=~a bs=~a skip=~a count=1 2>/dev/null" + (ssmcp-shell-quote remote-tmp) + chunk-size + index)) + +(define (remote-rm-command path) + (format "rm -f ~a" (ssmcp-shell-quote path))) + +(define (first-line text) + (let ([nl (string-index text #\newline)]) + (if nl (substring text 0 nl) text))) + +(define (second-line text) + (let ([nl (string-index text #\newline)]) + (if nl + (let ([rest (substring text (+ nl 1) (string-length text))]) + (first-line rest)) + ""))) + +(define (parse-kind line) + (cond + [(string=? line "D") 'directory] + [(string-prefix? "F:" line) 'file] + [else (ssmcp-error "remote packaging returned invalid header: ~a" line)])) + +(define (parse-size text) + (let ([line (second-line text)]) + (parse-number-option "remote payload size" line))) + +(define (extract-local-payload payload-path kind file-name destination delete?) + (let* ([dest (local-expand-path destination)] + [decode (format "(base64 -d < ~a || base64 -D < ~a)" + (ssmcp-shell-quote payload-path) + (ssmcp-shell-quote payload-path))]) + (if (eq? kind 'directory) + (let ([cmd (string-append + "set -eu; mkdir -p " (ssmcp-shell-quote dest) "; " + (if delete? + (string-append "find " (ssmcp-shell-quote dest) + " -mindepth 1 -maxdepth 1 -exec rm -rf {} +; ") + "") + decode " | tar -xzf - -C " (ssmcp-shell-quote dest))]) + (unless (system-ok? cmd) + (ssmcp-error "failed to extract remote directory into: ~a" destination))) + (if (local-dest-directory? dest) + (let ([cmd (string-append + "set -eu; mkdir -p " (ssmcp-shell-quote dest) "; " + decode " | tar -xzf - -C " (ssmcp-shell-quote dest))]) + (unless (system-ok? cmd) + (ssmcp-error "failed to extract remote file into: ~a" destination))) + (let ([tmpdir (tmp-path "dir")] + [parent (path-dirname dest)]) + (let ([cmd (string-append + "set -eu; rm -rf " (ssmcp-shell-quote tmpdir) "; " + "mkdir -p " (ssmcp-shell-quote tmpdir) " " (ssmcp-shell-quote parent) "; " + decode " | tar -xzf - -C " (ssmcp-shell-quote tmpdir) "; " + "mv -f " (ssmcp-shell-quote (string-append tmpdir "/" file-name)) + " " (ssmcp-shell-quote dest) "; " + "rmdir " (ssmcp-shell-quote tmpdir))]) + (unless (system-ok? cmd) + (ssmcp-error "failed to extract remote file into: ~a" destination)))))))) + +(define (upload-file inst config local-source remote-dest) + (let* ([payload (make-local-payload local-source)] + [chunks (split-string payload (cfg-ref config "chunk-size"))] + [remote-tmp (format "/tmp/jerboa-aws-ssmcp-~a-~a.b64" + (get-process-id) (current-time-token))]) + (display (format "ssmcp: uploading ~a to ~a:~a (~a chunk~a)\n" + local-source + (ht-ref/str inst "name") + remote-dest + (length chunks) + (if (= (length chunks) 1) "" "s"))) + (run-single-ssm inst config (remote-upload-init-command remote-tmp)) + (for-each + (lambda (chunk) + (run-single-ssm inst config (remote-upload-append-command remote-tmp chunk))) + chunks) + (run-single-ssm inst config + (remote-upload-final-command remote-tmp local-source remote-dest (cfg-ref config "delete"))) + (display "ssmcp: upload complete\n"))) + +(define (download-file inst config remote-source local-dest) + (let* ([remote-tmp (format "/tmp/jerboa-aws-ssmcp-~a-~a.b64" + (get-process-id) (current-time-token))] + [header (run-single-ssm inst config + (remote-download-prepare-command remote-source remote-tmp))] + [kind (parse-kind (first-line header))] + [file-name (if (eq? kind 'file) (path-basename remote-source) "")] + [size (parse-size header)] + [chunk-size (cfg-ref config "chunk-size")] + [chunks (+ (quotient size chunk-size) + (if (> (remainder size chunk-size) 0) 1 0))] + [payload-path (tmp-path "download.b64")]) + (display (format "ssmcp: downloading ~a:~a to ~a (~a chunk~a)\n" + (ht-ref/str inst "name") + remote-source + local-dest + chunks + (if (= chunks 1) "" "s"))) + (delete-file/quiet payload-path) + (let loop ([i 0] [parts '()]) + (if (= i chunks) + (write-file-string payload-path (apply string-append (reverse parts))) + (loop (+ i 1) + (cons (run-single-ssm inst config + (remote-download-chunk-command remote-tmp chunk-size i)) + parts)))) + (guard (e [#t #f]) + (run-single-ssm inst config (remote-rm-command remote-tmp))) + (extract-local-payload payload-path kind file-name local-dest (cfg-ref config "delete")) + (delete-file/quiet payload-path) + (display "ssmcp: download complete\n"))) + +(define (parse-number-option name value) + (unless (decimal-digits? value) + (ssmcp-error "~a requires a positive decimal integer" name)) + (let ([n (decimal-string->integer value)]) + (unless (> n 0) + (ssmcp-error "~a requires a positive decimal integer" name)) + n)) (define (decimal-digits? text) (and (> (string-length text) 0) @@ -187,30 +489,20 @@ (char->integer #\0))]) (loop (+ i 1) (+ (* n 10) digit)))))) -(define (parse-number-option name value) - (unless (decimal-digits? value) - (ssmcp-error "~a requires a positive decimal integer" name)) - (let ([n (decimal-string->integer value)]) - (unless (> n 0) - (ssmcp-error "~a requires a positive decimal integer" name)) - n)) - (define (parse-ssmcp-args args) (let ([config (make-hashtable string-hash string=?)]) (put-cfg! config "timeout" 300) (put-cfg! config "poll-interval" 2) (put-cfg! config "chunk-size" SSMCP-CHUNK-SIZE) + (put-cfg! config "cache-file" (default-cache-file)) (let loop ([rest args] [positional '()]) (cond [(null? rest) (let ([pos (reverse positional)]) - (when (pair? pos) - (put-cfg! config "pattern" (car pos))) + (when (pair? pos) (put-cfg! config "source" (car pos))) (when (and (pair? pos) (pair? (cdr pos))) - (put-cfg! config "source" (cadr pos))) - (when (and (pair? pos) (pair? (cdr pos)) (pair? (cddr pos))) - (put-cfg! config "destination" (caddr pos))) - (put-cfg! config "extra-positional" (if (> (length pos) 3) (cdddr pos) '())) + (put-cfg! config "destination" (cadr pos))) + (put-cfg! config "extra-positional" (if (> (length pos) 2) (cddr pos) '())) config)] [(or (string=? (car rest) "--help") (string=? (car rest) "-h")) (put-cfg! config "help" #t) @@ -224,12 +516,6 @@ [(or (string=? (car rest) "--verbose") (string=? (car rest) "-v")) (put-cfg! config "verbose" #t) (loop (cdr rest) positional)] - [(string=? (car rest) "--no-color") - (put-cfg! config "no-color" #t) - (loop (cdr rest) positional)] - [(string=? (car rest) "--literal-controls") - (put-cfg! config "literal-controls" #t) - (loop (cdr rest) positional)] [(and (or (string=? (car rest) "--profile") (string=? (car rest) "-p")) (pair? (cdr rest))) (put-cfg! config "profile" (cadr rest)) @@ -251,30 +537,29 @@ (loop (cdr rest) (cons (car rest) positional))])))) (define (print-ssmcp-usage) - (display "ssmcp - recursive upload over AWS SSM SendCommand + (display "ssmcp - recursive copy over AWS SSM SendCommand -Usage: ssmcp [options] <pattern> <local-src> <remote-dest-dir> +Usage: + ssmcp [options] <host>:<remote-path> <local-path> + ssmcp [options] <local-path> <host>:<remote-path> -Copies a local file or directory to every cached EC2 instance whose Name tag -matches <pattern>. The transfer uses SSM RunCommand, so targets need a running -SSM agent and shell tools: tar, base64, mkdir, and find. +Exactly one operand must be remote. The host is resolved from the EC2 cache and +must match exactly one running instance. Directory copies are recursive. Examples: - ssmcp 'web-*' ./site /opt/site - ssmcp --delete 'app-*' ./config /etc/myapp - ssmcp -p prod --timeout 600 'batch-*' ./scripts /tmp/scripts + ssmcp -p prod web-1:~/save ./foo + ssmcp ./foo web-1:/tmp/foo + ssmcp --delete app.example.com:/opt/app ./app Options: -p, --profile NAME AWS profile name -t, --timeout SECS Per-command timeout in seconds (default: 300) --poll SECS Poll interval in seconds (default: 2) - --cache PATH EC2 cache file (default from pssm) + --cache PATH EC2 cache file (default: ~/.aws-ec2-cache.json) --chunk-size N Base64 chars per SSM command chunk (default: 6000) - --delete Delete remote destination contents before extract - --dry-run Show target command without sending payload - -v, --verbose Verbose pssm output - --no-color Disable ANSI colors - --literal-controls Show remote control bytes as visible \\xNN text + --delete Delete destination contents before directory extract + --dry-run Resolve and show direction without copying + -v, --verbose Verbose progress -h, --help Show this help ")) @@ -282,15 +567,12 @@ Options: (when (cfg-ref config "help") (print-ssmcp-usage) (exit 0)) - (unless (cfg-ref config "pattern") - (print-ssmcp-usage) - (ssmcp-error "missing pattern")) (unless (cfg-ref config "source") (print-ssmcp-usage) - (ssmcp-error "missing local source")) + (ssmcp-error "missing source operand")) (unless (cfg-ref config "destination") (print-ssmcp-usage) - (ssmcp-error "missing remote destination directory")) + (ssmcp-error "missing destination operand")) (when (pair? (cfg-ref config "extra-positional")) (ssmcp-error "too many positional arguments"))) @@ -298,23 +580,27 @@ Options: (let ([config (parse-ssmcp-args args)]) (validate-ssmcp-config config) (let* ([source (cfg-ref/str config "source")] - [dest (cfg-ref/str config "destination")] - [remote-tmp (remote-payload-path)] - [chunk-size (cfg-ref config "chunk-size")]) - (if (cfg-ref config "dry-run") - (begin - (display (format "ssmcp dry-run: would upload ~a to ~a\n" source dest)) - (run-pssm-dry-run config (remote-final-command remote-tmp dest (cfg-ref config "delete")))) - (let* ([payload (make-local-payload source)] - [chunks (split-string payload chunk-size)]) - (display (format "ssmcp: uploading ~a chunk~a to ~a\n" - (length chunks) - (if (= (length chunks) 1) "" "s") - (cfg-ref/str config "pattern"))) - (run-pssm config (remote-init-command remote-tmp)) - (for-each - (lambda (chunk) - (run-pssm config (remote-append-command remote-tmp chunk))) - chunks) - (run-pssm config (remote-final-command remote-tmp dest (cfg-ref config "delete"))) - (display "ssmcp: done\n")))))) + [destination (cfg-ref/str config "destination")] + [source-remote (parse-remote-spec source)] + [dest-remote (parse-remote-spec destination)]) + (cond + [(and source-remote dest-remote) + (ssmcp-error "remote-to-remote copy is not supported: ~a ~a" source destination)] + [(not (or source-remote dest-remote)) + (ssmcp-error "one operand must be remote, in host:path form")] + [source-remote + (let* ([host (car source-remote)] + [remote-path (cdr source-remote)] + [inst (resolve-single-host host (cfg-ref/str config "cache-file"))]) + (if (cfg-ref config "dry-run") + (display (format "ssmcp dry-run: download ~a:~a -> ~a\n" + (ht-ref/str inst "name") remote-path destination)) + (download-file inst config remote-path destination)))] + [else + (let* ([host (car dest-remote)] + [remote-path (cdr dest-remote)] + [inst (resolve-single-host host (cfg-ref/str config "cache-file"))]) + (if (cfg-ref config "dry-run") + (display (format "ssmcp dry-run: upload ~a -> ~a:~a\n" + source (ht-ref/str inst "name") remote-path)) + (upload-file inst config source remote-path)))])))) --- a/test/test-all.ss +++ b/test/test-all.ss @@ -300,13 +300,11 @@ (hashtable-ref config "literal-controls" #f) #t)) (let ([config (parse-ssmcp-args - '("--delete" "--chunk-size" "4096" "web-*" "./site" "/opt/site"))]) - (check "SSMCP parses pattern" - (hashtable-ref config "pattern" #f) "web-*") - (check "SSMCP parses source" - (hashtable-ref config "source" #f) "./site") - (check "SSMCP parses destination" - (hashtable-ref config "destination" #f) "/opt/site") + '("--delete" "--chunk-size" "4096" "web-1:~/save" "./foo"))]) + (check "SSMCP parses remote source" + (hashtable-ref config "source" #f) "web-1:~/save") + (check "SSMCP parses local destination" + (hashtable-ref config "destination" #f) "./foo") (check "SSMCP parses --delete" (hashtable-ref config "delete" #f) #t) (check "SSMCP parses --chunk-size"