├── .gitattributes ├── .gitignore ├── Screenshot.png ├── example-graph.png ├── Squeakerfile.st ├── freshen.sh ├── LICENSE ├── Squeakerfile.tonyg.st ├── README.md └── squeaker /.gitattributes: -------------------------------------------------------------------------------- 1 | *.st diff=cr 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | github-cache/ 2 | package-cache/ 3 | update*-squeak.image.log 4 | -------------------------------------------------------------------------------- /Screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tonyg/squeaker/HEAD/Screenshot.png -------------------------------------------------------------------------------- /example-graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tonyg/squeaker/HEAD/example-graph.png -------------------------------------------------------------------------------- /Squeakerfile.st: -------------------------------------------------------------------------------- 1 | from: 'http://files.squeak.org/6.1alpha/Squeak6.1alpha-23549-64bit/Squeak6.1alpha-23549-64bit.zip'! 2 | -------------------------------------------------------------------------------- /freshen.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -eu 3 | squeaker build -t base-6.1 . 4 | squeaker build -t tonyg -f Squeakerfile.tonyg.st . 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Tony Garnock-Jones 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Squeakerfile.tonyg.st: -------------------------------------------------------------------------------- 1 | from: #'base-6.1'! "On OSX, `launchSystemFiles:event:` triggers an `inform:` in the background. As a temporary hack, clear out that method." Utilities authorInitials: 'squeaker'. Utilities authorName: 'https://github.com/tonyg/squeaker'. MorphicProject compile: 'launchSystemFiles: fileStreams event: genericMorphicEvent'. Utilities authorInitials: ''. World submorphs select: [:m | (m isKindOf: UserDialogBoxMorph) and: [m label asString includesSubstring: 'Cannot start a second instance']] thenDo: [:m | m delete]. "Clear away the welcome window and configuration wizard." World submorphs select: [:m | (m isKindOf: PreferenceWizardMorph) or: [m isSystemWindow and: [m label beginsWith: 'Welcome to Squeak']]] thenDo: [:m | m delete]. ! MCMcmUpdater default doUpdate: false! Metacello new configuration: 'FFI'; load! Installer ss project: 'OSProcess'; install: 'OSProcess'! Installer ss project: 'CommandShell'; install: 'CommandShell'! Installer ss project: 'Snarl'; install: 'Snarl'! SnarlNotificationMorph allInstancesDo: [:m | m delete]! Project current instVarNamed: #uiManager put: nil! "Tony's preferences!!" CommunityTheme createDark apply. Model useColorfulWindows: true. Display relativeUiScaleFactor: 3/2. Cursor useBiggerCursors: true. Preferences enable: #mouseOverForKeyboardFocus. Preferences disable: #balloonHelpEnabled. Preferences disable: #alternativeBrowseIt. TextEditor autoEnclose: false. Model windowActiveOnFirstClick: true. ! resource: 'https://source.squeak.org/trunk/'! "Update again ↓, depending ↑ on the state of trunk" MCMcmUpdater default doUpdate: false! -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Squeaker 2 | 3 | Like Docker, but for Smalltalk images. [You know, for 4 | kids.](https://www.youtube.com/watch?v=8UxAlkaTWLc) 5 | 6 | It's a small program that helps in automated derivation of 7 | *configured* Smalltalk images from some fixed *base* image and a 8 | collection of Smalltalk *commands*. It's a few hundred lines of 9 | Python, so far. 10 | 11 | - `Squeakerfile.st` is like `Dockerfile`, except it contains 12 | locations to fetch images from plus Smalltalk expressions to derive 13 | new images. 14 | 15 | - `squeaker build` is like `docker build`. It manages a cache (in 16 | `$XDG_CACHE_HOME/squeaker`, usually `$HOME/.cache/squeaker` on 17 | Unix) of downloaded artifacts, derived images, and the stages in 18 | each derivation. 19 | 20 | - `squeaker run` is like `docker run`. It starts a 21 | previously-downloaded or -derived image in a temporary directory. 22 | (Future: support persistent image instances, like docker does! Easy 23 | enough.) 24 | 25 | - `squeaker gc` is like `docker system prune`, roughly. It cleans out 26 | the Squeaker cache directory, treating tags as GC roots. 27 | 28 | ## Installation 29 | 30 | Install Python 3.x (I used 3.9 to build it), making sure `python3` is 31 | on the `PATH`. 32 | 33 | Make sure [`squeaker`](./squeaker) is on the `PATH`, too. 34 | 35 | ## Usage 36 | 37 | You can type "`squeaker --help`" and "`squeaker` *subcommand* 38 | `--help`" for terse usage information. 39 | 40 | ## Downloading and customising images 41 | 42 | The `Squeakerfile.st` specifying a build is written using 43 | [`!`-delimited chunk][chunks1] format (see also [here][chunks2] and 44 | [below](#chunk-format)). A chunk starting with `from:` specifies either 45 | 46 | - A URL, if the argument to `from:` is a Smalltalk string literal, 47 | e.g. 48 | 49 | from: 'http://files.squeak.org/6.0alpha/Squeak6.0alpha-20582-64bit/Squeak6.0alpha-20582-64bit.zip' 50 | 51 | The URL must point to a ZIP file containing at least one `*.image` 52 | file and a matching `*.changes` file. 53 | 54 | - A tag, previously defined using `squeaker build -t ...`, if the 55 | argument to from is a *quoted* Smalltalk *symbol* literal, e.g. 56 | 57 | from: #'myimage' 58 | 59 | Other chunks are snippets of Smalltalk to execute to configure the 60 | image, deriving another image. Chunks are applied in sequence. 61 | Squeaker caches intermediaries, like Docker does, to avoid repeated 62 | work. 63 | 64 | Let's work with the following `Squeakerfile.st`: 65 | 66 | ```smalltalk 67 | from: 'http://files.squeak.org/6.0alpha/Squeak6.0alpha-20582-64bit/Squeak6.0alpha-20582-64bit.zip'! 68 | 69 | World submorphs 70 | select: [:m | 71 | (m isKindOf: PreferenceWizardMorph) or: 72 | [m isSystemWindow and: [m label beginsWith: 'Welcome to Squeak']]] 73 | thenDo: [:m | m delete]. 74 | ! 75 | 76 | "Apply my preferences!!" 77 | CommunityTheme createDark apply. 78 | Model useColorfulWindows: true. 79 | [Preferences setDemoFonts] valueSupplyingAnswer: true. 80 | Cursor useBiggerCursors: true. 81 | Preferences enable: #mouseOverForKeyboardFocus. 82 | TextEditor autoEnclose: false. 83 | Model windowActiveOnFirstClick: true. 84 | ! 85 | 86 | MCMcmUpdater default doUpdate: false. 87 | ! 88 | 89 | Metacello new configuration: 'FFI'; load. 90 | Installer ss project: 'OSProcess'; install: 'OSProcess'. 91 | ! 92 | ``` 93 | 94 | Use `squeaker build` to build it. Here, I will tag the final image as 95 | `myimage`: 96 | 97 | ``` 98 | $ squeaker build -t myimage -f Squeakerfile.st . 99 | INFO:root:Downloading http://files.squeak.org/6.0alpha/Squeak6.0alpha-20582-64bit/Squeak6.0alpha-20582-64bit.zip 100 | INFO:root: 20519402/20519402 (100%) http://files.squeak.org/6.0alpha/Squeak6.0alpha-20582-64bit/Squeak6.0alpha-20582-64bit.zip 101 | INFO:root: 51209408/51209408 (100%) extracting: Squeak6.0alpha-20582-64bit.image 102 | INFO:root: 20374964/20374964 (100%) extracting: Squeak6.0alpha-20582-64bit.changes 103 | INFO:root: --- 2d3e365261fa70f3ae6b 104 | INFO:root:Running: 105 | World submorphs 106 | select: [:m | 107 | (m isKindOf: PreferenceWizardMorph) or: 108 | [m isSystemWindow and: [m label beginsWith: 'Welcome to Squeak']]] 109 | thenDo: [:m | m delete]. 110 | INFO:root: execution: 0.249 seconds 111 | INFO:root: 58630352/58630352 (100%) archiving: squeak.image 112 | INFO:root: 20376501/20376501 (100%) archiving: squeak.changes 113 | INFO:root: archiving: 2.182 seconds 114 | INFO:root: 58630352/58630352 (100%) extracting: squeak.image 115 | INFO:root: 20376501/20376501 (100%) extracting: squeak.changes 116 | INFO:root: --- a65c4397156194b571d7 117 | INFO:root:Running: 118 | "Tony's preferences!" 119 | CommunityTheme createDark apply. 120 | Model useColorfulWindows: true. 121 | [Preferences setDemoFonts] valueSupplyingAnswer: true. 122 | Cursor useBiggerCursors: true. 123 | Preferences enable: #mouseOverForKeyboardFocus. 124 | TextEditor autoEnclose: false. 125 | Model windowActiveOnFirstClick: true. 126 | INFO:root: execution: 0.619 seconds 127 | INFO:root: 62905552/62905552 (100%) archiving: squeak.image 128 | INFO:root: 20378159/20378159 (100%) archiving: squeak.changes 129 | INFO:root: archiving: 2.289 seconds 130 | INFO:root: 62905552/62905552 (100%) extracting: squeak.image 131 | INFO:root: 20378159/20378159 (100%) extracting: squeak.changes 132 | INFO:root: --- e80909c4f1f2f14324b2 133 | INFO:root:Running: 134 | MCMcmUpdater default doUpdate: false. 135 | 136 | MethodNode>>asColorizedSmalltalk80Text (TextStyler is Undeclared) 137 | ========== Update completed: 20582 -> 20678 ========== 138 | INFO:root: execution: 22.103 seconds 139 | INFO:root: 57047392/57047392 (100%) archiving: squeak.image 140 | INFO:root: 20540949/20540949 (100%) archiving: squeak.changes 141 | INFO:root: archiving: 2.135 seconds 142 | INFO:root: 57047392/57047392 (100%) extracting: squeak.image 143 | INFO:root: 20540949/20540949 (100%) extracting: squeak.changes 144 | INFO:root: --- 9e4bcce29c3dba7dd48e 145 | INFO:root:Running: 146 | Metacello new configuration: 'FFI'; load. 147 | Installer ss project: 'OSProcess'; install: 'OSProcess'. 148 | ⋮ 149 | (output from Metacello and OSProcess installation elided) 150 | ⋮ 151 | loaded 152 | INFO:root: execution: 45.081 seconds 153 | INFO:root: 69034504/69034504 (100%) archiving: squeak.image 154 | INFO:root: 24673049/24673049 (100%) archiving: squeak.changes 155 | INFO:root: archiving: 2.535 seconds 156 | INFO:root:Tagging 4c8767963a0bc6ce727b as 'myimage' 157 | 4c8767963a0bc6ce727bbbdb787e7a51c36fe27fff53dfcd4e84a8f4ad13872c858e4351262ba00d8d649bf474e28e515816a0774a8a30fc4c88039985e6b4b6 158 | 159 | $ 160 | ``` 161 | 162 | ## Running images 163 | 164 | Now I can get a transient, disposable image in a temporary directory 165 | which will be deleted when the VM process terminates, by running 166 | 167 | ``` 168 | $ squeaker run myimage 169 | INFO:root:Image: 4c8767963a0bc6ce727bbbdb787e7a51c36fe27fff53dfcd4e84a8f4ad13872c858e4351262ba00d8d649bf474e28e515816a0774a8a30fc4c88039985e6b4b6 170 | INFO:root: 69034504/69034504 (100%) extracting: squeak.image 171 | INFO:root: 24673049/24673049 (100%) extracting: squeak.changes 172 | ``` 173 | 174 | ![Screenshot of the running `myimage`](./Screenshot.png) 175 | 176 | If I want to keep this image, I have to take care to `Save as...` to 177 | some other directory! 178 | 179 | ## Garbage collection 180 | 181 | During development, and after a bit of normal usage, you'll find you 182 | have a lot of unwanted intermediary images saved in the cache. You can 183 | discard those not reachable from some tag using `squeaker gc`. 184 | 185 | Use `squeaker gc --dry-run` to get a description of what will be 186 | deleted, without deleting anything. 187 | 188 | You can also get a picture of the (live) contents of your cache by 189 | running the [Graphviz](https://graphviz.org/) 190 | [dot](https://graphviz.org/doc/info/lang.html) tool over the output of 191 | the `squeaker dot` command. 192 | 193 | Here's the kind of thing it looks like: 194 | 195 | ![Example graph drawn by Graphviz dot](./example-graph.png) 196 | 197 | ## Chunk format 198 | 199 | "Chunk" format ([1][chunks1], [2][chunks2]) is an old, old 200 | Smalltalkism. It's a *binary* file, using byte 0x21 (`!`) as 201 | delimiter. Exclamation points are *doubled* to escape them: so the 202 | chunk containing the text `Hello! How are you?` is written 203 | 204 | Hello!! How are you?! 205 | 206 | The final `!` terminates the chunk; the embedded `!!` decodes to a 207 | single `!`. 208 | 209 | [chunks1]: http://wiki.squeak.org/squeak/1105 210 | [chunks2]: https://live.exept.de/doc/online/english/programming/fileoutFormat.html 211 | 212 | ## License and Copyright 213 | 214 | ([MIT](https://opensource.org/licenses/MIT).) 215 | 216 | Copyright 2021 Tony Garnock-Jones 217 | 218 | Permission is hereby granted, free of charge, to any person obtaining 219 | a copy of this software and associated documentation files (the 220 | "Software"), to deal in the Software without restriction, including 221 | without limitation the rights to use, copy, modify, merge, publish, 222 | distribute, sublicense, and/or sell copies of the Software, and to 223 | permit persons to whom the Software is furnished to do so, subject to 224 | the following conditions: 225 | 226 | The above copyright notice and this permission notice shall be 227 | included in all copies or substantial portions of the Software. 228 | 229 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 230 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 231 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 232 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 233 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 234 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 235 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 236 | 237 | -------------------------------------------------------------------------------- /squeaker: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright 2021 Tony Garnock-Jones 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining 6 | # a copy of this software and associated documentation files (the 7 | # "Software"), to deal in the Software without restriction, including 8 | # without limitation the rights to use, copy, modify, merge, publish, 9 | # distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so, subject to 11 | # the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | from pathlib import Path 25 | from urllib.parse import urlparse 26 | from urllib.request import Request, urlopen 27 | import argparse 28 | import datetime 29 | import glob 30 | import hashlib 31 | import json 32 | import math 33 | import os 34 | import shutil 35 | import subprocess 36 | import sys 37 | import tempfile 38 | import time 39 | import zipfile 40 | 41 | import logging 42 | 43 | def ensuredir(*pieces): 44 | p = os.path.join(*pieces) 45 | Path(p).mkdir(parents=True, exist_ok=True) 46 | return p 47 | 48 | def cachedir(): 49 | base = os.environ.get('XDG_CACHE_HOME', os.path.join(Path.home(), '.cache')) 50 | return os.path.join(base, 'squeaker') 51 | 52 | def ensurecachedir(*pieces): 53 | return ensuredir(cachedir(), *pieces) 54 | 55 | def digest(s): 56 | return hashlib.sha512(s.encode('utf-8')).hexdigest() 57 | 58 | def digest_file(path): 59 | d = hashlib.sha512() 60 | with open(path, 'rb') as f: 61 | while True: 62 | buf = f.read(524288) 63 | if not buf: 64 | return d.hexdigest() 65 | d.update(buf) 66 | 67 | class DigestWriter: 68 | def __init__(self, d): 69 | self.digest = d 70 | 71 | def write(self, chunk): 72 | self.digest.update(chunk) 73 | 74 | def hexdigest(self): 75 | return self.digest.hexdigest() 76 | 77 | def digest_file_or_url(path): 78 | if urlparse(path).scheme == '': 79 | return digest_file(path) if os.path.exists(path) else None 80 | 81 | req = Request(path) 82 | resp = urlopen(req) 83 | if resp.status >= 200 and resp.status <= 299: 84 | d = DigestWriter(hashlib.sha512()) 85 | copy_with_progress(expected_str=resp.headers['content-length'], from_fh=resp, to_fh=d, extra=f' checksumming {path}') 86 | return d.hexdigest() 87 | elif resp.status == 404: 88 | return None 89 | else: 90 | raise Exception(f'Could not checksum {path}: HTTP response code {resp.status}:\n{resp.headers}') 91 | 92 | def digest_digests(digests): 93 | d = hashlib.sha512() 94 | for item in digests: 95 | d.update(bytearray.fromhex(item)) 96 | return d.hexdigest() 97 | 98 | def digest_stage(stage_type, stage_key): 99 | return digest(f'{stage_type}\n{stage_key}') 100 | 101 | def copy_with_progress(expected_str, from_fh, to_fh, extra=''): 102 | expected = int(expected_str) if expected_str is not None else None 103 | block_size = math.ceil(expected / 100) if expected is not None else 131072 104 | total = 0 105 | def status(): 106 | return f' {total}/{expected} ({math.floor(100.0 * (total/expected)) if expected is not None else "?"}%){extra}' 107 | while True: 108 | logging.info('\r' + status()) 109 | buf = from_fh.read(block_size) 110 | if not buf: 111 | break 112 | total = total + len(buf) 113 | to_fh.write(buf) 114 | logging.info('\r' + status() + '\n') 115 | 116 | def stage_path(stage_digest): 117 | return os.path.join(ensurecachedir('stages'), stage_digest) 118 | 119 | def load_stage(stage_digest, no_cache=[]): 120 | infopath = stage_path(stage_digest) 121 | logging.debug(f'stage cache check {infopath}') 122 | if os.path.exists(infopath): 123 | with open(infopath, 'rt') as f: 124 | info = json.load(f) 125 | if info['stage_type'] in no_cache: 126 | logging.info(f'Ignoring (and replacing) cache entry for stage {stage_digest[:20]}') 127 | else: 128 | logging.debug(f' cache hit') 129 | return info 130 | else: 131 | logging.debug(f' cache miss') 132 | return None 133 | 134 | def image_path(image_digest): 135 | return os.path.join(ensurecachedir('images'), image_digest) 136 | 137 | def stage_lookup(no_cache, stage_type, stage_key_fn, if_absent, extra_fn): 138 | logging.debug(f'stage_lookup of type {repr(stage_type)}') 139 | info = load_stage(digest_stage(stage_type, stage_key_fn()), no_cache) 140 | if info is not None: 141 | return info 142 | 143 | with tempfile.NamedTemporaryFile(prefix='squeaker-stage-') as output: 144 | image_digest = if_absent(output) 145 | if image_digest is None: 146 | output.flush() 147 | image_digest = digest_file(output.name) 148 | shutil.copyfile(output.name, image_path(image_digest)) 149 | 150 | final_stage_key = stage_key_fn() 151 | final_stage_digest = digest_stage(stage_type, final_stage_key) 152 | 153 | info = { 154 | 'image_digest': image_digest, 155 | 'stage_digest': final_stage_digest, 156 | 'stage_type': stage_type, 157 | 'stage_key': final_stage_key, 158 | } 159 | for (k, v) in extra_fn().items(): 160 | info[k] = v 161 | 162 | with open(stage_path(final_stage_digest), 'wt') as f: 163 | json.dump(info, f, indent=2) 164 | 165 | return info 166 | 167 | def download(no_cache, url): 168 | def if_absent(output): 169 | req = Request(url) 170 | if req.type == 'file': 171 | path = req.selector 172 | with open(path, 'rb') as from_fh: 173 | logging.info(f'Copying local file {repr(path)} into cache') 174 | copy_with_progress(expected_str=str(os.path.getsize(path)), from_fh=from_fh, to_fh=output, extra=' ' + path) 175 | else: 176 | resp = urlopen(req) 177 | if resp.status >= 200 and resp.status <= 299: 178 | logging.info(f'Downloading {url}') 179 | copy_with_progress(expected_str=resp.headers['content-length'], from_fh=resp, to_fh=output, extra=' ' + url) 180 | else: 181 | raise Exception(f'Could not retrieve {url}: HTTP response code {resp.status}:\n{resp.headers}') 182 | return stage_lookup(no_cache, 'url', lambda: url, if_absent, lambda: { 183 | 'url': url 184 | }) 185 | 186 | def tag_path(tag): 187 | return os.path.join(ensurecachedir('tags'), tag) 188 | 189 | def load_tag(tag, missing_ok=False): 190 | path = tag_path(tag) 191 | if os.path.exists(path): 192 | with open(path, 'rt') as f: 193 | return json.load(f) 194 | if missing_ok: 195 | return None 196 | raise Exception(f'Could not load tag {repr(tag)}') 197 | 198 | def unambiguous_prefix(p): 199 | if os.path.exists(p): 200 | return p 201 | matches = glob.glob(p + '*') 202 | if len(matches) > 1: 203 | raise Exception(f'Ambiguous filename prefix ({len(matches)} candidates found): {p}') 204 | if len(matches) == 1: 205 | return matches[0] 206 | return None 207 | 208 | def resolve_snapshot_name(image): 209 | info = load_tag(image, missing_ok=True) 210 | if info is None: 211 | path = unambiguous_prefix(image_path(image)) 212 | if path is not None: 213 | info = {'image_digest': os.path.basename(path)} 214 | if info is None: 215 | raise Exception(f'Could not resolve tag or image {repr(image)}') 216 | return info 217 | 218 | def pretty_stage(stage): 219 | return stage['image_digest'][:20] 220 | 221 | def write_tag(stage, tag, extra): 222 | logging.info(f'Tagging {pretty_stage(stage)} as {repr(tag)}') 223 | info = { 224 | 'stage_digest': stage['stage_digest'], 225 | 'image_digest': stage['image_digest'], 226 | 'tag': tag, 227 | } 228 | for (k, v) in extra.items(): 229 | info[k] = v 230 | with open(tag_path(tag), 'wt') as f: 231 | json.dump(info, f, indent=2) 232 | 233 | def extract_with_progress(z, entryname, targetname): 234 | if os.path.exists(targetname): 235 | logging.warning(f'{targetname} exists, not overwriting') 236 | return 237 | info = z.getinfo(entryname) 238 | with z.open(entryname, 'r') as from_fh: 239 | with open(targetname, 'wb') as to_fh: 240 | copy_with_progress(str(info.file_size), from_fh=from_fh, to_fh=to_fh, extra=' extracting: ' + entryname) 241 | 242 | def archive_with_progress(z, sourcename, entryname): 243 | with open(sourcename, 'rb') as from_fh: 244 | with z.open(entryname, 'w') as to_fh: 245 | copy_with_progress(str(os.path.getsize(sourcename)), from_fh=from_fh, to_fh=to_fh, extra=' archiving: ' + entryname) 246 | 247 | def unlink_missing_ok(path): 248 | try: 249 | os.unlink(path) 250 | except FileNotFoundError: 251 | pass 252 | 253 | def ensure_image_present(info, build_args): 254 | path = image_path(info['image_digest']) 255 | if os.path.exists(path): 256 | logging.debug(f'Image exists for stage {info.get("stage_digest", "???")[:20]} at {path}') 257 | return info 258 | 259 | if build_args is None: 260 | raise Exception(f'Cannot find image {repr(path)}') 261 | 262 | logging.info(f'Rebuilding image for stage {info["stage_digest"][:20]}') 263 | 264 | unlink_missing_ok(stage_path(info['stage_digest'])) 265 | 266 | desired_stage_type = info['stage_type'] 267 | if desired_stage_type == 'url': 268 | return download(build_args.no_cache, info['url']) 269 | elif desired_stage_type == 'stage': 270 | parent_info = load_stage(info['parent']) 271 | if parent_info is None: 272 | raise Exception(f'Cannot find stage {info["parent"][:20]}') 273 | return apply_chunk(build_args, parent_info, info['chunk']) 274 | elif desired_stage_type == 'resource': 275 | parent_info = load_stage(info['parent']) 276 | if parent_info is None: 277 | raise Exception(f'Cannot find stage {info["parent"][:20]}') 278 | return depend_on_resource(build_args, parent_info, info['resource_path']) 279 | else: 280 | raise Exception(f'Unknown stage_type {desired_stage_type}') 281 | 282 | def prepare_base(info, build_args): 283 | info = ensure_image_present(info, build_args) 284 | path = image_path(info['image_digest']) 285 | z = zipfile.ZipFile(path) 286 | names = z.namelist() 287 | imagename = next((n for n in names if n.endswith('.image')), None) 288 | if imagename is None: 289 | raise Exception(f'Base image zip file does not include any *.image files') 290 | changesname = imagename[:-6] + '.changes' 291 | if changesname not in names: 292 | raise Exception(f'Base image zip file contains image {repr(imagename)} but not {repr(changesname)}') 293 | extract_with_progress(z, imagename, 'squeak.image') 294 | extract_with_progress(z, changesname, 'squeak.changes') 295 | return info 296 | 297 | def report_time(label, action): 298 | start_time = time.monotonic() 299 | result = action() 300 | end_time = time.monotonic() 301 | logging.info(f' {label}: {round(end_time - start_time, 3)} seconds') 302 | return result 303 | 304 | def escape_str(chunk): 305 | return "'" + chunk.replace("!", "!!").replace("'", "''") + "'" 306 | 307 | def incorporate_chunk(args, chunk): 308 | script_body = f''' 309 | [ 310 | | oldRedirect errFile outFile buildDir logAndQuit | 311 | 312 | "macOS stderr etc. doesn't work (2024-07-08) so use real files instead" 313 | errFile := FileStream forceNewFileNamed: 'errors.txt'. 314 | outFile := FileStream forceNewFileNamed: 'output.txt'. 315 | oldRedirect := TranscriptStream redirectToStdOut. 316 | FileStream stderr become: errFile. 317 | FileStream stdout become: outFile. 318 | 319 | buildDir := FileStream detectFile: [FileStream readOnlyFileNamed: 'squeakerDirectory'] do: [:f | f upToEnd]. 320 | FileDirectory setDefaultDirectory: buildDir. 321 | 322 | logAndQuit := [:exn | 323 | FileStream stderr nextPut: Character cr. 324 | exn printVerboseOn: FileStream stderr. 325 | FileStream stderr flush. 326 | Smalltalk snapshot: false andQuitWithExitCode: 1]. 327 | 328 | [ 329 | ( 330 | [ 331 | [ 332 | Compiler evaluate: {escape_str('['+chunk+']')} 333 | ] on: SyntaxErrorNotification do: logAndQuit 334 | ] on: UndeclaredVariableWarning do: [:w | 335 | w defaultAction. 336 | Smalltalk snapshot: false andQuitWithExitCode: 1 337 | ] 338 | ) value 339 | ] on: UnhandledError do: [:exn | logAndQuit value: exn exception]. 340 | 341 | Transcript flush. 342 | FileStream stderr become: errFile. errFile flush; close. 343 | FileStream stdout become: outFile. outFile flush; close. 344 | TranscriptStream redirectToStdOut: oldRedirect. 345 | 346 | Smalltalk garbageCollect; snapshot: true andQuit: true. 347 | ] forkAt: Processor lowestPriority + 1 "plus one to avoid having the idle process starve us". 348 | ''' 349 | with open('squeakerDirectory', 'wt') as squeakerDirectory: 350 | squeakerDirectory.write(args.directory) 351 | try: 352 | with tempfile.NamedTemporaryFile(prefix='chunk-', suffix='.st') as script: 353 | script.write(script_body.encode('utf-8')) 354 | script.flush() 355 | c = subprocess.run( 356 | [args.vm, *([args.vm_headless_flag] if args.headless else []), 'squeak.image', script.name], 357 | check=True) 358 | finally: 359 | with open('errors.txt', 'rt') as f: 360 | errors = f.read() 361 | if errors: 362 | logging.error(errors.replace('\r', '\n')) 363 | with open('output.txt', 'rt') as f: 364 | output = f.read() 365 | if output: 366 | logging.info(output.replace('\r', '\n')) 367 | 368 | def make_archive(output): 369 | with zipfile.ZipFile(output, mode='w', compression=zipfile.ZIP_DEFLATED) as z: 370 | archive_with_progress(z, 'squeak.image', 'squeak.image') 371 | archive_with_progress(z, 'squeak.changes', 'squeak.changes') 372 | 373 | def apply_chunk(args, base_stage, chunk): 374 | # 375 | # NOTE: base_stage[0] is *UPDATED* when if_absent is called! 376 | # This makes digest_inputs_fn() yield a different answer, 377 | # which in turn allows us to repair a partially-cached stage-path. 378 | # 379 | base_stage = [base_stage] 380 | 381 | vm_digest = digest(args.vm) 382 | chunk_digest = digest(chunk) 383 | digest_inputs_fn = lambda: [base_stage[0]['stage_digest'], 384 | base_stage[0]['image_digest'], 385 | vm_digest, 386 | chunk_digest] 387 | was_cached = [True] 388 | def if_absent(output): 389 | was_cached[0] = False 390 | with tempfile.TemporaryDirectory(prefix='squeaker-build-') as builddirname: 391 | os.chdir(builddirname) 392 | base_stage[0] = prepare_base(base_stage[0], build_args=args) 393 | logging.info(f' --- {pretty_stage(base_stage[0])}') 394 | logging.info(('Running:\n' + chunk.replace('\r', '\n')).replace('\n', '\n ')) 395 | report_time('execution', lambda: incorporate_chunk(args, chunk)) 396 | report_time('archiving', lambda: make_archive(output)) 397 | os.chdir(args.directory) 398 | info = stage_lookup(args.no_cache, 'stage', lambda: digest_digests(digest_inputs_fn()), if_absent, lambda: { 399 | 'parent': base_stage[0]['stage_digest'], 400 | 'digest_inputs': digest_inputs_fn(), 401 | 'vm': args.vm, 402 | 'chunk': chunk, 403 | }) 404 | if was_cached and args.verbose > 0: 405 | logging.info(f' >>> image {pretty_stage(info)}, stage {info["stage_digest"][:20]}') 406 | logging.info((' is the cached result of command(s):\n' + chunk.replace('\r', '\n')).replace('\n', '\n ')) 407 | return info 408 | 409 | def depend_on_resource(args, base_stage, resource_path): 410 | # 411 | # NOTE: base_stage[0] is *UPDATED* when if_absent is called! 412 | # This makes digest_inputs_fn() yield a different answer, 413 | # which in turn allows us to repair a partially-cached stage-path. 414 | # 415 | base_stage = [base_stage] 416 | 417 | resource_digest = digest_file_or_url(resource_path) 418 | digest_inputs_fn = lambda: [base_stage[0]['stage_digest'], 419 | base_stage[0]['image_digest'], 420 | *([resource_digest] if resource_digest else [])] 421 | 422 | was_cached = [True] 423 | def if_absent(output): 424 | was_cached[0] = False 425 | base_stage[0] = ensure_image_present(base_stage[0], args) 426 | logging.info(f' --- {pretty_stage(base_stage[0])}') 427 | logging.info(f'Resource digest {resource_digest[:20] if resource_digest else "(absent)"} for {resource_path}') 428 | return base_stage[0]['image_digest'] 429 | def mk_extra(): 430 | extra = { 431 | 'parent': base_stage[0]['stage_digest'], 432 | 'digest_inputs': digest_inputs_fn(), 433 | 'resource_path': resource_path, 434 | } 435 | if resource_digest: 436 | extra['resource_digest'] = resource_digest 437 | return extra 438 | info = stage_lookup(args.no_cache, 'resource', lambda: digest_digests(digest_inputs_fn()), if_absent, mk_extra) 439 | if was_cached and args.verbose > 0: 440 | logging.info(f' >>> image {pretty_stage(info)}, stage {info["stage_digest"][:20]}') 441 | logging.info(f' is the cached result of depending on resource {resource_path}') 442 | return info 443 | 444 | class ChunkReader: 445 | def __init__(self, fh): 446 | self.fh = fh 447 | self.buf = None 448 | 449 | def peek(self): 450 | if self.buf is None: 451 | self.buf = self.fh.read(1) 452 | return self.buf 453 | 454 | def drop(self): 455 | self.buf = None 456 | 457 | def __iter__(self): 458 | return self 459 | 460 | def __next__(self): 461 | chunk = b'' 462 | while self.peek() != b'': 463 | if self.peek() == b'!': 464 | self.drop() 465 | if self.peek() == b'!': 466 | self.drop() 467 | chunk = chunk + b'!' 468 | else: 469 | return chunk.decode('utf-8') 470 | else: 471 | chunk = chunk + self.peek() 472 | self.drop() 473 | if chunk == b'': 474 | raise StopIteration 475 | else: 476 | return chunk.decode('utf-8') 477 | 478 | def lex_string_literal(s): 479 | if not (s[0] == "'" and s[-1] == "'"): 480 | return None 481 | s = s[1:-1] 482 | s = s.replace("''", "'") 483 | return s 484 | 485 | def lex_symbol(s): 486 | if s[0] != '#': 487 | return None 488 | return lex_string_literal(s[1:]) 489 | 490 | def command_build(args): 491 | args.directory = os.path.abspath(args.directory) 492 | os.chdir(args.directory) 493 | 494 | base_stage = None 495 | 496 | with open(args.f, 'rb') as squeakerfile: 497 | for chunk in ChunkReader(squeakerfile): 498 | chunk = chunk.strip() 499 | 500 | if chunk.startswith('from:'): 501 | loc = chunk[5:].strip() 502 | 503 | lit = lex_string_literal(loc) 504 | if lit is not None: 505 | base_stage = download(args.no_cache, lit) 506 | continue 507 | 508 | srctag = lex_symbol(loc) 509 | if srctag is not None: 510 | base_stage = load_tag(srctag) 511 | continue 512 | 513 | raise Exception('Cannot resolve "from:" specifier: ', repr(loc)) 514 | elif chunk.startswith('resource:'): 515 | resource_path = lex_string_literal(chunk[9:].strip()) 516 | if resource_path is not None: 517 | base_stage = depend_on_resource(args, base_stage, resource_path) 518 | continue 519 | raise Exception('Invalid "resource:" chunk: ', repr(chunk)) 520 | elif chunk.startswith('fileIn:'): 521 | literal_path = chunk[7:].strip() 522 | resource_path = lex_string_literal(literal_path) 523 | if resource_path is not None: 524 | base_stage = depend_on_resource(args, base_stage, resource_path) 525 | if not os.path.exists(resource_path): 526 | raise Exception('Missing file in fileIn: ' + resource_path) 527 | base_stage = apply_chunk(args, base_stage, f'Installer installFile: {literal_path}') 528 | continue 529 | raise Exception('Invalid "fileIn:" chunk: ', repr(chunk)) 530 | elif chunk == '': 531 | pass 532 | else: 533 | if base_stage is None: 534 | raise Exception('No "from:" specifier given') 535 | base_stage = apply_chunk(args, base_stage, chunk) 536 | 537 | if base_stage is not None: 538 | base_stage = ensure_image_present(base_stage, build_args=args) 539 | if args.t: 540 | write_tag(base_stage, args.t, {}) 541 | print(base_stage['image_digest']) 542 | 543 | def prune_recent_changes(): 544 | changesdir = ensurecachedir('recentchanges') 545 | changesfiles = os.listdir(changesdir) 546 | changesfiles = sorted(changesfiles) 547 | for filename in changesfiles[:-5]: 548 | unlink_missing_ok(os.path.join(changesdir, filename)) 549 | 550 | def utcstamp(): 551 | # utcnow() is deprecated and scheduled to be removed in some future python 552 | # see https://discuss.python.org/t/deprecating-utcnow-and-utcfromtimestamp/26221/5 553 | # 554 | # return datetime.datetime.utcnow().isoformat(timespec='seconds') + 'Z' 555 | # 556 | d = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) 557 | return d.isoformat(timespec='seconds') + 'Z' 558 | 559 | def command_run(args): 560 | info = resolve_snapshot_name(args.image) 561 | old_cwd = os.getcwd() 562 | with tempfile.TemporaryDirectory(prefix='squeaker-run-') as rundirname: 563 | try: 564 | os.chdir(rundirname) 565 | logging.info(f'Image: {info["image_digest"]}') 566 | prepare_base(info, build_args=None) 567 | subprocess.run( 568 | [ *(['sudo', '--'] if args.root else []), 569 | args.vm, 570 | *([args.vm_headless_flag] if args.headless else []), 571 | 'squeak.image', 572 | *args.args], 573 | check=True) 574 | finally: 575 | try: 576 | n = 'squeak.' + utcstamp() + '.changes' 577 | shutil.move('squeak.changes', os.path.join(ensurecachedir('recentchanges'), n)) 578 | prune_recent_changes() 579 | finally: 580 | os.chdir(old_cwd) 581 | 582 | def all_blobs(dirname): 583 | blobs = [] 584 | for filename in os.listdir(dirname): 585 | with open(os.path.join(dirname, filename), 'rt') as f: 586 | blobs.append(json.load(f)) 587 | return blobs 588 | 589 | def command_gc(args): 590 | root_images = set() 591 | marked_images = set() 592 | marked_stages = set() 593 | 594 | image_info = {} 595 | stage_info = {} 596 | 597 | for info in all_blobs(ensurecachedir('stages')): 598 | image_info.setdefault(info['image_digest'], []).append(info) 599 | stage_info[info['stage_digest']] = info 600 | 601 | def mark_stage(stage_digest, depth): 602 | marked_stages.add(stage_digest) 603 | info = stage_info.get(stage_digest, None) 604 | if info is None: 605 | # TODO: fsck-style warnings here 606 | return 607 | if depth <= args.keep_intermediate_stages: 608 | marked_images.add(info['image_digest']) 609 | if 'parent' in info: 610 | mark_stage(info['parent'], depth + 1) 611 | 612 | for info in all_blobs(ensurecachedir('tags')): 613 | marked_images.add(info['image_digest']) 614 | mark_stage(info['stage_digest'], 0) 615 | 616 | if args.delete_urls is False: 617 | for info in stage_info.values(): 618 | if info['stage_type'] == 'url': 619 | mark_stage(info['stage_digest'], 0) 620 | elif args.delete_urls is True: 621 | for info in stage_info.values(): 622 | if info['stage_type'] == 'url': 623 | if info['stage_digest'] in marked_stages: 624 | marked_images.add(info['image_digest']) 625 | elif args.delete_urls == 'all': 626 | for info in stage_info.values(): 627 | if info['stage_type'] == 'url': 628 | marked_images.discard(info['image_digest']) 629 | else: 630 | raise Exception(f'Invalid delete_urls setting: {repr(args.delete_urls)}') 631 | 632 | all_images = set(os.listdir(ensurecachedir('images'))) 633 | all_stages = set(os.listdir(ensurecachedir('stages'))) 634 | 635 | doomed_images = all_images - marked_images 636 | doomed_stages = all_stages - marked_stages 637 | 638 | logging.info(('Would remove' if args.dry_run else 'Removing') + \ 639 | f' {len(doomed_images)} image(s) and {len(doomed_stages)} stage(s)') 640 | 641 | for i in doomed_images: 642 | logging.info(f' image {i}') 643 | if not args.dry_run: 644 | os.unlink(os.path.join(ensurecachedir('images'), i)) 645 | for s in doomed_stages: 646 | logging.info(f' stage {s}') 647 | if not args.dry_run: 648 | os.unlink(os.path.join(ensurecachedir('stages'), s)) 649 | 650 | def command_tags(args): 651 | for info in all_blobs(ensurecachedir('tags')): 652 | print(info['tag']) 653 | 654 | def command_resolve_tag(args): 655 | print(load_tag(args.tag)['image_digest']) 656 | 657 | def command_dot(args): 658 | print('digraph G {') 659 | for info in all_blobs(ensurecachedir('tags')): 660 | tn = f'"tag_{info["tag"]}"' 661 | print(f' {tn} [shape=octagon, style=filled, fillcolor="#ffccff"];') 662 | print(f' image_{info["image_digest"][:8]} -> {tn};') 663 | stages = dict((s['stage_digest'], s) for s in all_blobs(ensurecachedir('stages'))) 664 | for info in stages.values(): 665 | print(f' stage_{info["stage_digest"][:8]} [shape=note, style=filled, fillcolor="#ffcccc"];') 666 | print(f' image_{info["image_digest"][:8]} [shape=ellipse, style=filled, fillcolor="#ccffcc"];') 667 | print(f' stage_{info["stage_digest"][:8]} -> image_{info["image_digest"][:8]};') 668 | ty = info['stage_type'] 669 | if ty == 'url': 670 | un = f'url_{info["image_digest"][:8]}' 671 | url_label = dotescape(info["url"] + '\n' + info["image_digest"][:20]) 672 | print(f' {un} [shape=box, style=filled, fillcolor="#ccccff", label={url_label}];') 673 | print(f' {un} -> stage_{info["stage_digest"][:8]};') 674 | elif ty == 'stage': 675 | print(f' note_{info["stage_digest"][:8]} [shape=note, style=filled, fillcolor="#ffffaa", label={dotescape(info["chunk"])}];') 676 | print(f' note_{info["stage_digest"][:8]} -> stage_{info["stage_digest"][:8]};') 677 | print(f' image_{stages[info["parent"]]["image_digest"][:8]} -> stage_{info["stage_digest"][:8]};') 678 | print(f' stage_{info["parent"][:8]} -> stage_{info["stage_digest"][:8]};') 679 | elif ty == 'resource': 680 | r_label = dotescape(info["resource_path"] + '\n' + info.get("resource_digest", "(absent)")[:20]) 681 | print(f' note_{info["stage_digest"][:8]} [shape=box, style=filled, fillcolor="#ccccff", label={r_label}];') 682 | print(f' note_{info["stage_digest"][:8]} -> stage_{info["stage_digest"][:8]};') 683 | par = stages.get(info["parent"]) 684 | if par is None: 685 | par_img = f'image_UNKNOWN_from_stage_{info["parent"][:8]}' 686 | else: 687 | par_img = par["image_digest"][:8] 688 | print(f' image_{par_img} -> stage_{info["stage_digest"][:8]};') 689 | print(f' stage_{info["parent"][:8]} -> stage_{info["stage_digest"][:8]};') 690 | else: 691 | pass 692 | print('}') 693 | 694 | def dotescape(s): 695 | s = s.replace('\\', '\\\\') 696 | s = s.replace('\n', '\\l') 697 | s = s.replace('\r', '\\l') 698 | s = s.replace('"', '\\"') 699 | return f'"{s}\\l"' 700 | 701 | def command_create(args): 702 | args.targetdirectory = os.path.abspath(args.targetdirectory) 703 | info = resolve_snapshot_name(args.image) 704 | old_cwd = os.getcwd() 705 | try: 706 | os.chdir(ensuredir(args.targetdirectory)) 707 | logging.info(f'Creating image from {args.image} in {args.targetdirectory}') 708 | prepare_base(info, build_args=None) 709 | finally: 710 | os.chdir(old_cwd) 711 | 712 | def command_untag(args): 713 | for tag in args.tag: 714 | unlink_missing_ok(tag_path(tag)) 715 | 716 | def command_unstage(args): 717 | for digestprefix in args.digest: 718 | path = unambiguous_prefix(stage_path(digestprefix)) 719 | if path is not None: 720 | print(os.path.basename(path)) 721 | unlink_missing_ok(path) 722 | 723 | def command_print_autodetect(args): 724 | print(f'vm-headless-flag\t{args.vm_headless_flag}') 725 | print(f'vm\t{args.vm if hasattr(args, 'vm') else discover_vm()}') 726 | 727 | class CustomHandler(logging.StreamHandler): 728 | def emit(self, record): 729 | if record.msg[0] == '\r': 730 | record.msg = record.msg[1:] 731 | self.stream.write('\r') 732 | if record.msg[-1] == '\n': 733 | self.terminator = '\n' 734 | record.msg = record.msg[:-1] 735 | else: 736 | self.terminator = '' 737 | else: 738 | if self.terminator == '': 739 | self.stream.write('\n') 740 | self.terminator = '\n' 741 | return super().emit(record) 742 | 743 | def discover_vm(): 744 | DEFAULT='squeak' 745 | try: 746 | if sys.platform == 'darwin': 747 | apps = sorted([n for n in os.listdir('/Applications') 748 | if 'squeak' in n.lower() and n.endswith('.app')]) 749 | return '/Applications/' + apps[-1] + '/Contents/MacOS/Squeak' 750 | else: 751 | return DEFAULT 752 | except: 753 | return DEFAULT 754 | 755 | def discover_headless_flag(): 756 | DEFAULT='-vm-display-null' 757 | try: 758 | if sys.platform == 'darwin': 759 | return '-headless' 760 | else: 761 | return DEFAULT 762 | except: 763 | return DEFAULT 764 | 765 | def main(argv): 766 | app_name = os.path.basename(argv[0]) 767 | argv = argv[1:] 768 | 769 | parser = argparse.ArgumentParser(prog=app_name) 770 | parser.add_argument('-d', '--debug', action='store_true', default=False, 771 | help='Enable debug logging') 772 | parser.add_argument('-v', '--verbose', action='count', default=0, 773 | help='Increment verbosity level') 774 | parser.add_argument('--vm-headless-flag', type=str, default=discover_headless_flag(), 775 | help=argparse.SUPPRESS) 776 | parser.set_defaults(handler=lambda args: parser.print_help()) 777 | sp = parser.add_subparsers() 778 | 779 | p = sp.add_parser('build', help='Build image') 780 | p.add_argument('-f', metavar='Squeakerfile.st', type=str, default='Squeakerfile.st', 781 | help='Specify Squeakerfile to use in ') 782 | p.add_argument('-t', metavar='tag', type=str, default=None, 783 | help='Optionally tag the produced image with this name') 784 | p.add_argument('--no-cache-urls', dest='no_cache', action='append_const', const='url', 785 | help='Always redownload from URLs') 786 | p.add_argument('--no-cache-stages', dest='no_cache', action='append_const', const='stage', 787 | help='Always recompute build stages') 788 | p.add_argument('--headless', action='store_true', default=True, 789 | help='Run squeak with a dummy display, not showing the window') 790 | p.add_argument('--no-headless', dest='headless', action='store_false', 791 | help='Run squeak in graphical mode, showing the window') 792 | p.add_argument('--vm', type=str, default=discover_vm(), 793 | help='Specify VM executable name') 794 | p.add_argument('directory', type=str, 795 | help='Directory to build the image in') 796 | p.set_defaults(no_cache=[], handler=command_build) 797 | 798 | p = sp.add_parser('run', help='Run image') 799 | p.add_argument('--vm', type=str, default=discover_vm(), 800 | help='Specify VM executable name') 801 | p.add_argument('--root', action='store_true', default=False, 802 | help='Execute VM within `sudo`') 803 | p.add_argument('--headless', action='store_true', default=False, 804 | help='Run squeak with a dummy display, not showing the window') 805 | p.add_argument('--no-headless', dest='headless', action='store_false', 806 | help='Run squeak in graphical mode, showing the window') 807 | p.add_argument('image') 808 | p.add_argument('args', nargs=argparse.REMAINDER) 809 | p.set_defaults(handler=command_run) 810 | 811 | p = sp.add_parser('gc', help='Garbage-collect images, stages etc.') 812 | p.add_argument('-n', '--dry-run', dest='dry_run', action='store_true', default=False, 813 | help='Show what would be garbage-collected without deleting anything') 814 | p.add_argument('--delete-unreferenced-urls', dest='delete_urls', action='store_true', default=False, 815 | help='Delete unreferenced downloaded image files') 816 | p.add_argument('--delete-all-urls', dest='delete_urls', action='store_const', const='all', 817 | help='Delete all downloaded image files') 818 | p.add_argument('--discard-all-intermediate', dest='keep_intermediate_stages', action='store_const', const=0, default=math.inf, 819 | help='Discard all intermediate stage images') 820 | p.add_argument('--keep-intermediate', metavar='N', dest='keep_intermediate_stages', type=int, 821 | help='Keep most-recent N intermediate stage images for each tag') 822 | p.set_defaults(handler=command_gc) 823 | 824 | p = sp.add_parser('tags', help='List available tagged images') 825 | p.set_defaults(handler=command_tags) 826 | 827 | p = sp.add_parser('resolve-tag', help='Resolve a tagged image to an on-disk path') 828 | p.add_argument('tag') 829 | p.set_defaults(handler=command_resolve_tag) 830 | 831 | p = sp.add_parser('dot', help='Produce graphviz dot description of objects') 832 | p.set_defaults(handler=command_dot) 833 | 834 | p = sp.add_parser('create', help='Create a permanent image from a tag or image digest') 835 | p.add_argument('image') 836 | p.add_argument('targetdirectory') 837 | p.set_defaults(handler=command_create) 838 | 839 | p = sp.add_parser('untag', help='Remove tags') 840 | p.add_argument('tag', nargs='*') 841 | p.set_defaults(handler=command_untag) 842 | 843 | p = sp.add_parser('unstage', help='Remove cached stages') 844 | p.add_argument('digest', nargs='*') 845 | p.set_defaults(handler=command_unstage) 846 | 847 | p = sp.add_parser('print-autodetect', help='Show autodetected settings') 848 | p.set_defaults(handler=command_print_autodetect) 849 | 850 | args = parser.parse_args(argv) 851 | logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO, 852 | handlers=[CustomHandler()]) 853 | try: 854 | args.handler(args) 855 | except Exception as e: 856 | logging.error(str(e), exc_info=e if args.debug else False) 857 | sys.exit(1) 858 | 859 | if __name__=='__main__': 860 | main(sys.argv) 861 | --------------------------------------------------------------------------------