├── .gitignore ├── contrib └── python-myna │ ├── .gitignore │ ├── Makefile │ ├── myna │ ├── __init__.py │ └── shim.py │ └── setup.py ├── glide.yaml ├── Makefile ├── glide.lock ├── .travis.yml ├── main.go ├── README.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | processfly 2 | processes.db 3 | vendor/ 4 | *.swp 5 | myna 6 | -------------------------------------------------------------------------------- /contrib/python-myna/.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | dist/ 3 | build/ 4 | python_myna.egg-info/ 5 | -------------------------------------------------------------------------------- /glide.yaml: -------------------------------------------------------------------------------- 1 | package: github.com/SpectoLabs/myna 2 | import: 3 | - package: github.com/boltdb/bolt 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | deps: 2 | glide up 3 | 4 | build: deps 5 | go build 6 | 7 | install: deps 8 | go install 9 | -------------------------------------------------------------------------------- /contrib/python-myna/Makefile: -------------------------------------------------------------------------------- 1 | release: 2 | python setup.py register 3 | python setup.py sdist upload 4 | -------------------------------------------------------------------------------- /contrib/python-myna/myna/__init__.py: -------------------------------------------------------------------------------- 1 | from . import shim 2 | 3 | tmpdir = None 4 | 5 | def setUp(): 6 | global tmpdir 7 | tmpdir = shim.setup_shim_for('kubectl') 8 | 9 | def tearDown(): 10 | global tmpdir 11 | shim.teardown_shim_dir(tmpdir) 12 | -------------------------------------------------------------------------------- /glide.lock: -------------------------------------------------------------------------------- 1 | hash: 81baa8c8413c1c099cdcdc6140c71a698fcca5069225981cfc2f347bba366fcc 2 | updated: 2016-08-08T11:09:46.766384918+01:00 3 | imports: 4 | - name: github.com/boltdb/bolt 5 | version: c1c3bd7e847a231b2b1f9592fa86182a121ad734 6 | - name: golang.org/x/sys 7 | version: d4feaf1a7e61e1d9e79e6c4e76c6349e9cab0a03 8 | subpackages: 9 | - unix 10 | devImports: [] 11 | -------------------------------------------------------------------------------- /contrib/python-myna/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name = "python-myna", 5 | version = "1.1.0", 6 | packages = find_packages(), 7 | author = "SpectoLabs", 8 | author_email = "admin@specto.io", 9 | url = "https://github.com/SpectoLabs/myna", 10 | description = "Using the Myna process virtualisation testing tool from Python unit tests", 11 | classifiers = [ 12 | 'Programming Language :: Python', 13 | ] 14 | ) 15 | -------------------------------------------------------------------------------- /contrib/python-myna/myna/shim.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | import os 3 | import shutil 4 | 5 | TEMPLATE = """ 6 | #!/bin/bash -e 7 | 8 | unset CAPTURE 9 | myna {application} "$@" 10 | """ 11 | 12 | def _add_shim_directory_to_path(shim_dir): 13 | os.environ['PATH'] = shim_dir + os.path.pathsep + os.environ['PATH'] 14 | 15 | def _remove_shim_directory_from_path(shim_dir): 16 | if os.environ['PATH'].startswith(shim_dir + os.path.pathsep): 17 | os.environ['PATH'] = os.environ['PATH'][len(shim_dir + os.path.pathsep):] 18 | 19 | def setup_shim_for(application): 20 | shim_dir = tempfile.mkdtemp(suffix='myna', prefix='tmp') 21 | shim_path = os.path.join(shim_dir, application) 22 | with open(shim_path, 'w') as f: 23 | f.write(TEMPLATE.format(application=application)) 24 | os.chmod(shim_path, 0o750) 25 | _add_shim_directory_to_path(shim_dir) 26 | return shim_dir 27 | 28 | def teardown_shim_dir(shim_dir): 29 | shutil.rmtree(shim_dir) 30 | _remove_shim_directory_from_path(shim_dir) 31 | 32 | setup_shim_for('echo') 33 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - 1.6 4 | install: 5 | - go get -v github.com/Masterminds/glide 6 | - glide install 7 | - go build 8 | deploy: 9 | provider: releases 10 | skip_cleanup: true 11 | api_key: 12 | secure: VWXlWLf9ELrEe19JhIH54ALIMt8/JtQb1XAQ6zrwbot7umoEOMuClhFHg5bgR8GDpZJfYJiXXKap2qET4aA7kTDJyIw2/ft9GQxp01qx5RVildm2+tdr4uto4EXNc4himeFeXCUIc7ZhpKxHqQAVYy3iytEOcA7QRpmQjSp6gkJOk04ZQ39oitRRmvPPaprigtOEiDuvME/u9N55kJmkJM7xY75W04Ud7SQ5JTNtnwo79MCZEZL2x5PzVBRHO7w4YCj7BL+WW+yGeDi7/GOYloTtlpjBjcrcQo3ksg4VAr7Y7YHwaaQR3edmI0cJe5iscKrzyNtuXogSJ5CSowI7c4Fi5USASCpGb2OusF/km0ycCPmT0wvj01Rmtcqf7NLutvQ3JlhttBi0jbIq1JsdvkowbwDaZRXUTGzxvj6VqInqcXIlIKh174anTnCxZUgCPqn+rsLiO3/Zufhc7NEyDjCdABUDI+BPaAnleweCu94a5vRLGmIQpf4vhAUmnHEPuSdrCqAsyiKbBVuBXyCy0Rdma65VecZ5TmSeBbuwovsOkWRkpszfDrlA8GwKfDOckcmyg3hT46cu3XNFSnyCsnwMN7pxCS8YhjYB3852QtB4oU5mE4go2SSZhathmAMhTrNIrnTpbaPNtgZvjM1HEZvTZS/7HjHSQrohxazEtr4= 13 | file: "./myna" 14 | on: 15 | tags: true 16 | repo: SpectoLabs/myna 17 | notifications: 18 | slack: 19 | secure: PNZiisa8HNknXS+hrvUgnKA47nCvWFrxla5tHHvip9K5MdtBRj07G2rrGI1t5abFMVevIyarxx86ex7AM08bMBWtmHlV79ziuN8jhGnpKqhrnx2yAljUFVQ0Ako8jR2oTzByVYyKmsoUlktPyfL/vR3+cKl3nua4iDQ3qNTvo2xr0EASFVViUtblIH0+cakKv3ooD+bN0JcxPtdkZHCy/DldGeYw5fxyWwZXycOMktEATukmGMKVDoIVAjB3yVuaRs4z/PfMnr7ajHcJRKbKouN4WXj07+eCD/IuyMkKfXaQJLYRgfOojsOQlzcEmyDJe3WVI2H9W1OZpDaGEJK9odEsOGIWrH7UjcxmsODqHaEv8U2LBZQPfsNVYbDJ8L18s0BnioqLMxs1udWulmTqn/AuXKybP26i7a0BGOX6PSa+TSfgFLBmOnmx9PKuE106Yot0KJCkR8SyimtijzYpM03IqdZ5MPy9VBWNJODO+dob2QlK6MCAC0cY7dqWo0ciCDSAmZShDlT63ZFaED7eJkDMBAh4+03kjiHI1D/HIXb8SKpybZaQiyPeaqNfhbpdUxbdwsuHS+R59elJWdSqNaGsjExdp5C2Goy9+RFrBlVVPODojFAKfcvxRtVrS5TqcKgPLlbXpUZElZ0w35lF7wrSflypj6DsouEddGLvfGI= 20 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "io/ioutil" 8 | "bytes" 9 | "syscall" 10 | "strings" 11 | "encoding/json" 12 | "github.com/boltdb/bolt" 13 | "encoding/base64" 14 | ) 15 | 16 | type Process struct { 17 | Command []string 18 | Stdout []byte 19 | Stderr []byte 20 | ReturnCode int 21 | } 22 | 23 | type ProcessJson struct { 24 | Command []string 25 | Stdout string 26 | Stderr string 27 | ReturnCode int 28 | } 29 | 30 | func encodeBase64(str []byte) string { 31 | return base64.StdEncoding.EncodeToString(str) 32 | } 33 | func decodeBase64(str string) []byte { 34 | data, _ := base64.StdEncoding.DecodeString(str) 35 | return data 36 | } 37 | func openBoltDb() (*bolt.DB, error) { 38 | location := os.Getenv("DATABASE_LOCATION") 39 | if location == "" { 40 | location = "processes.db" 41 | } 42 | return bolt.Open(location, 0600, nil) 43 | } 44 | 45 | func (p *Process) Json() []byte { 46 | payload := ProcessJson{} 47 | payload.Command = p.Command 48 | payload.ReturnCode = p.ReturnCode 49 | payload.Stdout = encodeBase64(p.Stdout) 50 | payload.Stderr = encodeBase64(p.Stderr) 51 | result, _ := json.Marshal(payload) 52 | return result 53 | } 54 | 55 | func (p *Process) FromProcessJson(proc *ProcessJson) { 56 | p.Command = proc.Command 57 | p.Stdout = decodeBase64(proc.Stdout) 58 | p.Stderr = decodeBase64(proc.Stderr) 59 | p.ReturnCode = proc.ReturnCode 60 | } 61 | 62 | func (p *Process) FromJson(payload []byte) { 63 | result := ProcessJson{} 64 | json.Unmarshal(payload, &result) 65 | p.FromProcessJson(&result) 66 | } 67 | 68 | func (p *Process) Key() []byte { 69 | return ([]byte)(strings.Join(p.Command, " ")) 70 | } 71 | 72 | func (p *Process) Print() { 73 | fmt.Println("Command: " + strings.Join(p.Command, " ")) 74 | fmt.Println("Stdout: " + string(p.Stdout)) 75 | fmt.Println("Stderr: " + string(p.Stderr)) 76 | fmt.Printf("Return code: %d\n", p.ReturnCode) 77 | } 78 | 79 | func (p *Process) Save() error { 80 | db, err := openBoltDb(); 81 | if err != nil { 82 | return err 83 | } 84 | defer db.Close() 85 | err = db.Update(func(tx *bolt.Tx) error { 86 | b, err := tx.CreateBucketIfNotExists([]byte("processes")) 87 | if err != nil { 88 | return fmt.Errorf("create bucket: %s", err) 89 | } 90 | return b.Put(p.Key(), p.Json()) 91 | }) 92 | return err 93 | } 94 | 95 | func (p *Process) Capture() { 96 | cmd := exec.Command(p.Command[0], p.Command[1:]...) 97 | var stdout bytes.Buffer 98 | var stderr bytes.Buffer 99 | cmd.Stdout = &stdout 100 | cmd.Stderr = &stderr 101 | if err := cmd.Run() ; err != nil { 102 | if exitError, ok := err.(*exec.ExitError); ok { 103 | waitStatus := exitError.Sys().(syscall.WaitStatus) 104 | p.ReturnCode = waitStatus.ExitStatus() 105 | } else { 106 | fmt.Println(err.Error()) 107 | return 108 | } 109 | } 110 | p.Stdout = stdout.Bytes() 111 | p.Stderr = stderr.Bytes() 112 | err := p.Save() 113 | if err != nil { 114 | fmt.Println(err.Error()) 115 | } 116 | p.Playback() 117 | } 118 | 119 | func (p *Process) Lookup() error { 120 | db, err := openBoltDb(); 121 | if err != nil { 122 | return err 123 | } 124 | defer db.Close() 125 | err = db.View(func(tx *bolt.Tx) error { 126 | b := tx.Bucket([]byte("processes")) 127 | if b == nil { 128 | return fmt.Errorf("Nothing has been recorded yet. Try to capture some commands first") 129 | } 130 | v := b.Get(p.Key()) 131 | if v == nil { 132 | return fmt.Errorf("%s does not know about this process. Run the command in capture mode first.", os.Args[0]) 133 | } 134 | p.FromJson(v) 135 | return nil 136 | }) 137 | return err 138 | } 139 | 140 | func (p *Process) Playback() error { 141 | os.Stdout.Write(p.Stdout) 142 | os.Stderr.Write(p.Stderr) 143 | os.Exit(p.ReturnCode) 144 | return nil 145 | } 146 | 147 | func Export() error { 148 | db, err := openBoltDb(); 149 | if err != nil { 150 | return err 151 | } 152 | defer db.Close() 153 | payload := []ProcessJson{} 154 | db.View(func(tx *bolt.Tx) error { 155 | b := tx.Bucket([]byte("processes")) 156 | if b == nil { 157 | return nil 158 | } 159 | b.ForEach(func(k, v []byte) error { 160 | p := ProcessJson{} 161 | json.Unmarshal(v, &p) 162 | payload = append(payload, p) 163 | return nil 164 | }) 165 | return nil 166 | }) 167 | data, _ := json.Marshal(payload) 168 | os.Stdout.Write(data) 169 | return nil 170 | } 171 | 172 | func Import(file string) { 173 | payload, err := ioutil.ReadFile(file) 174 | if err != nil { 175 | fmt.Println(err.Error()) 176 | return 177 | } 178 | parsed := []ProcessJson{} 179 | err = json.Unmarshal(payload, &parsed) 180 | if err != nil { 181 | fmt.Println(err.Error()) 182 | return 183 | } 184 | for _, j := range parsed { 185 | p := Process{} 186 | p.FromProcessJson(&j) 187 | p.Save() 188 | } 189 | } 190 | 191 | func Usage() { 192 | fmt.Println(os.Args[0] + " [OPTS] [COMMAND] [[ARGS]]") 193 | fmt.Println("") 194 | fmt.Println(os.Args[0] + " can either capture or playback commands:") 195 | fmt.Println("") 196 | fmt.Println("Example:") 197 | fmt.Println("") 198 | fmt.Println(" $ " + os.Args[0] + " --capture ls -al /") 199 | fmt.Println(" $ " + os.Args[0] + " ls -al /") 200 | fmt.Println("") 201 | fmt.Println("Options:") 202 | fmt.Println("") 203 | fmt.Println(" --export Export database to json") 204 | fmt.Println(" --import [PATH] Import json to database") 205 | fmt.Println(" --capture [COMMAND] [[ARGS]] Capture the output of running COMMAND [ARGS]") 206 | } 207 | 208 | func InCaptureMode() bool { 209 | return os.Getenv("CAPTURE") == "1" 210 | } 211 | 212 | func main() { 213 | if len(os.Args) == 1 { 214 | Usage() 215 | } else if os.Args[1] == "--export" { 216 | Export() 217 | } else if os.Args[1] == "--import" { 218 | Import(os.Args[2]) 219 | } else if os.Args[1] == "--capture" { 220 | p := Process{} 221 | p.Command = os.Args[2:] 222 | p.Capture() 223 | } else { 224 | p := Process{} 225 | p.Command = os.Args[1:] 226 | if InCaptureMode() { 227 | p.Capture() 228 | } else if err := p.Lookup() ; err == nil { 229 | p.Playback() 230 | } else { 231 | fmt.Println(err.Error()) 232 | } 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # myna 2 | 3 | *"Process virtualization for the people by the people with people." - People* 4 | 5 | Myna is a testing tool that captures and replays the output of command line programs. 6 | It's heavily inspired by [SpectoLab's Hoverfly](https://github.com/SpectoLabs/hoverfly), 7 | which does capture and playback for http(s) web services. 8 | 9 | Myna is written in Go and makes use of Boltdb for storage. It was 10 | initially built to facilitate testing of [KubeFuse](https://github.com/bspaans/kubefuse). 11 | 12 | It does not work with interactive programs at this point in time, but it's 13 | pretty baller with the rest :sparkles: 14 | 15 | ## Installation 16 | 17 | Myna uses the Glide build tool so you can just do: 18 | 19 | ``` 20 | glide install 21 | go build 22 | ``` 23 | 24 | And you're laughing. 25 | 26 | 27 | ## Usage 28 | 29 | Capture the output of `ls -al /` 30 | 31 | ```sh 32 | myna --capture ls -al / 33 | ``` 34 | 35 | Or by setting the CAPTURE environment variable: 36 | 37 | ```sh 38 | CAPTURE=1 myna ls -al / 39 | ``` 40 | 41 | Play back the output of `ls -al /` 42 | 43 | ```sh 44 | myna ls -al / 45 | ``` 46 | 47 | By default myna will save all its state in a file called `processes.db`, 48 | but we can override that by setting the `DATABASE_LOCATION` environment 49 | variable: 50 | 51 | ```sh 52 | DATABASE_LOCATION=/tmp/my.db myna --capture ls -al / 53 | DATABASE_LOCATION=/tmp/my.db myna ls -al / 54 | ``` 55 | 56 | To use the simulated process from our tests we can update our code to use myna 57 | in place of the usual binary, but by writing a wrapper `ls` file and putting 58 | this on the PATH we can seamlessly use myna from our tests without having to 59 | change a single thing: 60 | 61 | ```sh 62 | cat > ls < boot/initrd.img-4.2.0-36-generic 92 | lrwxrwxrwx 1 root root 32 Apr 6 18:58 initrd.img.old -> boot/initrd.img-4.2.0-35-generic 93 | drwxr-xr-x 29 root root 4096 Feb 17 21:13 lib 94 | drwxr-xr-x 2 root root 4096 Feb 17 21:13 lib32 95 | drwxr-xr-x 2 root root 4096 Feb 17 21:13 lib64 96 | drwx------ 2 root root 16384 Aug 10 2015 lost+found 97 | drwxr-xr-x 3 root root 4096 Aug 10 2015 media 98 | drwxr-xr-x 2 root root 4096 Apr 17 2015 mnt 99 | drwxr-xr-x 5 root root 4096 Apr 5 00:07 opt 100 | dr-xr-xr-x 297 root root 0 May 13 22:34 proc 101 | drwx------ 8 root root 4096 Nov 12 2015 root 102 | drwxr-xr-x 30 root root 960 May 15 03:05 run 103 | drwxr-xr-x 2 root root 12288 Mar 20 12:37 sbin 104 | drwxr-xr-x 2 root root 4096 Apr 22 2015 srv 105 | dr-xr-xr-x 13 root root 0 May 15 03:31 sys 106 | drwxrwxrwt 12 root root 12288 May 15 04:49 tmp 107 | drwxr-xr-x 12 root root 4096 Jan 30 15:09 usr 108 | drwxr-xr-x 13 root root 4096 Apr 22 2015 var 109 | lrwxrwxrwx 1 root root 29 May 14 12:29 vmlinuz -> boot/vmlinuz-4.2.0-36-generic 110 | lrwxrwxrwx 1 root root 29 Apr 6 18:58 vmlinuz.old -> boot/vmlinuz-4.2.0-35-generic 111 | $ export CAPTURE=0 112 | $ ls / 113 | drwxr-xr-x 24 root root 4096 May 14 12:29 . 114 | drwxr-xr-x 24 root root 4096 May 14 12:29 .. 115 | drwxr-xr-x 2 root root 12288 Mar 20 12:37 bin 116 | drwxr-xr-x 4 root root 4096 May 14 12:30 boot 117 | drwxrwxr-x 2 root root 4096 Aug 10 2015 cdrom 118 | drwxr-xr-x 19 root root 4880 May 14 10:17 dev 119 | drwxr-xr-x 159 root root 12288 May 14 12:29 etc 120 | drwxr-xr-x 4 root root 4096 Aug 10 2015 home 121 | lrwxrwxrwx 1 root root 32 May 14 12:29 initrd.img -> boot/initrd.img-4.2.0-36-generic 122 | lrwxrwxrwx 1 root root 32 Apr 6 18:58 initrd.img.old -> boot/initrd.img-4.2.0-35-generic 123 | drwxr-xr-x 29 root root 4096 Feb 17 21:13 lib 124 | drwxr-xr-x 2 root root 4096 Feb 17 21:13 lib32 125 | drwxr-xr-x 2 root root 4096 Feb 17 21:13 lib64 126 | drwx------ 2 root root 16384 Aug 10 2015 lost+found 127 | drwxr-xr-x 3 root root 4096 Aug 10 2015 media 128 | drwxr-xr-x 2 root root 4096 Apr 17 2015 mnt 129 | drwxr-xr-x 5 root root 4096 Apr 5 00:07 opt 130 | dr-xr-xr-x 297 root root 0 May 13 22:34 proc 131 | drwx------ 8 root root 4096 Nov 12 2015 root 132 | drwxr-xr-x 30 root root 960 May 15 03:05 run 133 | drwxr-xr-x 2 root root 12288 Mar 20 12:37 sbin 134 | drwxr-xr-x 2 root root 4096 Apr 22 2015 srv 135 | dr-xr-xr-x 13 root root 0 May 15 03:31 sys 136 | drwxrwxrwt 12 root root 12288 May 15 04:49 tmp 137 | drwxr-xr-x 12 root root 4096 Jan 30 15:09 usr 138 | drwxr-xr-x 13 root root 4096 Apr 22 2015 var 139 | lrwxrwxrwx 1 root root 29 May 14 12:29 vmlinuz -> boot/vmlinuz-4.2.0-36-generic 140 | lrwxrwxrwx 1 root root 29 Apr 6 18:58 vmlinuz.old -> boot/vmlinuz-4.2.0-35-generic 141 | ``` 142 | 143 | 144 | ## A Continuous Integration Workflow 145 | 146 | 147 | For [KubeFuse](https://github.com/bspaans/kubefuse) I have to generate more 148 | than one test case. To keep this process repeatable I added a `capture` step 149 | to my build system that I would run whenever the command under test (`kubectl` 150 | in this case) was updated. 151 | 152 | The build step looks something like this: 153 | 154 | ```sh 155 | #!/bin/bash 156 | 157 | # Put myna into capture mode 158 | export CAPTURE=1 159 | 160 | # Remove the old myna database if it exists 161 | rm -f processes.db 162 | 163 | # Run the commands I need for testing: 164 | myna kubectl get namespaces 165 | myna kubectl get pods --all-namespaces 166 | myna kubectl get svc --all-namespaces 167 | myna kubectl get rc --all-namespaces 168 | .... 169 | 170 | # Export the results 171 | myna --export | python -m json.tool > kubectl.json 172 | ``` 173 | 174 | Before I start running my tests I need to create the kubectl shim and import 175 | the definition: 176 | 177 | ```sh 178 | cat > bin/kubectl <