├── LICENSE ├── README.md ├── package.json ├── smfgen └── test ├── assets ├── basic-manifest.json ├── basic-manifest.xml └── nginx-cli-manifest.xml ├── cli-test.js └── module-test.js /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013, Joyent, Inc. All rights reserved. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # smfgen 2 | 3 | This tool generates an SMF manifest from a JSON description of the service. 4 | It's only intended to generate simple manifests. For more details, see _smf(5)_. 5 | 6 | This tool is still experimental. 7 | 8 | [sudo] npm install -g smfgen 9 | 10 | 11 | # JSON Input 12 | 13 | Emits an SMF manifest for the service described by the given JSON: 14 | 15 | | Property | Description 16 | |----------------|---------------------------------------------------------- 17 | | ident | The SMF identifier for the service. The full SMF identifier (FMRI) will be constructed from the category and identifier. 18 | | [category] | Service category (default: "application"). 19 | | label | The human-readable name of the service. 20 | | [dependencies] | Array of service FMRIs that must be online before this service is started. 21 | | start | The method object that describes how to start the service. (See below.) 22 | | [stop] | The method object that describes how to stop the service. (See below.) 23 | | [refresh] | The method object that describes how to refresh the service. (See below.) 24 | | [enabled] | Whether to start the service automatically (default: true) 25 | 26 | Both the `start` and `stop` properties in the JSON should have object values that 27 | describe the context of the method. A method context consists of these 28 | properties: 29 | 30 | | Property | Description 31 | |----------------------|---------------------------------------------------- 32 | | exec | The script to run for this method. Defaults to `:kill` for the `stop` method. This invocation may run the service synchronously or in the background. Note that this mechanism does not allow you to use SMF's built in timeout for service startup, since it doesn't know when the service has actually started. 33 | | [user] | Run the script for this method as this user. 34 | | [group] | Run the script for this method as this group. 35 | | [environment] | A hash of environment variables for this method script. 36 | | [privileges] | An array of RBAC privilege names. This method will be run with this privilege set. See also: _privileges(5)_. 37 | | [working\_directory] | Use this working directory when invoking the method script. 38 | | [timeout] | The number of seconds the method may run before it is considered timed out and aborted. Defaults to 10 for `start` and `refresh` and 30 for `stop`. 39 | 40 | A set of example methods might look like this: 41 | 42 | ```json 43 | { 44 | "start": { 45 | "user": "webservd", "group": "webservd", 46 | "exec": "/opt/pkg/sbin/apachectl start" 47 | }, 48 | "stop": { 49 | "timeout": 120 50 | } 51 | } 52 | ``` 53 | 54 | Note that you may also specify the properties that describe the context of a 55 | method at the top level of the JSON. Top-level properties apply to all 56 | methods, but may also be overridden in a specific method. For example: 57 | 58 | ```json 59 | { 60 | "user": "webservd", "group": "webservd", 61 | "exec": "/opt/pkg/sbin/apachectl %m", 62 | "timeout": 10, 63 | "stop": { 64 | "timeout": 120 65 | } 66 | } 67 | ``` 68 | 69 | For compatibility with earlier versions of smfgen, the program will accept 70 | a string in place of an object for the `start`, `stop`, and `refresh` methods. 71 | This string will be assumed to be the `exec` property of that method. 72 | 73 | 74 | ## Example 75 | 76 | $ cat bapi.json 77 | 78 | ``` json 79 | { 80 | "ident": "bapi", 81 | "label": "Boilerplate API", 82 | "start": "node bapi.js" 83 | } 84 | ``` 85 | 86 | $ ./smfgen < bapi.json 87 | 88 | ``` xml 89 | 90 | 91 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 107 | 108 | 109 | ``` 110 | 111 | # CLI Input 112 | 113 | The following options can be specified over the CLI: 114 | 115 | - `-c`, `--category` becomes `data.category` 116 | - `-d`, `--cwd` becomes `data.working_directory` 117 | - `-D`, `--dependency` appends to `data.dependencies` 118 | - `-e`, `--env` appends to `data.environment` 119 | - `-g`, `--group` becomes `data.group` 120 | - `-i`, `--ident` becomes `data.ident` 121 | - `-l`, `--label` becomes `data.label` 122 | - `-p`, `--privilege` appends to `data.privileges` 123 | - `-r`, `--refresh` becomes `data.refresh.exec` 124 | - `-s`, `--start` becomes `data.start.exec` 125 | - `-S`, `--stop` becomes `data.stop.exec` 126 | - `-t`, `--timeout` becomes `data.timeout` 127 | - `-u`, `--user` becomes `data.user` 128 | 129 | ## Example 130 | 131 | $ ./smfgen -i nginx -l 'NGINX Web Server' \ 132 | -s 'nginx -d' -d /var/www \ 133 | -u nobody -g other \ 134 | -p basic -p net_privaddr \ 135 | -eHOME=/var/tmp -ePATH=/bin:/usr/bin 136 | 137 | ``` xml 138 | 139 | 140 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 181 | 182 | 183 | ``` 184 | 185 | # Assumptions 186 | 187 | This tool makes a ton of assumptions about your service: 188 | 189 | * Your service is a contract-model service in SMF. That means SMF should consider the service failed (and potentially restart it) if: 190 | * all processes in the service exit, OR 191 | * any process in the service produces a core dump, OR 192 | * any process outside the service sends any service process a fatal signal 193 | See _svc.startd(1M)_ for details. 194 | * Your service is an application which depends on system services like the filesystem and network. This tool wouldn't work for system services implementing any of that functionality. 195 | * If you specify any additional dependencies (like other services of yours), that means your service should not be started until those other services are online. However, if those services restart, your service will not be restarted. 196 | * You only intend to have one instance of your service. 197 | * SMF provides a mechanism for timing out the "start" operation. But for simplicity, this tool always runs your start script in the background, so as far as SMF sees it starts almost instantly. If you want to detect "start" timeout, you must implement a start method that returns exactly when your program has started providing service (e.g., opened its server socket), and you'll have to write your own manifest rather than use this tool. 198 | * By default, the "stop" method just kills all processes in this service, which includes all processes forked by the initial "start" script. You can override this with a "stop" script, but you should use the default if that script is only going to kill processes. There's a default 30 second timeout on the stop script, so the processes must exit within about 30 seconds of receiving the signal. 199 | * The service does not use SMF to store configuration properties. 200 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "smfgen", 3 | "version": "1.2.0", 4 | "description": "Generate an SMF manifest from a JSON or CLI description of the service", 5 | "author": "Dave Pacheco ", 6 | "repository": { 7 | "url": "https://github.com/joyent/smfgen", 8 | "type": "git" 9 | }, 10 | "main": "./smfgen", 11 | "bin": { 12 | "smfgen": "./smfgen" 13 | }, 14 | "license": "MIT", 15 | "keywords": [ 16 | "illumos", 17 | "smartos", 18 | "smf", 19 | "smfgen" 20 | ], 21 | "dependencies": { 22 | "assert-plus": "^1.0.0", 23 | "posix-getopt": "^1.2.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /smfgen: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /* 4 | * smfgen: generates an SMF manifest from a JSON description of the service. See 5 | * emitManifest below for details. 6 | * 7 | * This tool is still experimental. It's only intended to generate simple 8 | * manifests. For more details, see smf(5). 9 | */ 10 | 11 | var util = require('util'); 12 | 13 | var assert = require('assert-plus'); 14 | 15 | var PROGNAME = require('./package').name; 16 | 17 | var HEADER_COMMENT = [ 18 | '', 19 | util.format(' Manifest automatically generated by %s.', PROGNAME), 20 | '' 21 | ].join('\n'); 22 | 23 | function checkAndApplyContext(prefix, conf, applyto) 24 | { 25 | var s = (prefix ? prefix + '.' : ''); 26 | 27 | if (conf.hasOwnProperty('user')) { 28 | assert.string(conf.user, s + 'user'); 29 | applyto.user = conf.user; 30 | } 31 | 32 | if (conf.hasOwnProperty('group')) { 33 | assert.string(conf.group, s + 'group'); 34 | applyto.group = conf.group; 35 | } 36 | 37 | if (conf.hasOwnProperty('environment')) { 38 | assert.object(conf.environment, s + 'environment'); 39 | applyto.environment = conf.environment; 40 | } 41 | 42 | if (conf.hasOwnProperty('privileges')) { 43 | assert.arrayOfString(conf.privileges, s + 'privileges'); 44 | applyto.privileges = conf.privileges; 45 | } 46 | 47 | if (conf.hasOwnProperty('working_directory')) { 48 | assert.string(conf.working_directory, s + 'working_directory'); 49 | applyto.working_directory = conf.working_directory; 50 | } 51 | 52 | if (conf.hasOwnProperty('exec')) { 53 | assert.string(conf.exec, s + 'exec'); 54 | applyto.exec = conf.exec; 55 | } 56 | 57 | if (conf.hasOwnProperty('timeout')) { 58 | assert.number(conf.timeout, s + 'timeout'); 59 | applyto.timeout = conf.timeout; 60 | } 61 | 62 | if (conf.hasOwnProperty('enabled')) { 63 | assert.bool(conf.enabled, s + 'enabled'); 64 | applyto.enabled = conf.enabled; 65 | } 66 | } 67 | 68 | function shouldEmitMethodContext(conf) 69 | { 70 | var lookfor = ['user', 'group', 'privileges', 'environment', 71 | 'working_directory']; 72 | for (var i = 0; i < lookfor.length; i++) { 73 | if (conf.hasOwnProperty(lookfor[i])) 74 | return (true); 75 | } 76 | return (false); 77 | } 78 | 79 | function emitMethodContext(xml, conf) 80 | { 81 | var attrs = {}; 82 | if (conf.working_directory) 83 | attrs.working_directory = conf.working_directory; 84 | xml.emitStart('method_context', attrs); 85 | 86 | attrs = {}; 87 | if (conf.user) 88 | attrs.user = conf.user; 89 | if (conf.group) 90 | attrs.group = conf.group; 91 | if (conf.privileges) 92 | attrs.privileges = conf.privileges.join(','); 93 | if (Object.keys(attrs).length > 0) 94 | xml.emitEmpty('method_credential', attrs); 95 | 96 | if (conf.environment) { 97 | xml.emitStart('method_environment'); 98 | Object.keys(conf.environment).forEach(function (k) { 99 | var v = conf.environment[k]; 100 | xml.emitEmpty('envvar', { name: k, value: v }); 101 | }); 102 | xml.emitEnd('method_environment'); 103 | } 104 | 105 | xml.emitEnd('method_context'); 106 | } 107 | 108 | function emitMethod(xml, conf) 109 | { 110 | var attrs = { 111 | type: 'method', 112 | name: conf.name, 113 | exec: conf.exec, 114 | timeout_seconds: conf.timeout 115 | }; 116 | if (shouldEmitMethodContext(conf)) { 117 | xml.emitStart('exec_method', attrs); 118 | emitMethodContext(xml, conf); 119 | xml.emitEnd('exec_method'); 120 | } else { 121 | xml.emitEmpty('exec_method', attrs); 122 | } 123 | } 124 | 125 | /* 126 | * Emit an SMF manifest for the service described by the given JSON. The 127 | * expected structure of the JSON stream is specified in README.md. 128 | * 129 | * Note that for backwards compatibility we also accept a string in place of 130 | * the method object for 'start', 'stop', and 'refresh', which is assumed to be 131 | * the 'exec' value for that method. 132 | */ 133 | function emitManifest(stream, conf) 134 | { 135 | var xml = new XmlEmitter(stream); 136 | var deps = [ 'svc:/milestone/multi-user:default' ]; 137 | var fmri, i; 138 | 139 | /* default values for start/stop methods */ 140 | var start = { name: 'start', timeout: 10 }; 141 | var stop = { name: 'stop', timeout: 30, exec: ':kill' }; 142 | var refresh = { name: 'refresh', timeout: 10, exec: ':true' }; 143 | 144 | assert.string(conf.ident, 'ident'); 145 | assert.string(conf.label, 'label'); 146 | 147 | /* 148 | * Apply the global versions of method context and exec from the root 149 | * of the config object first, then override them if more specific 150 | * options exist for a particular method. 151 | */ 152 | checkAndApplyContext(null, conf, start); 153 | checkAndApplyContext(null, conf, stop); 154 | checkAndApplyContext(null, conf, refresh); 155 | 156 | if (conf.hasOwnProperty('start')) { 157 | if (typeof (conf.start) === 'string') { 158 | start.exec = conf.start; 159 | } else { 160 | checkAndApplyContext('start', conf.start, start); 161 | } 162 | } 163 | assert.string(start.exec, 'start.exec'); 164 | start.exec += ' &'; 165 | 166 | if (conf.hasOwnProperty('stop')) { 167 | if (typeof (conf.stop) === 'string') 168 | stop.exec = conf.stop; 169 | else 170 | checkAndApplyContext('stop', conf.stop, stop); 171 | } 172 | 173 | if (conf.hasOwnProperty('refresh')) { 174 | if (typeof (conf.refresh) === 'string') 175 | refresh.exec = conf.refresh; 176 | else 177 | checkAndApplyContext('refresh', conf.refresh, refresh); 178 | } 179 | 180 | if (conf.hasOwnProperty('dependencies')) { 181 | assert.arrayOfString(conf.dependencies, 'dependencies'); 182 | conf.dependencies.forEach(function (dep) { 183 | deps.push(dep); 184 | }); 185 | } 186 | 187 | assert.optionalString(conf.category, 'category'); 188 | fmri = util.format('%s/%s', 189 | (conf.category || 'application'), conf.ident); 190 | 191 | xml.emitDoctype('service_bundle', 'SYSTEM', 192 | '/usr/share/lib/xml/dtd/service_bundle.dtd.1'); 193 | xml.emitComment(HEADER_COMMENT); 194 | 195 | xml.emitStart('service_bundle', { 196 | 'type': 'manifest', 197 | /* JSSTYLED */ 198 | 'name': fmri.replace(/\//g, '-') 199 | }); 200 | 201 | xml.emitStart('service', { 202 | 'name': fmri, 203 | 'type': 'service', 204 | 'version': '1' 205 | }); 206 | 207 | var enabled = conf.hasOwnProperty('enabled') ? conf.enabled : true; 208 | xml.emitEmpty('create_default_instance', { 'enabled': enabled }); 209 | 210 | i = 0; 211 | deps.forEach(function (dep) { 212 | xml.emitStart('dependency', { 213 | 'name': 'dep' + i++, 214 | 'grouping': 'require_all', 215 | 'restart_on': 'error', 216 | 'type': 'service' 217 | }); 218 | 219 | xml.emitEmpty('service_fmri', { 'value': dep }); 220 | xml.emitEnd('dependency'); 221 | }); 222 | 223 | emitMethod(xml, start); 224 | emitMethod(xml, stop); 225 | if (conf.hasOwnProperty('refresh')) 226 | emitMethod(xml, refresh); 227 | 228 | xml.emitStart('template'); 229 | xml.emitStart('common_name'); 230 | xml.emitStart('loctext', { 231 | 'xml:lang': 'C' 232 | }, { 'bare': true }); 233 | xml.emitCData(conf['label']); 234 | xml.emitEnd('loctext', { 'bare': true }); 235 | xml.emitEnd('common_name'); 236 | xml.emitEnd('template'); 237 | 238 | xml.emitEnd('service'); 239 | xml.emitEnd('service_bundle'); 240 | } 241 | 242 | /* 243 | * Basic interface for emitting well-formed XML. This isn't bulletproof, but it 244 | * does escape values (not tags or keys) and checks for basic errors. 245 | */ 246 | function XmlEmitter(stream) 247 | { 248 | this.xe_stream = stream; 249 | this.xe_stack = []; 250 | } 251 | 252 | XmlEmitter.prototype.emitDoctype = function (name, type, path) 253 | { 254 | this.xe_stream.write('\n'); 255 | this.xe_stream.write('\n'); 257 | }; 258 | 259 | XmlEmitter.prototype.escape = function (str) 260 | { 261 | /* BEGIN JSSTYLED */ 262 | return (str.toString().replace(/&/g, '&'). 263 | replace(//g, '>'). 265 | replace(/"/g, '"')); 266 | /* END JSSTYLED */ 267 | }; 268 | 269 | XmlEmitter.prototype.emitIndent = function () 270 | { 271 | var str = ''; 272 | var i; 273 | 274 | for (i = 0; i < this.xe_stack.length; i++) 275 | str += ' '; 276 | 277 | this.xe_stream.write(str); 278 | }; 279 | 280 | XmlEmitter.prototype.emitEmpty = function (name, attrs) 281 | { 282 | this.emitIndent(); 283 | this.xe_stream.write('<' + name + ' '); 284 | this.emitAttrs(attrs); 285 | this.xe_stream.write('/>\n'); 286 | }; 287 | 288 | XmlEmitter.prototype.emitAttrs = function (attrs) 289 | { 290 | var key; 291 | 292 | if (!attrs) 293 | return; 294 | 295 | for (key in attrs) 296 | this.xe_stream.write(key + '=\"' + 297 | this.escape(attrs[key]) + '\" '); 298 | }; 299 | 300 | XmlEmitter.prototype.emitStart = function (name, attrs, opts) 301 | { 302 | this.emitIndent(); 303 | this.xe_stack.push(name); 304 | 305 | this.xe_stream.write('<' + name + ' '); 306 | this.emitAttrs(attrs); 307 | this.xe_stream.write('>'); 308 | 309 | if (!opts || !opts['bare']) 310 | this.xe_stream.write('\n'); 311 | }; 312 | 313 | XmlEmitter.prototype.emitEnd = function (name, opts) 314 | { 315 | var check = this.xe_stack.pop(); 316 | 317 | assert.equal(name, check); 318 | 319 | if (!opts || !opts['bare']) 320 | this.emitIndent(); 321 | 322 | this.xe_stream.write('\n'); 323 | }; 324 | 325 | XmlEmitter.prototype.emitCData = function (data) 326 | { 327 | this.xe_stream.write(this.escape(data)); 328 | }; 329 | 330 | XmlEmitter.prototype.emitComment = function (content) 331 | { 332 | this.xe_stream.write('\n'); 333 | }; 334 | 335 | function usage() 336 | { 337 | var msg = [ 338 | util.format('Usage: %s [options]', PROGNAME), 339 | '', 340 | 'Generate an SMF manifest from a JSON or CLI description of the service', 341 | '', 342 | 'Examples', 343 | ' Create a service from a JSON file:', 344 | ' $ cat service.json | smfgen', 345 | '', 346 | ' Create a service from CLI arguments:', 347 | ' $ smfgen -i my-service -l "My Service" -s /opt/custom/bin/my-service', 348 | '', 349 | 'Options', 350 | ' -c, --category service category, defaults to "application"', 351 | ' -d, --cwd the directory to use when executing methods', 352 | ' -D, --dependency service dependency, multiple arguments allowed', 353 | ' -e, --env a single env var to use in the form of KEY=value, multiple arguments allowed', 354 | ' -g, --group group to use when executing methods', 355 | ' -h, --help print this usage message and exit', 356 | ' -i, --ident SMF identifier basename', 357 | ' -l, --label