diff --git a/README.md b/README.md index 592580b..1dcbcfd 100644 --- a/README.md +++ b/README.md @@ -349,6 +349,34 @@ Caveats: - Only works with `:results output` - Can break with fancy prompts. (You're not putting fancy prompts on your servers anyway, right??) +## Scheme targets + +Modern devops workflows involve CLI tools as well as filesystem +targets. + +`devops.el` allows you to define TRAMP-like `/SCHEME:ARG:` targets: + +```org +#+TARGET: prod /aws:prod: + +* Who am I :prod: + +#+begin_src sh +aws sts get-caller-identity | jq -r .Arn +#+end_src +``` + +The package sets up the environment defined in to `devops-target-schemes`: + +```elisp +(setq devops-target-schemes + '(("aws" :env (("AWS_PROFILE" . "%s")) :allow ("dev" "prod")) + ("kube" :env (("KUBECONFIG" . "~/.kube/%s.yaml"))) + ("gh" :env (("GH_CONFIG_DIR" . "~/.config/gh-%s"))))) +``` + +See [examples/3_schemes.org](examples/3_schemes.org). + ## Terminal DWIM command I often like using a separate terminal to run most commands, instead of emacs. diff --git a/devops-test.el b/devops-test.el index 0ff2832..bf409cc 100644 --- a/devops-test.el +++ b/devops-test.el @@ -2190,4 +2190,216 @@ tools.org; with it off, nothing loads." (devops-lob-mode -1) (when (buffer-live-p buf) (kill-buffer buf)))))) +;;; Scheme targets (/SCHEME:ARG:, a credential as a #+TARGET) + +(defconst devops-test--schemes + '(("aws" :env (("AWS_PROFILE" . "%s")) :allow ("dev" "prod")) + ("kube" :env (("KUBECONFIG" . "~/.kube/%s.yaml")) :dir "deploy")) + "A `devops-target-schemes' for the tests below.") + +(defmacro devops-test--with-scheme-org (root-var text &rest body) + "Run BODY in an org buffer with TEXT, inside a fresh project at ROOT-VAR. +`devops-target-schemes' is `devops-test--schemes', and TRAMP's file name +handler signals: no scheme name may reach it." + (declare (indent 2)) + `(devops-test--with-project ,root-var nil + (let ((devops-target-schemes devops-test--schemes)) + (cl-letf (((symbol-function 'tramp-file-name-handler) + (lambda (&rest args) + (error "TRAMP saw %S" args)))) + (devops-test--with-org ,text + (goto-char (point-min)) + ,@body))))) + +(ert-deftest devops-target-scheme-test () + "A /SCHEME:ARG: target parses; an unknown scheme is left to TRAMP." + (let ((devops-target-schemes devops-test--schemes)) + (should (equal (devops-target-scheme "/aws:prod:") + (list "aws" "prod" (cdr (assoc "aws" devops-test--schemes))))) + (should (equal (car (devops-target-scheme "/kube:any-name_1:")) "kube")) + (should-not (devops-target-scheme "/ssh:host:")) + (should-not (devops-target-scheme "/srv/app/")) + (should-not (devops-target-scheme "..")))) + +(ert-deftest devops-target-scheme-allow-test () + "An ARG not in :allow is an error naming the tag." + (let ((devops-target-schemes devops-test--schemes)) + (should (equal (cadr (should-error (devops-target-scheme "/aws:prod-admin:" "prod") + :type 'user-error)) + "Target prod: aws:prod-admin is not in :allow")))) + +(ert-deftest devops-target-scheme-name-syntax-test () + "An ARG that is not a name, or a path after the name, is an error." + (let ((devops-target-schemes devops-test--schemes)) + (dolist (target '("/aws:../x:" "/aws::" "/aws:prod:/x" "/aws:.prod:" + "/kube:a/b:" "/kube:a.b:")) + (should-error (devops-target-scheme target) :type 'user-error)))) + +(ert-deftest devops-target-env-test () + "The env puts ARG in each FORMAT and expands a leading ~/." + (let ((devops-target-schemes devops-test--schemes)) + (should (equal (devops-target-env "/aws:prod:") '(("AWS_PROFILE" . "prod")))) + (should (equal (devops-target-env "/kube:stage:") + `(("KUBECONFIG" . ,(expand-file-name "~/.kube/stage.yaml"))))) + (should-not (devops-target-env "/srv/app/")))) + +(ert-deftest devops-target-scheme-dir-test () + "A scheme target runs at the project root, or its :dir under it." + (devops-test--with-scheme-org root "" + (make-directory (expand-file-name "sub/dir" root) t) + (let ((default-directory (expand-file-name "sub/dir/" root))) + (should (equal (devops--target-dir "/aws:prod:") root)) + (should (equal (devops--target-dir "/kube:x:") (concat root "deploy/")))))) + +(ert-deftest devops-target-scheme-dir-no-project-test () + "Outside a project, a scheme target runs in the org file's directory." + (let ((devops-target-schemes devops-test--schemes) + (default-directory (file-name-as-directory + (make-temp-file "devops-noproj-" t)))) + (unwind-protect + (cl-letf (((symbol-function 'project-current) #'ignore)) + (should (equal (devops--target-dir "/aws:prod:") default-directory))) + (delete-directory default-directory t)))) + +(ert-deftest devops-session-name-scheme-test () + "A scheme target's pool is named by the target and the dir it runs in." + (devops-test--with-scheme-org root "" + (should (equal (devops--session-name "/aws:prod:") + (concat "devops:/aws:prod:" root))) + (should (equal (devops--session-name "/aws:dev:" 2) + (concat "devops:/aws:dev:" root "<2>"))))) + +(ert-deftest devops-execute-scheme-target-test () + "A block under a scheme target runs at the project root with its env." + (devops-test--with-scheme-org root + (concat "#+TARGET: prod /aws:prod:\n" + "#+TARGET: dev /aws:dev:\n\n" + "* Prod\t\t:prod:\n\n" + "#+begin_src sh :results output\npwd; echo $AWS_PROFILE\n#+end_src\n\n" + "* Dev\t\t:dev:\n\n" + "#+begin_src sh :results output\necho $AWS_PROFILE\n#+end_src\n") + (let ((org-confirm-babel-evaluate nil)) + (re-search-forward "begin_src") + (pcase-let ((`(,pwd ,profile) + (split-string (org-babel-execute-src-block) "\n" t))) + (should (equal (file-name-as-directory (file-truename pwd)) + (file-truename root))) + (should (equal profile "prod"))) + (re-search-forward "begin_src") + (should (equal (org-trim (org-babel-execute-src-block)) "dev")) + (should-not (member "AWS_PROFILE=prod" process-environment))))) + +(ert-deftest devops-execute-scheme-target-params-test () + "The injected :dir is the project root, and the session is the scheme's pool." + (let ((devops-enable-session-async t)) + (devops-test--with-scheme-org root + (concat "#+TARGET: prod /aws:prod:\n\n" + "* Prod\t\t:prod:\n\n" + "#+begin_src sh\npwd\n#+end_src\n") + (re-search-forward "begin_src") + (let ((params (devops-test--executor-params "sh"))) + (should (equal (cdr (assq :dir params)) root)) + (should (equal (cdr (assq :session params)) + (concat "devops:/aws:prod:" root))))))) + +(ert-deftest devops-execute-scheme-target-not-allowed-test () + "A target not in :allow fails before anything runs." + (devops-test--with-scheme-org root + (concat "#+TARGET: prod /aws:prod-admin:\n\n" + "* Prod\t\t:prod:\n\n" + "#+begin_src sh\npwd\n#+end_src\n") + (re-search-forward "begin_src") + (cl-letf (((symbol-function 'org-babel-execute:sh) + (lambda (&rest _) (error "Should not run")))) + (let ((org-confirm-babel-evaluate nil)) + (should-error (org-babel-execute-src-block) :type 'user-error))))) + +(ert-deftest devops-execute-scheme-target-from-ref-test () + "A dynamic target that yields a scheme target runs the same way, and is checked." + (devops-test--with-scheme-org root + (concat "#+TARGET: prod <>\n\n" + "* Locate\n\n" + "#+name: where\n" + "#+begin_src emacs-lisp\nwhere\n#+end_src\n\n" + "* Prod\t\t:prod:\n\n" + "#+begin_src sh :results output\necho $AWS_PROFILE\n#+end_src\n") + (let ((org-confirm-babel-evaluate nil)) + (goto-char (point-max)) + (re-search-backward "begin_src") + (let ((where "/aws:prod:")) + (cl-letf (((symbol-function 'org-babel-execute:emacs-lisp) + (lambda (&rest _) where))) + (should (equal (org-trim (org-babel-execute-src-block)) "prod")) + (setq where "/aws:prod-admin:") + (should-error (org-babel-execute-src-block) :type 'user-error)))))) + +(ert-deftest devops-execute-scheme-target-async-session-test () + "An async session started for a scheme target carries its env." + (let ((devops-enable-session-async t)) + (devops-test--with-scheme-org root + (concat "#+TARGET: prod /aws:prod:\n\n" + "* Prod\t\t:prod:\n\n" + "#+begin_src sh :results output\necho $AWS_PROFILE\n#+end_src\n") + (let ((session (devops--session-name "/aws:prod:"))) + (unwind-protect + (let* ((org-confirm-babel-evaluate nil) + (uuid (progn (re-search-forward "begin_src") + (org-babel-execute-src-block))) + (deadline (+ (float-time) 30))) + (should (get-buffer session)) + (while (and (< (float-time) deadline) + (save-excursion + (goto-char (point-min)) + (search-forward uuid nil t))) + (accept-process-output nil 0.2)) + (goto-char (point-min)) + (should (re-search-forward "^: prod$" nil t))) + (when-let* ((buf (get-buffer session))) + (let ((kill-buffer-query-functions nil)) + (kill-buffer buf)))))))) + +(ert-deftest devops-tangle-scheme-target-refuses-test () + "Tangling, visiting or drift-checking a scheme target's heading is an error." + (devops-test--with-scheme-org root + (concat "#+TARGET: prod /aws:prod:\n\n" + "* Prod\t\t:prod:\n\n" + "#+begin_src conf :tangle x.conf\nx\n#+end_src\n") + (re-search-forward "begin_src") + (should (equal (cadr (should-error (devops-tangle) :type 'user-error)) + "Target prod: /aws:prod: is not a filesystem target")) + (should-error (devops-visit-file) :type 'user-error) + (should-error (devops-drift--check (current-buffer)) :type 'user-error))) + +(ert-deftest devops-tangle-all-skips-scheme-target-test () + "Tangling every heading skips a scheme target and tangles the rest." + (devops-test--with-local-target target + (devops-test--with-scheme-org root + (format (concat "#+TARGET: prod /aws:prod:\n" + "#+TARGET: local %s\n\n" + "* Prod\t\t:prod:\n\n" + "#+begin_src conf :tangle x.conf\nx\n#+end_src\n\n" + "* Local\t\t:local:\n\n" + "#+begin_src conf :tangle y.conf\ny\n#+end_src\n") + target) + (should (equal (devops-tangle-all (current-buffer)) + (list (list "local" target 1)))) + (should (file-exists-p (concat target "y.conf")))))) + +(ert-deftest devops-open-terminal-dwim-scheme-target-test () + "The terminal is local, at the project root, with the scheme's env first." + (devops-test--with-scheme-org root + (concat "#+TARGET: prod /aws:prod:\n\n" + "* Prod\t\t:prod:\n\n" + "#+begin_src sh :var name=\"val\"\naws s3 ls\n#+end_src\n") + (re-search-forward "begin_src") + (let (called kill-ring) + (cl-letf (((symbol-function 'start-process) + (lambda (_name _buf &rest cmd) (setq called cmd)))) + (devops-open-terminal-dwim)) + (should (equal called + (list "ghostty" (concat "--working-directory=" root) + "-e" "bash" "-c" + "export AWS_PROFILE=prod && export name=val && exec $SHELL"))) + (should (equal (car kill-ring) "aws s3 ls"))))) + (provide 'devops-test) diff --git a/devops.el b/devops.el index bbb3016..bb447b4 100644 --- a/devops.el +++ b/devops.el @@ -67,6 +67,7 @@ ;; Options: ;; ;; - `devops-enable-session-async' run blocks in async session pool +;; - `devops-target-schemes' credentials a #+TARGET may name ;; ;; Please report issues at ;; https://github.com/kpassapk/devops.el/issues @@ -78,6 +79,7 @@ (require 'org) (require 'org-element) ; org-element-property / org-element-at-point (require 'ob-tangle) ; advised below: org-babel-tangle-collect-blocks +(require 'project) ; a scheme target runs at the project root (require 'tramp) ; tramp-tramp-file-p / tramp-dissect-file-name etc. are used ; below; autoloaded interactively but not under `emacs -Q'. @@ -121,6 +123,28 @@ The sessions share nothing: a block must not rely on a `cd' or an :type '(repeat string) :group 'devops) +(defcustom devops-target-schemes nil + "Schemes a #+TARGET may name as /SCHEME:ARG:, for non-filesystem targets. +A scheme target is a credential, not a place: blocks under its tag run +locally with the scheme's environment. It is set here, in Emacs, never +in the org file. Each entry is (SCHEME . PLIST): + + :env alist of (NAME . FORMAT); FORMAT's %s is ARG, and a value + starting with ~/ is expanded. Set in the environment of the + block's process, so of a session when it starts. + :allow list of the ARGs an org file may name, or nil for any ARG + matching the name syntax. + :dir directory the blocks run in, relative to the project root, + or nil for the project root itself. Outside a project the + root is the org file's directory. + +For example: + + ((\"aws\" :env ((\"AWS_PROFILE\" . \"%s\")) :allow (\"dev\" \"prod\")) + (\"kube\" :env ((\"KUBECONFIG\" . \"~/.kube/%s.yaml\"))))" + :type '(alist :key-type string :value-type plist) + :group 'devops) + (defvar devops--inhibit-async nil "When non-nil, do not inject `:session' or `:async'. Bound by `devops-with-sync' around tangling and drift checks.") @@ -196,11 +220,68 @@ value." (user-error "Target %s: %s must yield one line, got %S" tag ref value)) value)) +(defun devops-target-scheme (target &optional tag) + "Return (SCHEME ARG PLIST) when TARGET is /SCHEME:ARG:, or nil. +SCHEME is a key of `devops-target-schemes' and PLIST its entry. A +TARGET whose scheme is not in the map is nil, left to TRAMP. Signal a +`user-error' when ARG is not a name, `[[:alnum:]][[:alnum:]_-]*', or not +in the scheme's :allow. TAG, when given, names the target in the error. + +Only string matching: a scheme name must never reach a file name +function, whose TRAMP handler would take SCHEME for a method." + (when (string-match "\\`/\\([^/:|]+\\):" target) + (when-let* ((entry (assoc (match-string 1 target) devops-target-schemes))) + (let ((label (if tag (format "Target %s: " tag) "")) + (scheme (car entry)) + (plist (cdr entry))) + (unless (string-match + "\\`/[^/:|]+:\\([[:alnum:]][[:alnum:]_-]*\\):\\'" target) + (user-error "%s%s is not /%s:NAME:" label target scheme)) + (let ((arg (match-string 1 target)) + (allow (plist-get plist :allow))) + (when (and allow (not (member arg allow))) + (user-error "%s%s:%s is not in :allow" label scheme arg)) + (list scheme arg plist)))))) + +(defun devops-target-env (target) + "Return the (NAME . VALUE) environment of TARGET, a scheme target, or nil. +The scheme's :env with ARG put in and a leading ~/ expanded." + (pcase (devops-target-scheme target) + (`(,_ ,arg ,plist) + (mapcar (lambda (pair) + (let ((value (format (cdr pair) arg))) + (cons (car pair) + (if (string-prefix-p "~/" value) + (expand-file-name value) + value)))) + (plist-get plist :env))))) + +(defun devops--scheme-dir (plist) + "Return the directory to run a scheme target with PLIST in. +The scheme's :dir, relative to the current project's root, or that root. +Outside a project the root is `default-directory'." + (let ((root (if-let* ((project (project-current nil))) + (project-root project) + default-directory))) + (file-name-as-directory + (expand-file-name (or (plist-get plist :dir) ".") root)))) + +(defun devops--target-dir (target) + "Return the :dir to run a block on TARGET in. +TARGET itself, or for a scheme target its local directory." + (if-let* ((scheme (devops-target-scheme target))) + (devops--scheme-dir (nth 2 scheme)) + target)) + (defun devops--resolve-target-for-tag (tag) - "Look up TAG in #+TARGET keywords, return target name or nil." + "Look up TAG in #+TARGET keywords, return target name or nil. +A scheme target, written or resolved from a reference, is checked by +`devops-target-scheme'." (when-let* ((target (cdr (assoc tag (devops-target-tag-alist))))) - (if-let* ((ref (devops-target-ref target))) - (devops--target-from-ref tag ref) + (let ((target (if-let* ((ref (devops-target-ref target))) + (devops--target-from-ref tag ref) + target))) + (devops-target-scheme target tag) target))) (defun devops-heading-targets () @@ -236,7 +317,8 @@ If more than one of the heading's tags names a target, use "Return :dir from the current heading's tags and #+TARGET mappings. If there is more than one target, use `completing-read', allowing the user to select one." - (cdr (devops--heading-target))) + (when-let* ((pair (devops--heading-target))) + (devops--target-dir (cdr pair)))) (defun devops-set-header-args-from-tags () "Set :header-args: :dir from the current heading's tag and #+TARGET mappings." @@ -280,11 +362,16 @@ for any value other than those in `devops--target-none-values'." (defun devops--session-name (target &optional slot) "Return the name of session SLOT in the pool for TARGET. SLOT counts from 1, the default. Slot 1 is \"devops:TARGET\" and slot N -adds \"\", the way Emacs names a second buffer." +adds \"\", the way Emacs names a second buffer. A scheme target is +followed by the directory it runs in, \"devops:/aws:prod:/src/app/\": a +session keeps the directory it started in." (format "devops:%s%s" - (if (tramp-tramp-file-p target) - target - (file-name-as-directory (expand-file-name target))) + (cond ((devops-target-scheme target) + (concat target (devops--target-dir target))) + ((tramp-tramp-file-p target) + target) + (t + (file-name-as-directory (expand-file-name target)))) (if (and slot (> slot 1)) (format "<%d>" slot) ""))) (defun devops--async-marker (kind id) @@ -424,7 +511,8 @@ ARGS is its whole argument list. Nil when the block opted out with "Return ARGS, for `org-babel-execute-src-block', set to run on PAIR. PAIR is the (TAG . TARGET) from `devops--execution-target', or nil to leave ARGS alone. Only PARAMS, the third argument, is rewritten: :dir -is the target, and :session and :async are injected as decision 2 says." +is the target, or a scheme target's local directory, and :session and +:async are injected as decision 2 says." (if (not pair) args (let* ((info (nth 1 args)) @@ -435,7 +523,7 @@ is the target, and :session and :async are injected as decision 2 says." ;; Ours first: within one alist `org-babel-merge-params' ;; lets a later pair overwrite an earlier one, so an ;; explicit PARAMS from the caller still wins. - (append (cons (cons :dir (cdr pair)) + (append (cons (cons :dir (devops--target-dir (cdr pair))) (devops--async-session-cells params block-params (nth 0 block-info) (cdr pair))) @@ -451,8 +539,15 @@ is the target, and :session and :async are injected as decision 2 says." "Advise `org-babel-execute-src-block' (FN with ARGS) to run on the target. The heading's #+TARGET is resolved once, injected by `devops--inject-header-args', and bound to `devops--block-target' for -the run, with the session it gets bound to `devops-block-session'." +the run, with the session it gets bound to `devops-block-session'. A +scheme target's environment is put in front of `process-environment', +so a session started by the run inherits it." (let* ((devops--block-target (devops--execution-target args)) + (process-environment + (append (mapcar (lambda (pair) (concat (car pair) "=" (cdr pair))) + (and devops--block-target + (devops-target-env (cdr devops--block-target)))) + process-environment)) (args (devops--inject-header-args args devops--block-target)) (devops-block-session (when-let* ((pair devops--block-target)) @@ -682,7 +777,9 @@ Return the number of files tangled, or nil." "Return a tangle plan for the current buffer. Each entry is a plist (:tag TAG :target TARGET :heading-pos POS). -With prefix ARG non-nil, include all target-tagged headings." +With prefix ARG non-nil, include all target-tagged headings, skipping +scheme targets with a message. Without, a scheme target on the heading +is an error: it is not a filesystem." (if arg (let (specs) (org-map-entries @@ -693,14 +790,21 @@ With prefix ARG non-nil, include all target-tagged headings." (seq-remove (lambda (pair) (member (car pair) parent-tags)) (devops-heading-targets)))) - (push (list :tag (car pair) - :target (cdr pair) - :heading-pos (point)) - specs)))) + (if (devops-target-scheme (cdr pair)) + (message "Skipping target %s: %s is not a filesystem target" + (car pair) (cdr pair)) + (push (list :tag (car pair) + :target (cdr pair) + :heading-pos (point)) + specs))))) (nreverse specs)) (let ((pairs (devops-heading-targets))) (unless pairs (user-error "No #+TARGET match for tags on current heading")) + (dolist (pair pairs) + (when (devops-target-scheme (cdr pair)) + (user-error "Target %s: %s is not a filesystem target" + (car pair) (cdr pair)))) (let ((pos (save-excursion (org-back-to-heading t) (point)))) (mapcar (lambda (pair) (list :tag (car pair) @@ -898,10 +1002,14 @@ is an alist of (NAME . VALUE) exported in that shell." ;;;###autoload (defun devops-open-terminal-dwim () "Open terminal at contextual directory. -In a src block, if the : copies body to clipboard and exports :var env vars." +In a src block, if the : copies body to clipboard and exports :var env vars. +On a scheme target the terminal is local, in the target's directory, and +exports the scheme's environment before the block's vars." (interactive) - (let* ((dir (devops--heading-target-dir)) - (env-vars (devops-src-block-env-vars)) + (let* ((target (cdr (devops--heading-target))) + (dir (and target (devops--target-dir target))) + (env-vars (append (and target (devops-target-env target)) + (devops-src-block-env-vars))) (lang (org-element-property :language (org-element-at-point)))) (when (or (string= lang "shell") (string= lang "sh")) (kill-new (devops--src-block-body)) diff --git a/examples/3_schemes.org b/examples/3_schemes.org new file mode 100644 index 0000000..5c5d98c --- /dev/null +++ b/examples/3_schemes.org @@ -0,0 +1,59 @@ +#+PROPERTY: header-args:sh :results output +#+TARGET: prod /aws:prod: +#+TARGET: dev /aws:dev: +#+TITLE: Example 3: Scheme targets + +A target can be a credential instead of a place. =/aws:prod:= is not a +TRAMP name: =aws= is a scheme you define in your Emacs, and a block +under =:prod:= runs locally, at the project root, with the scheme's +environment set. + +#+begin_src emacs-lisp :eval no +(setq devops-target-schemes + '(("aws" :env (("AWS_PROFILE" . "%s")) :allow ("dev" "prod")) + ("kube" :env (("KUBECONFIG" . "~/.kube/%s.yaml"))))) +#+end_src + +The org file only names the scheme and the profile. What =aws= means, +and which profiles a file may name, is up to the operator: a target +=/aws:prod-admin:= fails before anything runs, because it is not in +=:allow=. + +* Who am I in prod? :prod: + +The body is left alone: pipes, loops and several CLIs work as usual. + +#+begin_src sh +env | grep AWS_ +pwd +aws sts get-caller-identity | jq -r .Arn +#+end_src + +* Who am I in dev? :dev: + +With =devops-enable-session-async=, each target gets its own pool, +=devops:/aws:dev:/path/to/project/=, and its sessions keep the +environment they started with. After changing =devops-target-schemes=, +run =devops-restart-session=. + +#+begin_src sh +env | grep AWS_ +#+end_src + +* A terminal with the same credential :prod: + +=devops-open-terminal-dwim= here opens a local terminal at the project +root with =AWS_PROFILE=prod= exported, then the block's =:var=s. + +#+begin_src sh :var bucket="my-bucket" +aws s3 ls "s3://$bucket" +#+end_src + +* Tangling is refused :prod: + +A scheme target is not a filesystem: =devops-tangle= here is an error, +and =devops-tangle-all= skips it. + +#+begin_src conf :tangle config.conf +key = value +#+end_src diff --git a/work/20261019T150900--scheme-targets__design.org b/work/20261019T150900--scheme-targets__design.org new file mode 100644 index 0000000..6b55eea --- /dev/null +++ b/work/20261019T150900--scheme-targets__design.org @@ -0,0 +1,271 @@ +#+TITLE: Scheme targets: a CLI's credential as a #+TARGET +#+PROPERTY: header-args:sh :results output +#+TARGET: local .. + +* Background + +A =#+TARGET= is a place: a directory or a TRAMP prefix, injected as +=:dir=. But much of what an operator reaches with credentials is not a +filesystem: an AWS service, a kube deployment, a =gh= account, a +=PGSERVICE=. Today a block picks those itself (=aws --profile prod …=), +so nothing about the heading says which credential it uses, and +nothing stops a block from naming another one. + +TRAMP already covers containers and pods as filesystems +(=/ssh:host|podman:box:=, =/kubernetes:pod:=), so those stay as they +are. This is for the rest. + +The driver is devops.bb's sandbox work: Claude, in a sandbox, writes +the workbook, and the operator runs it. There, a target is a +credential, the prompt shows which one a block will use, and the +operator, not the workbook, decides which ones a workbook may name. + +* Spec + +** Goal + +- =#+TARGET: prod /aws:prod:= makes the blocks under =:prod:= run with + the credential context =aws:prod=, chosen through the environment. +- The body runs unchanged: several commands, pipes, loops, =aws= and + =jq= and =kubectl= mixed. +- What a scheme means is set in the operator's Emacs, never in the + workbook, with an allowlist per scheme. +- =devops-open-terminal-dwim= opens a terminal with the same + environment. + +** Why environment, not a prefix + +Interpolating a prefix (=aws --profile prod <>=) works for a +one-command body only. With a pipe it applies to the first word, +quoting breaks, and the text that runs is neither what was written nor +what the operator read. The CLIs already pick credentials from the +environment: =AWS_PROFILE=, =KUBECONFIG=, =GH_CONFIG_DIR=, +=PGSERVICE=, =CLOUDSDK_ACTIVE_CONFIG_NAME=. A scheme target sets those +for the session, and the body is left alone. + +** Syntax + +=/SCHEME:ARG:=, a TRAMP-like name that is not TRAMP: SCHEME is a key of +=devops-target-schemes=, ARG a name, =[[:alnum:]][[:alnum:]_-]*= (no +=.=, no =/=, so =%s= in a path cannot climb out). It is recognized +before any TRAMP call: a scheme name must never reach +=expand-file-name=, =file-remote-p= or =tramp-dissect-file-name=, whose +file name handlers would try =aws= as a TRAMP method (step 1 checks +what each does with one). A name whose SCHEME is not in the map is +left to TRAMP as today. + +** The map + +#+begin_src emacs-lisp :eval no +(defcustom devops-target-schemes nil + "Schemes a #+TARGET may name as /SCHEME:ARG:, for non-filesystem targets. +Each entry is (SCHEME . PLIST): + + :env alist of (NAME . FORMAT); FORMAT's %s is ARG, and a value + starting with ~/ is expanded. Set in the session's + environment when it starts. + :unset list of NAMEs removed from the environment Emacs passes on, + the variables that would take precedence over :env. + :allow list of the ARGs a workbook may name, or nil for any ARG + matching the name syntax. + :dir directory the blocks run in, relative to the project root, + or nil for the project root itself. Outside a project the + root is the org file's directory." + :type '(alist :key-type string :value-type plist) + :group 'devops) + +(setq devops-target-schemes + '(("aws" :env (("AWS_PROFILE" . "%s")) + :unset ("AWS_ACCESS_KEY_ID" "AWS_SECRET_ACCESS_KEY" + "AWS_SESSION_TOKEN" "AWS_DEFAULT_PROFILE") + :allow ("dev" "staging" "prod")) + ("kube" :env (("KUBECONFIG" . "~/.kube/%s.yaml"))) + ("gh" :env (("GH_CONFIG_DIR" . "~/.config/gh-%s")) + :unset ("GH_TOKEN" "GITHUB_TOKEN" "GH_ENTERPRISE_TOKEN" + "GITHUB_ENTERPRISE_TOKEN" "GH_HOST")) + ("pg" :env (("PGSERVICE" . "%s")) + :unset ("PGHOST" "PGHOSTADDR" "PGPORT" "PGUSER" + "PGPASSWORD" "PGDATABASE" "PGPASSFILE")))) +#+end_src + +=:allow= is what makes this safe when someone else writes the workbook: +=/aws:prod-admin:= fails with =user-error "Target prod: aws:prod-admin +is not in :allow"= before anything runs. + +*=:unset= keeps the label true.* The scheme's =:env= goes in front of +the environment Emacs inherited, and most CLIs let *other* variables +win over the one a scheme sets: AWS's env keys over =AWS_PROFILE=, +=GH_TOKEN= over =GH_CONFIG_DIR=, =PGHOST= / =PGUSER= over +=PGSERVICE=. Emacs picks these up easily (=envrc=, +=exec-path-from-shell=, a shell with an =aws sso= export). Without +=:unset= the block runs as whatever identity those hold while the +target, and devops.bb's prompt, say =AWS_PROFILE=prod=. Explicit per +scheme, not guessed from a prefix: the precedence rules are each CLI's +own. The example above is the README's and the docstring's; it is the +part worth copying. + +** Resolution + +- =devops--parse-target-keyword= is unchanged: =/aws:prod:= is already + one word. +- A new =devops-target-scheme TARGET= returns =(SCHEME ARG PLIST)= for a + scheme target, or nil. It checks the name syntax and =:allow=. +- Dynamic targets compose: =#+TARGET: prod <>= may yield + =/aws:prod:=, and is checked the same way after it resolves. +- =devops-target-env TARGET= returns the expanded =(NAME . VALUE)= + alist, public, for a package that shows it, with each =:unset= NAME + as =(NAME . nil)= after the =:env= pairs (devops.bb's confirm prompt: + =target /aws:prod: · AWS_PROFILE=prod · unset AWS_ACCESS_KEY_ID …=). + A NAME in both =:env= and =:unset= is =:env='s. + +** Sessions + +- =devops--inject-header-args= injects =:session= and =:async= as + today, and =:dir= as the project root (=project-current=, else the + org file's directory), or the scheme's =:dir= relative to it. It + never injects the scheme name as =:dir=. +- =devops--session-name= names the pool by the target as written, + followed by that directory, =devops:/aws:prod:/src/app/=: a session + keeps the directory it started in, so two projects using + =/aws:prod:= must not share one. +- =devops--execute-on-target= binds =process-environment= with the + scheme's =:env= in front around the run, and each =:unset= NAME as a + bare ="NAME"= (no ===), which is how =process-environment= marks a + variable removed for the processes it starts. A session's shell inherits + it when babel starts the session, so it holds for every block that + session runs after: sh, bash, python, any session language. A live + session keeps the environment it started with; after changing + =devops-target-schemes=, kill its sessions. The README says so. +- A block can still =export AWS_PROFILE=other= inside the session, and + that carries over. The reviewer sees it in the block; the README says + so too. +- =:target nil= and a block's own =:dir= opt out as today. + +** Shell DWIM + +=devops-open-terminal-dwim= on a heading with a scheme target: + +- opens a *local* terminal (=devops--open-terminal-at-dir=), in the + same directory blocks run in, never through the + =file-remote-p= branch; +- =unset=s the scheme's =:unset= first, then exports its =:env=, then + the block's =:var= env vars, so a block's own variable wins as it + does today. The terminal's login shell may set them again from its + rc files; the README says so; +- copies a shell block's body to the kill ring, as today. + +=devops--ghostty-command= takes the merged alist; a =(NAME . nil)= +pair becomes =unset NAME=. + +** Tangle and drift + +A scheme target is not a filesystem. =devops-tangle=, =devops-drift= +and =devops-visit-file= on it fail with =user-error "Target prod: +/aws:prod: is not a filesystem target"=, and =devops-tangle-all= skips +it with a message. + +** Not in scope + +- Schemes over a remote host (=/ssh:bastion|aws:prod:=): the scheme's + environment is local. +- Credentials that are not environment (=aws sso login= state, a + keychain prompt): the CLI handles them as it does today. +- Checking *who* a credential is (=aws sts get-caller-identity=) at run + time. A package may add it; devops.el does not run it. + +** Done when + +- A block under =#+TARGET: prod /aws:prod:= prints =AWS_PROFILE=prod= + with =env | grep AWS_=, in an async sh session and in python; a + second target =/aws:dev:= runs in its own pool with its own value. +- =/aws:prod-admin:= with =:allow ("prod")= fails before a session + starts; =/aws:../x:= fails the name syntax. +- A dynamic target yielding =/aws:prod:= runs the same way. +- With =AWS_ACCESS_KEY_ID= set in Emacs's =process-environment= and in + the scheme's =:unset=, a block's =env= does not show it, in a new + session and in a sync run; =devops-target-env= lists it as =(NAME + . nil)=; DWIM's command has =unset AWS_ACCESS_KEY_ID= before the + =export=s. +- No TRAMP function sees a scheme name: a test with + =tramp-file-name-handler= advised to signal never trips. +- =devops-open-terminal-dwim= on the heading builds a local ghostty + command exporting =AWS_PROFILE=prod= then the block's vars + (=devops--ghostty-command= is the pure part to test). +- =devops-tangle= on the heading fails with the message above. +- =make compile= and =make test= pass on Emacs 30.1; an example in + =examples/=; README section. + +** Code map + +| Function | Change | +|-----------------------------------+-----------------------------------------------------------| +| =devops-target-schemes= | new defcustom | +| =devops-target-scheme= | new: parse, syntax, =:allow= | +| =devops-target-env= | new, public: the expanded env alist | +| =devops--resolve-target-for-tag= | check a resolved dynamic target through the scheme too | +| =devops--inject-header-args= | no =:dir= for a scheme target, unless the scheme's =:dir= | +| =devops--session-name= | scheme names as written | +| =devops--execute-on-target= | bind =process-environment= | +| =devops-open-terminal-dwim= | local dir, merged env | +| =devops--ghostty-command= | =(NAME . nil)= → =unset NAME= | +| =:unset= | docstring, =devops-target-env=, =process-environment=, README | +| tangle, drift, visit-file | not-a-filesystem error | +| =devops-test.el= | the cases in Done when | + +* Steps + +** DONE 1. What does Emacs do with a scheme name today? :local: + +Which of these call TRAMP, and which signal? Each must be avoided, or +be safe, for =/aws:prod:=. + +#+begin_src emacs-lisp :results output +(require 'tramp) +(dolist (f '(tramp-tramp-file-p file-remote-p expand-file-name + file-name-as-directory)) + (princ (format "%-24s %S\n" f + (condition-case err (funcall f "/aws:prod:") + (error (list 'error (error-message-string err))))))) +#+end_src + +#+RESULTS: +: tramp-tramp-file-p t +: file-remote-p "/aws:prod:" +: expand-file-name (error "Method ‘aws’ is not known") +: file-name-as-directory (error "Method ‘aws’ is not known") + +** DONE 2. Baseline: tests pass :local: + +#+begin_src sh +make compile 2>&1 | tail -n 5 +make test 2>&1 | tail -n 5 +#+end_src + +#+RESULTS: +: emacs --batch -L . --eval "(setq native-comp-enable-subr-trampolines nil)" \ +: --eval "(setq byte-compile-error-on-warn t)" \ +: -f batch-byte-compile devops.el devops-lob.el devops-drift.el devops-test.el +: passed 145/146 devops-user-run-p-test (0.000016 sec) +: passed 146/146 devops-with-sync-inhibits-async-test (0.000552 sec) +: +: Ran 146 tests, 146 results as expected, 0 unexpected (2026-10-19 15:22:38-0600, 2.471665 sec) + +** DONE 3. =devops-target-schemes=, =devops-target-scheme=, =devops-target-env= +** DONE 4. Sessions: naming, no =:dir=, =process-environment= +** DONE 5. Dynamic targets yielding a scheme +** DONE 6. Shell DWIM +** DONE 7. Tangle, drift, visit-file refuse +** DONE 8. Example, README +** TODO 9. =:unset=: environment, =devops-target-env=, DWIM, README +Does Emacs's own environment hold any of them right now? Anything +listed here is what a scheme block would inherit today. + +#+begin_src emacs-lisp :results output +(dolist (e process-environment) + (when (string-match-p "\\`\\(AWS_\\|GH_\\|GITHUB_\\|PG\\|KUBECONFIG\\)" e) + (princ (format "%s\n" (car (split-string e "=")))))) +#+end_src + +** TODO 10. devops.bb: show =devops-target-env= in the confirm prompt +In devops.bb's sandbox workbook; the resolved env is part of the +fingerprint.