--- /dev/null
+/*
+ Man_In_Grey_file_gate
+
+ The gate of the bespoke journal interpreter for the file command language.
+ Installed setuid root, it starts the journal interpreter with root's rights
+ while the real uid still names the caller. Unblessed, it starts the journal
+ interpreter with the caller's rights.
+
+ The journal interpreter is at a path fixed when the gate is compiled,
+ relative to the gate: ../Man_In_Grey/journal_interpreter_file.py. The caller
+ cannot name another. When running privileged, the gate refuses to start it
+ unless the interpreter, and every directory above it, is owned by root and
+ writable by no other account.
+
+ The journal arrives on standard input and is passed on unread. Every
+ argument is passed on to the journal interpreter, except --print-flags,
+ which reports the caller and whether the gate is running privileged.
+
+ Who is allowed to hand a journal to the interpreter is decided by the
+ interpreter's constraints, not here.
+*/
+
+#include <errno.h>
+#include <libgen.h>
+#include <limits.h>
+#include <pwd.h>
+#include <stdarg.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/prctl.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <unistd.h>
+
+#define PYTHON "/usr/bin/python3"
+#define INTERPRETER_RELATIVE "../Man_In_Grey/journal_interpreter_file.py"
+#define ARGUMENT_MAX 64
+
+Local void die(const char *format ,...){
+ va_list ap; va_start(ap ,format);
+ fputs("Man_In_Grey_file_gate: " ,stderr);
+ vfprintf(stderr ,format ,ap); va_end(ap);
+ fputc('\n' ,stderr);
+ _exit(2);
+}
+
+Local bool privileged(void){
+ return geteuid() != getuid() || getegid() != getgid();
+}
+
+/* path and every directory above it: owned by root, writable by no other account */
+Local void check_owned_by_root(const char *path){
+ char prefix[PATH_MAX];
+ size_t length = strlen(path);
+ if(length >= sizeof prefix) die("path too long: %s" ,path);
+ for(size_t i = 1; i <= length; ++i){
+ if( i < length && path[i] != '/' ) continue;
+ memcpy(prefix ,path ,i); prefix[i] = '\0';
+ struct stat st;
+ if( lstat(prefix ,&st) != 0 ) die("cannot examine %s: %s" ,prefix ,strerror(errno));
+ if( st.st_uid != 0 ) die("refusing to run privileged: %s is not owned by root" ,prefix);
+ if( st.st_mode & (S_IWGRP | S_IWOTH) ) die("refusing to run privileged: %s is writable by an account other than root" ,prefix);
+ }
+ struct stat st;
+ if( lstat("/" ,&st) != 0 || st.st_uid != 0 ) die("refusing to run privileged: / is not owned by root");
+}
+
+Local void print_flags(void){
+ struct passwd *pw = getpwuid(getuid());
+ printf("caller.uid=%ld\n" ,(long)getuid());
+ printf("caller.name=%s\n" ,pw ? pw->pw_name : "unknown");
+ printf("effective.uid=%ld\n" ,(long)geteuid());
+ printf("privileged=%d\n" ,privileged() ? 1 : 0);
+}
+
+int main(int argc ,char **argv){
+ if( argc > 1 && strcmp(argv[1] ,"--print-flags") == 0 ){ print_flags(); return 0; }
+ if( argc > ARGUMENT_MAX ) die("too many arguments");
+
+ char exe[PATH_MAX];
+ ssize_t n = readlink("/proc/self/exe" ,exe ,sizeof exe - 1);
+ if( n < 0 ) die("cannot resolve /proc/self/exe: %s" ,strerror(errno));
+ exe[n] = '\0';
+
+ char candidate[PATH_MAX];
+ if( snprintf(candidate ,sizeof candidate ,"%s/%s" ,dirname(exe) ,INTERPRETER_RELATIVE) >= (int)sizeof candidate )
+ die("path too long");
+ char interpreter[PATH_MAX];
+ if( !realpath(candidate ,interpreter) ) die("journal interpreter not found: %s" ,candidate);
+
+ if( privileged() ) check_owned_by_root(interpreter);
+
+ /* harden the process before starting the interpreter */
+ uid_t caller_uid = getuid();
+ gid_t caller_gid = getgid();
+ struct passwd *pw = getpwuid(caller_uid);
+ char caller_name[256];
+ snprintf(caller_name ,sizeof caller_name ,"%s" ,pw ? pw->pw_name : "unknown");
+
+ clearenv();
+ setenv("PATH" ,"/usr/sbin:/usr/bin:/sbin:/bin" ,1);
+ setenv("LANG" ,"C.UTF-8" ,1);
+ umask(077);
+ prctl(PR_SET_DUMPABLE ,0 ,0 ,0 ,0);
+ if( chdir("/") != 0 ) die("cannot change directory to /");
+
+ char number[32];
+ snprintf(number ,sizeof number ,"%ld" ,(long)caller_uid); setenv("MIG_CALLER_UID" ,number ,1);
+ snprintf(number ,sizeof number ,"%ld" ,(long)caller_gid); setenv("MIG_CALLER_GID" ,number ,1);
+ setenv("MIG_CALLER_NAME" ,caller_name ,1);
+
+ /* python3 -E -s -B <interpreter> <arguments> */
+ char *argument[ARGUMENT_MAX + 6];
+ int k = 0;
+ argument[k++] = (char *)PYTHON;
+ argument[k++] = (char *)"-E";
+ argument[k++] = (char *)"-s";
+ argument[k++] = (char *)"-B";
+ argument[k++] = interpreter;
+ for(int i = 1; i < argc; ++i) argument[k++] = argv[i];
+ argument[k] = NULL;
+
+ execv(PYTHON ,argument);
+ die("cannot start %s: %s" ,PYTHON ,strerror(errno));
+}
# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
"""
-FileLanguage - a small command language for writing files, Man-In-Grey's
+FileLanguage - a small command language for writing files, Man_In_Grey's
test language. It is a constrained form of the POSIX file interface.
open(directory ,name ,mode ,owner ,permission) produces a symbol for the file
# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
"""
-Journal - the journal of Man-In-Grey, independent of any command language.
+Journal - the journal of Man_In_Grey, independent of any command language.
A composer places commands into a journal. A command is data: the name of
the command, its arguments, and the composer that placed it. A command whose
"""
Man_In_Grey
-Runs the composers on a stage, which place commands into one journal, and
-hands the journal to a gate, which starts a bespoke journal interpreter. The
-journal interpreter checks every command against its constraints, and only
-then executes the journal.
-
-A composer is a Python file on the stage defining compose(prov ,journal_iface).
-prov describes where the composer is; journal_iface places commands of the
-file command language: open, write and unlink.
-
-Usage: Man_In_Grey --stage <dir> [option ...]
- help | -h | --help print this message (also printed when given no arguments)
- --stage <dir> the stage of composers
- --input_acceptance <file> a Python file defining accept(prov), true for each stage
- file that is a composer; by default every file ending in
- .py whose name does not begin with a dot
+Runs composers, which place commands into one journal, and hands the journal
+to a gate, which starts a bespoke journal interpreter. The journal interpreter
+checks every command against its constraints, and only then executes the
+journal.
+
+A composer is a Python file defining compose(prov ,journal_iface). prov
+describes where the composer is; journal_iface places commands of the file
+command language: open, write and unlink.
+
+Usage: Man_In_Grey <command> <path> [option ...]
+ help | -h | --help print this message (also printed when given no arguments)
+ compose <file> run the one composer in <file>
+ orchestrate <directory> run every composer in <directory>, breadth first, then
+ by name; the layout of the directory carries no meaning
+
+Options:
+ --input_acceptance <file> orchestrate only: a Python file defining accept(prov),
+ true for each file that is a composer; by default every
+ file ending in .py whose name does not begin with a dot
--gate <file> the gate to hand the journal to; by default
../Man_In_Grey_file_gate/Man_In_Grey_file_gate
--constraints <file> constraints for an unprivileged run, given to the
journal interpreter
- --phase-0-then-stop stop after checking the stage and the filter
+ --phase-0-then-stop stop after checking the path and the filter
--phase-1-print print the journal after composing
--phase-1-then-stop stop after composing; nothing reaches the gate
--phase-2-print print the journal as the journal interpreter decoded it
,"--phase-2-filesystem-then-stop": "filesystem"
}
FLAG = {"--phase-0-then-stop" ,"--phase-1-print" ,"--phase-1-then-stop" ,"--phase-2-print"} | set(STOP_OPTION)
-VALUE = {"--stage" ,"--input_acceptance" ,"--gate" ,"--constraints"}
+VALUE = {"--input_acceptance" ,"--gate" ,"--constraints"}
+COMMAND = ("compose" ,"orchestrate")
# --------------------------------------------------------------------------------
Where a composer is. Given to the acceptance filter and to compose().
"""
__slots__ = (
- "stage_root_dpath"
+ "composer_root_dpath"
,"composer_abs_fpath"
,"composer_rel_fpath"
,"composer_dir_dpath"
,"cwd_dpath"
)
- def __init__(self ,stage_root ,composer_path):
- self.stage_root_dpath = stage_root.resolve()
+ def __init__(self ,composer_root ,composer_path):
+ self.composer_root_dpath = composer_root.resolve()
self.composer_abs_fpath = composer_path.resolve()
- self.composer_rel_fpath = self.composer_abs_fpath.relative_to(self.stage_root_dpath)
+ self.composer_rel_fpath = self.composer_abs_fpath.relative_to(self.composer_root_dpath)
self.composer_dir_dpath = self.composer_abs_fpath.parent
name = self.composer_abs_fpath.name
- for suffix in (".stage.py" ,".py"):
- if name.endswith(suffix):
- name = name[:-len(suffix)]; break
+ if name.endswith(".py"):
+ name = name[:-3]
self.implied_fname = name
self.process_user = getpass.getuser()
self.cwd_dpath = Path.cwd().resolve()
raise RuntimeError(f"{filter_path} defines no accept(prov)")
return function
-def composer_list(stage_root ,accept):
- """The accepted stage files, breadth first, then by name. Symbolic links to directories are not followed."""
+def composer_list(composer_root ,accept):
+ """The accepted files of the composer directory, breadth first, then by name. Symbolic links to directories are not followed."""
found = []
- for dirpath ,dirnames ,filenames in os.walk(stage_root):
+ for dirpath ,dirnames ,filenames in os.walk(composer_root):
dirnames[:] = sorted(d for d in dirnames if not os.path.islink(os.path.join(dirpath ,d)))
for name in filenames:
path = Path(dirpath ,name)
if not stat.S_ISREG(path.stat().st_mode):
continue
- prov = Provenance(stage_root ,path)
+ prov = Provenance(composer_root ,path)
if accept(prov):
found.append((len(prov.composer_rel_fpath.parts) ,prov.composer_rel_fpath.as_posix() ,prov))
found.sort(key=lambda t: (t[0] ,t[1]))
return [t[2] for t in found]
-def compose_all(stage_root ,prov_list):
+def compose_all(composer_root ,prov_list):
"""Run each composer into one journal. Raises RuntimeError naming the composer that failed."""
journal = Journal.Journal(FileLanguage.NAME)
journal_iface = FileLanguage.JournalInterface(journal ,Path.cwd())
"composed_at": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ")
,"process_user": getpass.getuser()
,"host": os.uname().nodename
- ,"stage_root": str(stage_root.resolve())
+ ,"composer_root": str(composer_root.resolve())
,"composer_list": [p.composer_rel_fpath.as_posix() for p in prov_list]
}
return journal
-def run(stage ,input_acceptance ,gate ,constraints ,flag ,stop_after):
- stage_root = Path(stage)
- if not stage_root.is_dir():
- print(f"error: the stage {stage_root} is not a directory" ,file=sys.stderr)
- return 2
+def run(command ,path ,input_acceptance ,gate ,constraints ,flag ,stop_after):
+ path = Path(path)
+ if command == "compose":
+ if input_acceptance:
+ print("error: --input_acceptance is for orchestrate; compose runs the one file it is given" ,file=sys.stderr)
+ return 2
+ if not path.is_file():
+ print(f"error: the composer {path} is not a file" ,file=sys.stderr)
+ return 2
+ composer_root = path.resolve().parent
+ else:
+ if not path.is_dir():
+ print(f"error: {path} is not a directory" ,file=sys.stderr)
+ return 2
+ composer_root = path
try:
accept = accept_function(Path(input_acceptance) if input_acceptance else None)
except Exception as error:
print(f"error: acceptance filter: {error}" ,file=sys.stderr)
return 2
if "--phase-0-then-stop" in flag:
- print(f"phase 0: OK, stage {stage_root.resolve()}")
+ print(f"phase 0: OK, {command} {path.resolve()}")
return 0
try:
- prov_list = composer_list(stage_root ,accept)
- journal = compose_all(stage_root ,prov_list)
+ if command == "compose":
+ prov_list = [Provenance(composer_root ,path)]
+ else:
+ prov_list = composer_list(composer_root ,accept)
+ journal = compose_all(composer_root ,prov_list)
except Exception as error:
print(f"error: {error}" ,file=sys.stderr)
return 2
def CLI(argv=None):
argv = sys.argv[1:] if argv is None else argv
if not argv or argv[0] in ("help" ,"-h" ,"--help"): return print_help()
+ command ,rest = argv[0] ,argv[1:]
+ if command not in COMMAND:
+ print(f"Error: unknown command '{command}'." ,file=sys.stderr)
+ print_help()
+ return 2
+ if not rest or rest[0].startswith("--"):
+ print(f"Error: {command} is given a {'file' if command == 'compose' else 'directory'} first." ,file=sys.stderr)
+ return 2
+ path ,rest = rest[0] ,rest[1:]
value ,flag = {} ,set()
i = 0
- while i < len(argv):
- word = argv[i]
- if word in VALUE and i + 1 < len(argv):
- value[word] = argv[i + 1]; i += 2
+ while i < len(rest):
+ word = rest[i]
+ if word in VALUE and i + 1 < len(rest):
+ value[word] = rest[i + 1]; i += 2
elif word in FLAG:
flag.add(word); i += 1
else:
print(f"Error: unknown or incomplete option '{word}'." ,file=sys.stderr)
return 2
- if "--stage" not in value:
- print("Error: --stage <dir> is required." ,file=sys.stderr)
- return 2
stop = [STOP_OPTION[f] for f in flag if f in STOP_OPTION]
if len(stop) > 1:
print("Error: give at most one --phase-2-...-then-stop option." ,file=sys.stderr)
return 2
return run(
- value["--stage"] ,value.get("--input_acceptance") ,value.get("--gate") ,value.get("--constraints")
+ command ,path ,value.get("--input_acceptance") ,value.get("--gate") ,value.get("--constraints")
,flag ,stop[0] if stop else None
)
# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
"""
-journal_interpreter - the general journal interpreter of Man-In-Grey.
+journal_interpreter - the general journal interpreter of Man_In_Grey.
A bespoke journal interpreter is a gate, a guard and this general
interpreter. The bespoke interpreter chooses the command language and the
+++ /dev/null
-/*
- Man_In_Grey_file_gate
-
- The gate of the bespoke journal interpreter for the file command language.
- Installed setuid root, it starts the journal interpreter with root's rights
- while the real uid still names the caller. Unblessed, it starts the journal
- interpreter with the caller's rights.
-
- The journal interpreter is at a path fixed when the gate is compiled,
- relative to the gate: ../Man_In_Grey/journal_interpreter_file.py. The caller
- cannot name another. When running privileged, the gate refuses to start it
- unless the interpreter, and every directory above it, is owned by root and
- writable by no other account.
-
- The journal arrives on standard input and is passed on unread. Every
- argument is passed on to the journal interpreter, except --print-flags,
- which reports the caller and whether the gate is running privileged.
-
- Who is allowed to hand a journal to the interpreter is decided by the
- interpreter's constraints, not here.
-*/
-
-#include <errno.h>
-#include <libgen.h>
-#include <limits.h>
-#include <pwd.h>
-#include <stdarg.h>
-#include <stdbool.h>
-#include <stdio.h>
-#include <stdlib.h>
-#include <string.h>
-#include <sys/prctl.h>
-#include <sys/stat.h>
-#include <sys/types.h>
-#include <unistd.h>
-
-#define PYTHON "/usr/bin/python3"
-#define INTERPRETER_RELATIVE "../Man_In_Grey/journal_interpreter_file.py"
-#define ARGUMENT_MAX 64
-
-Local void die(const char *format ,...){
- va_list ap; va_start(ap ,format);
- fputs("Man_In_Grey_file_gate: " ,stderr);
- vfprintf(stderr ,format ,ap); va_end(ap);
- fputc('\n' ,stderr);
- _exit(2);
-}
-
-Local bool privileged(void){
- return geteuid() != getuid() || getegid() != getgid();
-}
-
-/* path and every directory above it: owned by root, writable by no other account */
-Local void check_owned_by_root(const char *path){
- char prefix[PATH_MAX];
- size_t length = strlen(path);
- if(length >= sizeof prefix) die("path too long: %s" ,path);
- for(size_t i = 1; i <= length; ++i){
- if( i < length && path[i] != '/' ) continue;
- memcpy(prefix ,path ,i); prefix[i] = '\0';
- struct stat st;
- if( lstat(prefix ,&st) != 0 ) die("cannot examine %s: %s" ,prefix ,strerror(errno));
- if( st.st_uid != 0 ) die("refusing to run privileged: %s is not owned by root" ,prefix);
- if( st.st_mode & (S_IWGRP | S_IWOTH) ) die("refusing to run privileged: %s is writable by an account other than root" ,prefix);
- }
- struct stat st;
- if( lstat("/" ,&st) != 0 || st.st_uid != 0 ) die("refusing to run privileged: / is not owned by root");
-}
-
-Local void print_flags(void){
- struct passwd *pw = getpwuid(getuid());
- printf("caller.uid=%ld\n" ,(long)getuid());
- printf("caller.name=%s\n" ,pw ? pw->pw_name : "unknown");
- printf("effective.uid=%ld\n" ,(long)geteuid());
- printf("privileged=%d\n" ,privileged() ? 1 : 0);
-}
-
-int main(int argc ,char **argv){
- if( argc > 1 && strcmp(argv[1] ,"--print-flags") == 0 ){ print_flags(); return 0; }
- if( argc > ARGUMENT_MAX ) die("too many arguments");
-
- char exe[PATH_MAX];
- ssize_t n = readlink("/proc/self/exe" ,exe ,sizeof exe - 1);
- if( n < 0 ) die("cannot resolve /proc/self/exe: %s" ,strerror(errno));
- exe[n] = '\0';
-
- char candidate[PATH_MAX];
- if( snprintf(candidate ,sizeof candidate ,"%s/%s" ,dirname(exe) ,INTERPRETER_RELATIVE) >= (int)sizeof candidate )
- die("path too long");
- char interpreter[PATH_MAX];
- if( !realpath(candidate ,interpreter) ) die("journal interpreter not found: %s" ,candidate);
-
- if( privileged() ) check_owned_by_root(interpreter);
-
- /* harden the process before starting the interpreter */
- uid_t caller_uid = getuid();
- gid_t caller_gid = getgid();
- struct passwd *pw = getpwuid(caller_uid);
- char caller_name[256];
- snprintf(caller_name ,sizeof caller_name ,"%s" ,pw ? pw->pw_name : "unknown");
-
- clearenv();
- setenv("PATH" ,"/usr/sbin:/usr/bin:/sbin:/bin" ,1);
- setenv("LANG" ,"C.UTF-8" ,1);
- umask(077);
- prctl(PR_SET_DUMPABLE ,0 ,0 ,0 ,0);
- if( chdir("/") != 0 ) die("cannot change directory to /");
-
- char number[32];
- snprintf(number ,sizeof number ,"%ld" ,(long)caller_uid); setenv("MIG_CALLER_UID" ,number ,1);
- snprintf(number ,sizeof number ,"%ld" ,(long)caller_gid); setenv("MIG_CALLER_GID" ,number ,1);
- setenv("MIG_CALLER_NAME" ,caller_name ,1);
-
- /* python3 -E -s -B <interpreter> <arguments> */
- char *argument[ARGUMENT_MAX + 6];
- int k = 0;
- argument[k++] = (char *)PYTHON;
- argument[k++] = (char *)"-E";
- argument[k++] = (char *)"-s";
- argument[k++] = (char *)"-B";
- argument[k++] = interpreter;
- for(int i = 1; i < argc; ++i) argument[k++] = argv[i];
- argument[k] = NULL;
-
- execv(PYTHON ,argument);
- die("cannot start %s: %s" ,PYTHON ,strerror(errno));
-}
<RT·section id="sec-band">
<RT·name>Out-of-band signalling</RT·name>
<p>
- A template mixes two kinds of thing in one stream: the content to be written, and marks that say where to substitute. That is <RT·term>in-band signalling</RT·term>. Some content will always look like a mark, so the template language needs an escape, and the escape is itself a mark. There is no tight way to design the aliasing out. Each level of nesting multiplies the escapes: an f-string that explains how to write f-strings escapes its braces, and a document about that f-string escapes them again.
+ The general method of filling in templates mixes data and control together, i.e. the content to be written, and marks that say where to substitute. Thus this method is a form of <RT·term>in-band signalling</RT·term>. With in-band signaling there will potentially that a content string will be identical to a control string, so data and control can alias. There is no tight way to design the aliasing out. The typical workaround is to disallow control strings in the data and to replace missing characters with 'escape sequences', where the escape then is control. Each level of nesting multiplies the escapes: an f-string that explains how to write f-strings escapes its braces, and a document about that f-string escapes them again.
</p>
<p>
Man_In_Grey signals out of band. A composer is a program, so the logic that forms the output is code, with loops, conditions and lookups, rather than marks embedded in the output. A command in the journal is a structure, and file content is carried in it as a byte string with its length. The journal interpreter never scans content for meaning, so content can hold anything, including text that looks like a journal, with no escaping. This is the same principle as an extent string, where the extent of the content travels beside it rather than being marked within it.
--- /dev/null
+# replace: the second run discards the first run's file, keeping no backup.
+# The content is written in two parts.
+
+def compose(prov ,journal_iface):
+ file = journal_iface.open("out/net" ,prov.implied_fname ,"replace" ,prov.process_user ,"0444")
+ journal_iface.write(file ,"server:\n")
+ journal_iface.write(file ," verbosity: 1\n")
--- /dev/null
+# unlink: the first run removes out/obsolete.conf; the second finds nothing to remove.
+
+def compose(prov ,journal_iface):
+ journal_iface.unlink("out" ,"obsolete.conf")
--- /dev/null
+# replace_keep: the second run keeps the first run's file as a timestamped backup.
+# The file name is the one this composer implies: its own name less .py.
+
+def compose(prov ,journal_iface):
+ file = journal_iface.open("out" ,prov.implied_fname ,"replace_keep" ,prov.process_user ,0o444)
+ journal_iface.write(file ,"server:\n do-ip6: no\n")
--- /dev/null
+# replace_keep, under a file name given here rather than implied.
+
+def compose(prov ,journal_iface):
+ file = journal_iface.open("out/web" ,"nginx.conf" ,"replace_keep" ,prov.process_user ,"0644")
+ journal_iface.write(file ,"events {}\nhttp { server { listen 8080; } }\n")
--- /dev/null
+# A wrong argument: the journal interface raises an error, and no journal reaches the gate.
+
+def compose(prov ,journal_iface):
+ journal_iface.open("out" ,"unbound_conf" ,"overwrite" ,prov.process_user ,0o444)
--- /dev/null
+# create: allowed once; a second run is refused, as the file then exists.
+
+def compose(prov ,journal_iface):
+ file = journal_iface.open("out" ,"new.conf" ,"create" ,prov.process_user ,0o600)
+ journal_iface.write(file ,"made once\n")
--- /dev/null
+# One file the constraints allow, and one they do not. The whole journal is refused.
+
+def compose(prov ,journal_iface):
+ allowed = journal_iface.open("out" ,"unbound_conf" ,"replace_keep" ,prov.process_user ,0o444)
+ journal_iface.write(allowed ,"this is not to be written\n")
+ forbidden = journal_iface.open("out" ,"forbidden.conf" ,"replace" ,prov.process_user ,0o644)
+ journal_iface.write(forbidden ,"nor this\n")
"""
run
-The write_file test of Man-In-Grey's file command language. It runs the
+The write_file test of Man_In_Grey's file command language. It runs the
promoted Man_In_Grey, from $REPO_HOME/consumer, through an unblessed gate,
with constraints it writes for the files it expects, and checks:
- run_1 the stage beside this file: each composer's file written, and
+ run_1 orchestrate composer/: each composer's file written, and
out/obsolete.conf, made by the test beforehand, unlinked; the
files are compared with expected/run_1
- run_2 the same stage again: replace_keep files kept as backups named
+ run_2 orchestrate composer/ again: replace_keep files kept as backups named
<file>.YYYYMMDDTHHMMSSZ, the replace file overwritten with no
backup; compared with expected/run_2
- create stage_create: a file opened in create mode is written once, and
+ create compose composer_create/new.py: a file opened in create mode is written once, and
a second run is refused by the filesystem check
- refused stage_refused: a journal opening one allowed file and one the
+ refused orchestrate composer_refused/: a journal opening one allowed file and one the
constraints do not allow is refused whole by the constraint check
- argument stage_argument: a composer giving a wrong mode fails when it
+ argument orchestrate composer_argument/: a composer giving a wrong mode fails when it
places the command, and no journal reaches the gate
A refused run is to leave every file as it was. Backup names carry the time
problem.append(f"owner uid {path.lstat().st_uid}, expected {os.getuid()}")
log.check(not problem ,what ,"; ".join(problem) or None)
-def run_Man_In_Grey(program ,stage ,work_dpath ,constraint_fpath):
- command = [str(program) ,"--stage" ,str(TEST_DPATH / stage) ,"--constraints" ,str(constraint_fpath)]
+def run_Man_In_Grey(program ,command ,path ,work_dpath ,constraint_fpath):
+ command = [str(program) ,command ,str(TEST_DPATH / path) ,"--constraints" ,str(constraint_fpath)]
result = subprocess.run(command ,cwd=work_dpath ,capture_output=True ,text=True)
return result.returncode ,(result.stdout + result.stderr).strip()
# run_1 and run_2
for run_name in ("run_1" ,"run_2"):
- status ,output = run_Man_In_Grey(program ,"stage" ,work_dpath ,constraint_fpath)
+ status ,output = run_Man_In_Grey(program ,"orchestrate" ,"composer" ,work_dpath ,constraint_fpath)
if check_status(log ,f"{run_name} Man_In_Grey exits 0" ,status ,0 ,output):
compare(log ,run_name ,work_dpath)
else:
return log.summary()
# create: once, then refused
- status ,output = run_Man_In_Grey(program ,"stage_create" ,work_dpath ,constraint_fpath)
+ status ,output = run_Man_In_Grey(program ,"compose" ,"composer_create/new.py" ,work_dpath ,constraint_fpath)
check_status(log ,"create Man_In_Grey exits 0" ,status ,0 ,output)
new = out / "new.conf"
log.check(new.is_file() and new.read_text() == "made once\n" and stat.S_IMODE(new.stat().st_mode) == 0o600
,"create out/new.conf" ,None if new.is_file() else "not written")
before = snapshot(out)
- status ,output = run_Man_In_Grey(program ,"stage_create" ,work_dpath ,constraint_fpath)
+ status ,output = run_Man_In_Grey(program ,"compose" ,"composer_create/new.py" ,work_dpath ,constraint_fpath)
check_status(log ,"create again is refused by the filesystem check" ,status ,1 ,output ,"filesystem: refused")
log.check(snapshot(out) == before ,"create again changes nothing")
# a journal the constraints refuse
before = snapshot(out)
- status ,output = run_Man_In_Grey(program ,"stage_refused" ,work_dpath ,constraint_fpath)
+ status ,output = run_Man_In_Grey(program ,"orchestrate" ,"composer_refused" ,work_dpath ,constraint_fpath)
check_status(log ,"refused is refused by the constraint check" ,status ,1 ,output ,"constraint: refused")
log.check(snapshot(out) == before ,"refused changes nothing, the allowed file included")
# a composer with a wrong argument
before = snapshot(out)
- status ,output = run_Man_In_Grey(program ,"stage_argument" ,work_dpath ,constraint_fpath)
+ status ,output = run_Man_In_Grey(program ,"orchestrate" ,"composer_argument" ,work_dpath ,constraint_fpath)
check_status(log ,"argument fails in the composer" ,status ,2 ,output ,"wrong_mode.py: open: mode 'overwrite'")
log.check(snapshot(out) == before ,"argument changes nothing")
+++ /dev/null
-# replace: the second run discards the first run's file, keeping no backup.
-# The content is written in two parts.
-
-def compose(prov ,journal_iface):
- file = journal_iface.open("out/net" ,prov.implied_fname ,"replace" ,prov.process_user ,"0444")
- journal_iface.write(file ,"server:\n")
- journal_iface.write(file ," verbosity: 1\n")
+++ /dev/null
-# unlink: the first run removes out/obsolete.conf; the second finds nothing to remove.
-
-def compose(prov ,journal_iface):
- journal_iface.unlink("out" ,"obsolete.conf")
+++ /dev/null
-# replace_keep: the second run keeps the first run's file as a timestamped backup.
-# The file name is the one this composer implies: its own name less .py.
-
-def compose(prov ,journal_iface):
- file = journal_iface.open("out" ,prov.implied_fname ,"replace_keep" ,prov.process_user ,0o444)
- journal_iface.write(file ,"server:\n do-ip6: no\n")
+++ /dev/null
-# replace_keep, under a file name given here rather than implied.
-
-def compose(prov ,journal_iface):
- file = journal_iface.open("out/web" ,"nginx.conf" ,"replace_keep" ,prov.process_user ,"0644")
- journal_iface.write(file ,"events {}\nhttp { server { listen 8080; } }\n")
+++ /dev/null
-# A wrong argument: the journal interface raises an error, and no journal reaches the gate.
-
-def compose(prov ,journal_iface):
- journal_iface.open("out" ,"unbound_conf" ,"overwrite" ,prov.process_user ,0o444)
+++ /dev/null
-# create: allowed once; a second run is refused, as the file then exists.
-
-def compose(prov ,journal_iface):
- file = journal_iface.open("out" ,"new.conf" ,"create" ,prov.process_user ,0o600)
- journal_iface.write(file ,"made once\n")
+++ /dev/null
-# One file the constraints allow, and one they do not. The whole journal is refused.
-
-def compose(prov ,journal_iface):
- allowed = journal_iface.open("out" ,"unbound_conf" ,"replace_keep" ,prov.process_user ,0o444)
- journal_iface.write(allowed ,"this is not to be written\n")
- forbidden = journal_iface.open("out" ,"forbidden.conf" ,"replace" ,prov.process_user ,0o644)
- journal_iface.write(forbidden ,"nor this\n")