From fb7d3935d7e1dac9957a9c3d19f0fb3bba4103ee Mon Sep 17 00:00:00 2001 From: Trent Mick Date: Mon, 25 Sep 2017 21:56:25 -0700 Subject: [PATCH] PUBAPI-1428 CreateMachine: add "affinity" rules, deprecate "locality" hints Reviewed by: Josh Wilsdon Approved by: Josh Wilsdon --- docs/index.md | 167 +++++- lib/app.js | 4 +- lib/errors.js | 3 +- lib/machines.js | 52 +- lib/triton-affinity.js | 1064 ++++++++++++++++++++++++++++++++++++ package.json | 6 +- test/affinity-unit.test.js | 309 +++++++++++ test/machines.71.test.js | 4 +- test/machines.test.js | 10 + test/machines/affinity.js | 184 +++++++ tools/jsl.node.conf | 1 + 11 files changed, 1762 insertions(+), 42 deletions(-) create mode 100644 lib/triton-affinity.js create mode 100644 test/affinity-unit.test.js create mode 100644 test/machines/affinity.js diff --git a/docs/index.md b/docs/index.md index 83a6c015..30a48ba7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -838,27 +838,80 @@ range, they can leverage the codes above. # API Versions +A CloudAPI endpoint has two relevant version values: the code version and the +"API version". The former includes the full `major.minor.patch` version value +of the deployed server and, as of CloudAPI v8.3.0, is available in the "Server" +header of all responses: + + Server: cloudapi/8.3.1 + +The *API* version is only changed for major versions, e.g. API version "8.0.0" +is used for all 8.x code versions. (Older CloudAPI v7 would bump the API version +at the minor version level.) + All requests to CloudAPI must specify an acceptable API [version range](https://github.com/npm/node-semver#ranges) via the 'Accept-Version' (or for backward compatibility the 'Api-Version') header. For example: Accept-Version: ~8 // accept any 8.x version - Accept-Version: 7.1.0 // require exactly this version + Accept-Version: 7.0.0 // require exactly this version Accept-Version: ~8||~7 // accept 8.x or 7.x - Accept-Version: * // wild west + Accept-Version: * // the latest version (wild west) For new applications using CloudAPI SDKs, it is recommended that one explicitly accept a particular major version, e.g. `Accept-Version: ~8`, so that future CloudAPI backward incompatible changes (always done with a *major* version bump) don't break your application. -The `triton` tool uses `Accept-Version: ~8||~7` by default. Users can restrict -the API version via the `triton --accept-version=RANGE ...` option. The older -`sdc-*` tools from node-smartdc similarly use `~8||~7` by default, and users -can restrict the API version via the `SDC_API_VERSION=RANGE` environment -variable or the `--api-version=RANGE` option to each command. +The [`triton` tool](https://github.com/joyent/node-triton) uses +`Accept-Version: ~8||~7` by default. Users can restrict the API version via the +`triton --accept-version=RANGE ...` option. The older `sdc-*` tools from +node-smartdc similarly use `~8||~7` by default, and users can restrict the API +version via the `SDC_API_VERSION=RANGE` environment variable or the +`--api-version=RANGE` option to each command. + +The set of supported *API versions* is given in the ping endpoint: + + GET /ping + accept: application/json + accept-version: ~8 + ... + + HTTPS/1.1 200 OK + server: cloudapi/8.3.0 + content-type: application/json + ... + api-version: 8.0.0 + + { + "ping": "pong", + "cloudapi": { + "versions": [ + "7.0.0", + "7.1.0", + "7.2.0", + "7.3.0", + "8.0.0" + ] + } + } + + +# Versions + +The section describes API changes in CloudAPI versions. + +## 8.3.0 -The rest of this section describes API changes in each version. +- CreateMachine supports a new `affinity` field for specifying affinity rules. + Affinity rules (inspired by Docker Swarm affinity filters) allow a more + powerful mechanism for controlling server placement of instances. + This deprecates the `locality` field for "locality hints" on CreateMachine. + Limitation: Affinity rules currently do not properly consider *concurrent* + provisions (see [TRITON-9](https://smartos.org/bugview/TRITON-9)). + + This CloudAPI feature is comparable to [Triton's Docker placement affinity + rules](https://apidocs.joyent.com/docker/features/placement). ## 8.2.1 @@ -4256,27 +4309,8 @@ obtain the IP addresses and networks of a newly-provisioned instance, poll [GetMachine](#GetMachine) until the instance state is `running`. Typically, Triton will allocate the new instance somewhere reasonable within the -cloud. You may want this instance to be placed on the same server as another -instance you have, or have it placed on an entirely different server from your -existing instances so that you can spread them out. In either case, you can -provide locality hints (aka 'affinity' criteria) to CloudAPI. - -Here is an example of a locality hint: - - "locality": { - "strict": false, - "near": ["af7ebb74-59be-4481-994f-f6e05fa53075"], - "far": ["da568166-9d93-42c8-b9b2-bce9a6bb7e0a", "d45eb2f5-c80b-4fea-854f-32e4a9441e53"] - } - -UUIDs provided should be the ids of instances belonging to you. If there is only -a single UUID entry in an array, you can omit the array and provide the UUID -string directly as the value to a near/far key. - -`strict` defaults to false, meaning that Triton will attempt to meet all the -`near` and/or `far` criteria but will still provision the instance when no -server fits all the requirements. If `strict` is set to true, the creation of -the new instance will fail if the affinity criteria cannot be met. +cloud. See [affinity rules](#affinity-rules) below for options on controlling +server placement of new instances. When Triton CNS is enabled, the DNS search domain of the new VM will be automatically set to the suffix of the "instance" record that is created for @@ -4288,13 +4322,15 @@ be changed later within the instance, if desired. ### Inputs + **Field** | **Type** | **Description** --------- | -------- | --------------- name | String | Friendly name for this instance; default is the first 8 characters of the machine id. If the name includes the string {{shortId}}, any instances of that tag within the name will be replaced by the first 8 characters of the machine id. package | String | Id of the package to use on provisioning, obtained from [ListPackages](#ListPackages) image | String | The image UUID (the "id" field in [ListImages](#ListImages)) networks | Array | Desired networks ids, obtained from [ListNetworks](#ListNetworks). See the note about network pools under [AddNic](#AddNic). -locality | Object[String => Array] | Optionally specify which instances the new instance should be near or far from +affinity | Array | (Added in CloudAPI v8.3.0.) Optional array of [affinity rules](#affinity-rules). +locality | Object | (Deprecated in CloudAPI v8.3.0.) Optionally object of [locality hints](#locality-hints), specify which instances the new instance should be near or far from. metadata.$name | String | An arbitrary set of metadata key/value pairs can be set at provision time, but they must be prefixed with "metadata." tag.$name | String | An arbitrary set of tags can be set at provision time, but they must be prefixed with "tag." firewall_enabled | Boolean | Completely enable or disable firewall for this instance. Default is false @@ -4409,6 +4445,77 @@ or $ sdc-createmachine --image=2b683a82-a066-11e3-97ab-2faa44701c5a --package=7b17343c-94af-6266-e0e8-893a3b9993d0 -t foo=bar -t group=test + +### Affinity rules + +As of CloudAPI v8.3.0 an "affinity" field can be specified with CreateMachine. +It is an array of "affinity rules" to specify rules (or hints, "soft rules") for +placement of the new instance. + +By default, Triton makes a reasonable attempt to spread all containers (and +non-Docker containers and VMs) owned by a single account across separate +physical servers. + +Affinity rules are of one of the following forms: + + instance + container + + + is one of: + + +- `==`: The new instance must be on the same node as the instance(s) identified + by . +- `!=`: The new instance must be on a different node as the instance(s) + identified by . +- `==~`: The new instance should be on the same node as the instance(s) + identified by . I.e. this is a best effort or "soft" rule. +- `!=~`: The new instance should be on a different node as the instance(s) + identified by . I.e. this is a best effort or "soft" rule. + + is an exact string, simple \*-glob, or regular expression to match +against instance names or IDs, or against the named tag's value. Some examples: + + # Run on the same node as instance silent_bob. + triton instance create -a instance==silent_bob ... + + # Run on a different node as all instances tagged with 'role=database'. + triton instance create -a 'role!=database' ... + + # Run on a different node to all instances with names starting with "foo". + triton instance create -a 'instance!=foo*' ... + + # Same, using a regular expression. + triton instance create -a 'instance!=/^foo/' ... + + +### Locality hints + +(Deprecated in CloudAPI v8.3.0.) + +You may want this instance to be placed on the same server as another +instance you have, or have it placed on an entirely different server from your +existing instances so that you can spread them out. In either case, you can +provide locality hints to CloudAPI. + +Here is an example of a locality hint: + + "locality": { + "strict": false, + "near": ["af7ebb74-59be-4481-994f-f6e05fa53075"], + "far": ["da568166-9d93-42c8-b9b2-bce9a6bb7e0a", "d45eb2f5-c80b-4fea-854f-32e4a9441e53"] + } + +UUIDs provided should be the ids of instances belonging to you. If there is only +a single UUID entry in an array, you can omit the array and provide the UUID +string directly as the value to a near/far key. + +`strict` defaults to false, meaning that Triton will attempt to meet all the +`near` and/or `far` criteria but will still provision the instance when no +server fits all the requirements. If `strict` is set to true, the creation of +the new instance will fail if the affinity criteria cannot be met. + ### User-script The special value `metadata.user-script` can be specified to provide a custom diff --git a/lib/app.js b/lib/app.js index 3d260af4..6844981c 100644 --- a/lib/app.js +++ b/lib/app.js @@ -5,7 +5,7 @@ */ /* - * Copyright (c) 2016, Joyent, Inc. + * Copyright (c) 2017, Joyent, Inc. */ /* @@ -354,7 +354,7 @@ module.exports = { var server; var machineThrottle; - config.name = 'Joyent Triton ' + version(); + config.name = 'cloudapi/' + version(); // API version and package.json version are separate; see RFD 68 // for more details config.version = ['8.0.0', '7.3.0', '7.2.0', '7.1.0', '7.0.0']; diff --git a/lib/errors.js b/lib/errors.js index 46e2a543..432192af 100644 --- a/lib/errors.js +++ b/lib/errors.js @@ -5,7 +5,7 @@ */ /* - * Copyright 2016, Joyent, Inc. + * Copyright (c) 2017, Joyent, Inc. */ /* @@ -310,6 +310,7 @@ function vmapiErrorWrap(cause, message) { module.exports = { // Re-exported restify errors. Add more as needed. ResourceNotFoundError: restify.ResourceNotFoundError, + InvalidArgumentError: restify.InvalidArgumentError, // Custom error classes. CloudApiError: CloudApiError, diff --git a/lib/machines.js b/lib/machines.js index 45a350d3..954dcef1 100644 --- a/lib/machines.js +++ b/lib/machines.js @@ -17,17 +17,18 @@ var util = require('util'); var restify = require('restify'); var libuuid = require('libuuid'); -function uuid() { - return (libuuid.create()); -} +var semver = require('semver'); var clone = require('clone'); var vasync = require('vasync'); +var errors = require('./errors'); var images = require('./datasets'); var resources = require('./resources'); var membership = require('./membership'), preloadGroups = membership.preloadGroups; -var semver = require('semver'); +var triton_affinity = require('./triton-affinity'); + + // --- Globals @@ -560,7 +561,7 @@ function getCreateOptions(req) { var shortId; var tags = {}; - opts.uuid = uuid(); + opts.uuid = libuuid.create(); shortId = opts.uuid.split(/-/)[0]; if (params.name) { opts.alias = params.name.replace(/{{shortId}}/g, shortId); @@ -650,6 +651,15 @@ function getCreateOptions(req) { opts.locality = params.locality; } + // 'affinity' is the new replacement for 'locality' (now deprecated). + if (params.affinity) { + if (params.locality) { + throw new InvalidArgumentError( + 'cannot specify both "locality" (deprecated) and "affinity"'); + } + opts.affinity = params.affinity; + } + Object.keys(pkg).forEach(function (p) { if (typeof (opts[p]) === 'undefined' && (PKG_USED_PARAMS.indexOf(p) === -1)) { @@ -1191,6 +1201,38 @@ function create(req, res, next) { var pipeline = []; + /* + * Translate any given `affinity` rules into "locality hints" that + * sdc-designation (aka DAPI) currently understands. Eventually it is + * planned that DAPI will support affinity rules natively, and we'll only + * need to validate the affinity rules here. + */ + pipeline.push(function getLocalityFromAffinity(_, cb) { + if (!opts.affinity) { + cb(); + return; + } + + triton_affinity.localityFromAffinity({ + log: req.log, + vmapi: req.sdc.vmapi, + ownerUuid: customer, + affinity: opts.affinity + }, function (affErr, locality, debugInfo) { + if (affErr) { + cb(new errors.InvalidArgumentError(affErr, affErr.message)); + } else if (locality) { + req.log.info({affinity: opts.affinity, locality: locality, + rulesInfo: debugInfo.rulesInfo}, 'localityFromAffinity'); + opts.locality = locality; + delete opts.affinity; + cb(); + } else { + cb(); + } + }); + }); + if (req.accountMgmt) { if (req.headers['role-tag']) { var role_tags = req.headers['role-tag'].split(','); diff --git a/lib/triton-affinity.js b/lib/triton-affinity.js new file mode 100644 index 00000000..e41fd4e9 --- /dev/null +++ b/lib/triton-affinity.js @@ -0,0 +1,1064 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/* + * Copyright (c) 2017, Joyent, Inc. + */ + +/* BEGIN JSSTYLED */ +/* + * Triton's *affinity rules* support (i.e. the rules/hints for deciding to what + * server a new instance is provisioned). + * + * A source motivation of Triton affinity rules was the affinity features that + * Docker Swarm provides with its "affinity" container filters, described here: + * https://docs.docker.com/swarm/scheduler/filter/#how-to-write-filter-expressions + * The other Swarm filters are ignored. See DOCKER-630 for discussion. + * + * # Affinity types + * + * There are three affinity axes in the Swarm docs: + * + * - *container affinity*: Specify to land on the same or different server + * as an existing instances/containers. + * docker run -e affinity:container==db0 ... + * docker run --label 'com.docker.swarm.affinities=["container==db0"]' ... + * triton create -a instance==db0 ... + * + * - *label affinity*: Specify to land on the same or different server as + * existing containers with a given label key/value. + * docker run --label role=webhead ... # the starter container + * docker run -e affinity:role==webhead ... + * docker run --label 'com.docker.swarm.affinities=["role==webhead"]' ... + * triton create -a role=webhead ... + * + * - *image affinity*: Specify to land on a node with the given image. + * docker run -e affinity:image==redis ... + * docker run --label 'com.docker.swarm.affinities=["image==redis"]' ... + * Note: We will skip this one. For Triton an image is present on all nodes + * in the DC. Until a possible future when Triton acts as a Swarm master + * for multiple DCs, the semantics of this affinity don't apply. + * + * # Affinities -> Locality Hints + * + * Triton's current feature for a VM creation providing affinity is "locality + * hints". As a first pass we'll be translating given affinity expressions + * (in Docker, via both the '-e' envvar syntax and the newer '--label' syntax; + * and in CloudAPI, via the 'affinity' param to CreateMachine) to Triton's + * "locality hints". See here for the locality hints big-theory comment and + * implementation: + * https://github.com/joyent/sdc-designation/blob/master/lib/algorithms/soft-filter-locality-hints.js + * + * # Limitations + * + * - DOCKER-1039 is a known issue: Hard affinity rules using instance names or + * tags for *concurrent provisions* will race. The correct fix for that (to + * handle the translation from instance name/tags to UUIDs in DAPI's + * server selection -- which is serialized in the DC) will fix the issue for + * both sdc-docker and CloudAPI. + * + * - Affinity rules using the '==' operator and that match *multiple instances + * on separate CNs*: the translation to locality hints must select just + * *one* of those multiple instances. + * + * E.g. 'instance==webhead*' when there are "webhead0" and "webhead1" + * instances on separate CNs. There is no way to provision an instance that + * is on *both* CNs. The intention is to select webhead0's server *or* + * webhead1's server. However, locality hints don't support "or". + * Therefore the affinity->locality translation must select just one. + * + * The issue is that the translation doesn't know which one might be more + * appropriate -- e.g. webhead0's server might be out of space. + * + * - sdc-designation's locality hints cannot handle mixed strict and non-strict + * rules. E.g.: + * docker run -e affinity:container==db0 -e 'affinity:container!=db1' ... + * To support that we'd need to extend the "locality" data structure format. + * Currently we just drop the non-strict rules when hitting this. An + * alternative would be to error out. + */ +/* END JSSTYLED */ + +var assert = require('assert-plus'); +var format = require('util').format; +var strsplit = require('strsplit'); +var vasync = require('vasync'); +var VError = require('verror'); +var XRegExp = require('xregexp'); + + +// ---- globals + +var EXPR_KEY_RE = /^[a-z_][a-z0-9\-_.]+$/i; + +/* + * Expression values can have the following chars: + * - alphanumeric: a-z, A-Z, 0-9 + * - plus any of the following characters: `-:_.*()/?+[]\^$|` + * + * The Swarm docs and code do not agree, so it is hard to divine the intent + * other than "pretty loose". + * + * Dev Note: This regex differs from the Swarm one in expr.go to fix some issues + * (e.g. it looks to me like Swarm's regex usage is in error that it allows + * a leading `=` because the surrounding parsing code parses out the full + * operator already) and accomodate slight parsing differences (e.g. this code + * parses off a leading `~` or `!` or `=` from the operator before using this + * regex). + */ +// JSSTYLED +var EXPR_VALUE_RE = /^[-a-z0-9:_\s.*/()?+[\]\\^$|]+$/i; + + +// ---- internal support stuff + +function setIntersection(a, b) { + var intersection = new Set(); + a.forEach(function (elem) { + if (b.has(elem)) { + intersection.add(elem); + } + }); + return intersection; +} + +function setJoin(set, sep) { + var arr = []; + set.forEach(function (elem) { + arr.push(elem); + }); + return arr.join(sep); +} + + +function setRandomChoice(set) { + var idx = Math.floor(Math.random() * set.size); + var choice; + set.forEach(function (elem) { + if (idx === 0) { + choice = elem; + } + idx--; + }); + assert.ok(choice !== undefined); + return choice; +} + +function _isUuid(str) { + var re = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/; + if (str && str.length === 36 && str.match(re)) { + return true; + } else { + return false; + } +} + + +/* + * This is a copy of `dockerIdToUuid` from sdc-docker.git: + * https://github.com/joyent/sdc-docker/blob/94fa554d/lib/common.js#L537-L547 + * to determine a Triton VM UUID from a Docker container ID. + */ +function dockerIdToUuid(dockerId) { + var out; + + out = dockerId.substr(0, 8) + '-' + + dockerId.substr(8, 4) + '-' + + dockerId.substr(12, 4) + '-' + + dockerId.substr(16, 4) + '-' + + dockerId.substr(20, 12); + + return (out); +} + + +/** + * Parse out affinity rules from a Docker container config. + * + * Compare to Swarm's processing for pulling from Env and Labels, + * storing `Labels['com.docker.swarm.affinities']`: + * https://github.com/docker/swarm/blob/4ff0b10/cluster/config.go + * + * *Side-Effect*: + * - This removes 'affinity:*' entries from `container.Env`. + * - If affinity expressions are provided in `container.Env` then + * `container.Labels['com.docker.swarm.affinities']` is updated with them. + * + * @throws {VError} with name 'ValidationError' if a given affinity label or + * envvar is invalid. + */ +function _affinityRulesFromDockerContainer(opts) { + assert.object(opts, 'opts'); + assert.object(opts.log, 'opts.log'); + assert.object(opts.container, 'opts.container'); + assert.optionalObject(opts.container.Labels, 'opts.container.Labels'); + assert.optionalArrayOfString(opts.container.Env, 'opts.container.Env'); + + var exprs = []; + + // Labels, e.g.: { 'com.docker.swarm.affinities': '["a==b"]' } + var labels = opts.container.Labels; + if (labels && labels['com.docker.swarm.affinities']) { + exprs = exprs.concat(_affinityExprsFromDockerLabel( + labels['com.docker.swarm.affinities'])); + } + + // Env, e.g.: [ 'affinity:foo==bar' ] + var env = opts.container.Env; + var envIdxToDel = []; + var i, kv, parts; + if (env) { + for (i = 0; i < env.length; i++) { + kv = env[i]; + if (kv.slice(0, 9) === 'affinity:') { + parts = strsplit(kv, ':', 2); + exprs.push(parts[1]); + envIdxToDel.push(i); + } + } + } + + // Parse the rules/expressions. + var rules = []; + for (i = 0; i < exprs.length; i++) { + rules.push(ruleFromExpr(exprs[i])); + } + + // Side-effects. + if (envIdxToDel.length > 0) { + envIdxToDel.reverse().forEach(function (idx) { + opts.container.Env.splice(idx, 1); + }); + labels['com.docker.swarm.affinities'] = JSON.stringify(exprs); + } + + return rules; +} + + +/** + * Parse an affinity rule expression. + * + * Our "affinity expression" is the equivalent of a Swarm filter expression. + * https://github.com/docker/swarm/blob/ee28008f/scheduler/filter/expr.go + * + * The underlined part is the rule/expression: + * + * docker run -e affinity:container==db0 ... + * ^^^^^^^^^^^^^^ + * docker run --label 'com.docker.swarm.affinities=["container==db0"]' ... + * ^^^^^^^^^^^^^^ + * + * A parsed affinity rule is an object like this: + * { + * key: '', // e.g. 'container', 'instance' + * operator: <'==' or '!='>, + * value: '', + * isSoft: , + * valueType: <'exact', 'glob' or 're'>, + * valueRe: // only defined if valueType==='re' + * } + * + * @throws {VError} with name 'ValidationError' if a given rule string + * is invalid. + */ +function ruleFromExpr(s) { + assert.string(s, 's'); + + var i; + var rule = {}; + var op; + var opIdx; + var OPERATORS = ['==', '!=']; + + // Determine which operator was used. + for (i = 0; i < OPERATORS.length; i++) { + opIdx = s.indexOf(OPERATORS[i]); + if (opIdx !== -1) { + op = OPERATORS[i]; + break; + } + } + + if (!op) { + throw new VError({name: 'ValidationError'}, + 'could not find operator in affinity rule: ' + + 'expected one of "%s": %j', OPERATORS.join('", "'), s); + } + + // Build the rule. + rule.key = s.slice(0, opIdx); + if (!EXPR_KEY_RE.test(rule.key)) { + throw new VError({name: 'ValidationError'}, + 'invalid key in affinity rule: %j: %j does not match %s', + s, rule.key, EXPR_KEY_RE); + } + rule.operator = op; + rule.value = s.slice(opIdx + rule.operator.length); + if (rule.value.length > 0 && rule.value[0] === '~') { + rule.isSoft = true; + rule.value = rule.value.slice(1); + } else { + rule.isSoft = false; + } + if (!EXPR_VALUE_RE.test(rule.value)) { + throw new VError({name: 'ValidationError'}, + 'invalid value in affinity rule: %j: %j does not match %s', + s, rule.value, EXPR_VALUE_RE); + } + if (rule.value.length >= 3 && rule.value[0] === '/' && + rule.value[rule.value.length - 1] === '/') + { + rule.valueType = 're'; + rule.valueRe = XRegExp(rule.value.slice(1, -1)); + } else if (rule.value.indexOf('*') !== -1) { + rule.valueType = 'glob'; + } else { + rule.valueType = 'exact'; + } + + return rule; +} + +function exprFromRule(rule) { + assert.object(rule, 'rule'); + assert.string(rule.key, 'rule.key'); + assert.string(rule.operator, 'rule.operator'); + assert.bool(rule.isSoft, 'rule.isSoft'); + assert.string(rule.value, 'rule.value'); + + return format('%s%s%s%s', rule.key, rule.operator, rule.isSoft ? '~' : '', + rule.value); +} + +/** + * Parse affinity expressions from a `docker run` "com.docker.swarm.affinities" + * label. + * + * @throws {VError} with name 'ValidationError' if there is an error parsing. + */ +function _affinityExprsFromDockerLabel(label) { + assert.string(label, 'label'); + + var exprs; + try { + exprs = JSON.parse(label); + } catch (parseErr) { + throw new VError({name: 'ValidationError'}, + 'invalid affinities label: %j: %s', label, parseErr); + } + + if (!Array.isArray(exprs)) { + throw new VError({name: 'ValidationError'}, + 'affinities label is not an array: ' + label); + } + + return exprs; +} + + +/* + * Find the VM(s) matching the given affinity rule (parsed by ruleFromExpr). + * + * If `affinity.key` is one of "container" or "instance" (*), the affinity value + * can be any of: + * - instance uuid: use that directly + * - docker id: if at least a 32-char prefix of a docker_id, + * then can construct instance UUID from that and use that + * directly + * - short docker id: look up all docker containers by uuid + * - name: lookup all (not just docker) instances by alias + * - name glob: lookup all (not just docker) instances by alias + * IIUC, Swarm's impl. is just simple globbing: '*'-only + * - name regex: lookup all (not just docker) containers by + * alias. + * + * (*) "container" is required for Docker compat. "instance" is the external + * language that Triton now attempts to use, despite the continued use + * of "machine" in cloudapi code (e.g. see node-triton). It is perhaps + * debatable that we'd want to accept "inst" (node-triton does) and + * "machine". I'm inclined to *not*. This is a case of less (fewer options) + * is more: less confusion, less namespace pollution for tag names. + * + * Otherwise `affinity.key` is a tag key: + * Find any VMs matching that key/value. As above, the value can be an exact + * value (stringified comparison), glob (simple '*'-only glob) or regex. + * + * Dev Note: Annoyingly Triton prefixes docker labels with "docker:label:" on + * VM.tags. So we search both. Note that this can look obtuse or ambiguious + * to the docker user if a container has both 'foo' and 'docker:label:foo' + * VM tags. + * + * @param {Object} opts.rule - The parsed affinity rule object. + * @param {Object} opts.log + * @param {UUID} opts.ownerUuid + * @param {Object} opts.vmapi + * @param {Object} opts.cache: Used to cache data for repeated calls to this + * function, e.g., for a single `localityFromDockerContainer` call. + * @param {Function} cb: `function (err, vms)` + */ +function _vmsFromRule(opts, cb) { + assert.object(opts.rule, 'opts.rule'); + assert.object(opts.log, 'opts.log'); + assert.uuid(opts.ownerUuid, 'opts.ownerUuid'); + assert.object(opts.vmapi, 'opts.vmapi'); + assert.object(opts.cache, 'opts.cache'); + assert.func(cb, 'cb'); + + var rule = opts.rule; + var i; + var keyIsInst = (rule.key === 'instance' || rule.key === 'container'); + var log = opts.log; + var query; + var vm; + var vms; + + var headers = {}; + if (log.fields.req_id) { + headers['x-request-id'] = log.fields.req_id; + } + + // A caching version of VMAPI 'ListVms?state=active&owner_uuid=$ownerUuid'. + function getAllActiveVms(vmsCb) { + if (opts.cache.allActiveVms) { + vmsCb(null, opts.cache.allActiveVms); + return; + } + opts.vmapi.listVms({ + fields: 'uuid,alias,internal_metadata,docker', + owner_uuid: opts.ownerUuid, + state: 'active' + }, { + headers: headers + }, function onListAllVms(err, allActiveVms) { + if (err) { + vmsCb(err); + } else { + opts.cache.allActiveVms = allActiveVms; + vmsCb(null, allActiveVms); + } + }); + } + + + // $tag=$value + // $tag=$glob + if (!keyIsInst && rule.valueType !== 're') { + query = { + fields: 'uuid,alias,server_uuid,tags', + owner_uuid: opts.ownerUuid, + state: 'active', + predicate: JSON.stringify({ + or: [ + {eq: ['tag.' + rule.key, rule.value]}, + {eq: ['tag.docker:label:' + rule.key, rule.value]} + ] + }) + }; + opts.vmapi.listVms(query, { + headers: headers + }, function onListVmsMatchingTags(err, vms_) { + if (err) { + cb(err); + return; + } + log.trace({expr: exprFromRule(rule), vms: vms_}, '_vmsFromRule'); + cb(null, vms_); + }); + + // $tag==/regex/ + // Get a all '$key=*'-tagged VMs and post-filter with `valueRe`. + } else if (!keyIsInst && rule.valueType === 're') { + query = { + fields: 'uuid,alias,server_uuid,tags', + owner_uuid: opts.ownerUuid, + state: 'active', + predicate: JSON.stringify({ + or: [ + {eq: ['tag.' + rule.key, '*']}, + {eq: ['tag.docker:label:' + rule.key, '*']} + ] + }) + }; + opts.vmapi.listVms(query, { + headers: headers + }, function onListVmsForTagRegex(err, allVms) { + if (err) { + cb(err); + return; + } + vms = []; + for (i = 0; i < allVms.length; i++) { + vm = allVms[i]; + + var tag = vm.tags[rule.key]; + if (tag !== undefined && rule.valueRe.test(tag.toString())) { + // Docker labels can only be strings. Triton VM tags can + // also be booleans or numbers. + vms.push(vm); + continue; + } + var label = vm.tags['docker:label:' + rule.key]; + if (label !== undefined && rule.valueRe.test(label)) { + vms.push(vm); + continue; + } + } + log.trace({expr: exprFromRule(rule), vms: vms}, '_vmsFromRule'); + cb(null, vms); + }); + + // instance==UUID + } else if (_isUuid(rule.value)) { + assert.ok(keyIsInst, 'key is "container" or "instance": ' + rule.key); + opts.vmapi.getVm({ + uuid: rule.value, + owner_uuid: opts.ownerUuid, + fields: 'uuid,alias,state,server_uuid' + }, { + headers: headers + }, function onGetVm(err, vm_) { + if (err) { + cb(err); + } else if (vm_ && + ['destroyed', 'failed'].indexOf(vm_.state) === -1) { + cb(null, [vm_]); + } else { + cb(null, []); + } + }); + + // instance== + // + // Given a full 64-char docker id, Docker-docker will skip container + // *name* matching (at least that's what containers.js#findContainerIdMatch + // implies). We'll do the same here. Any other length means we need to + // consider name matching. + } else if (/^[a-f0-9]{64}$/.test(rule.value)) { + assert.ok(keyIsInst, 'key is "container" or "instance": ' + rule.key); + var vmUuid = dockerIdToUuid(rule.value); + opts.vmapi.getVm({ + uuid: vmUuid, + owner_uuid: opts.ownerUuid, + fields: 'uuid,alias,state,server_uuid,internal_metadata,docker' + }, { + headers: headers + }, function onGetVmFromDockerId(err, vm_) { + if (err && err.statusCode !== 404) { + cb(err); + } else if (!err && vm_ && vm_.docker && + ['destroyed', 'failed'].indexOf(vm_.state) === -1 && + vm_.internal_metadata['docker:id'] === rule.value) + { + cb(null, [vm_]); + } else { + cb(null, []); + } + }); + + // instance= + // instance= + // instance= (simple '*'-globbing only) + // instance= + // + // List all active VMs (non-docker too) and pass to "containers.js" + // filter function to select a match. + } else { + assert.ok(keyIsInst, 'key is "container" or "instance": ' + rule.key); + + vms = []; + vasync.pipeline({funcs: [ + /* + * First attempt an exact name (aka alias) match as a quick out, + * if possible. + */ + function attemptNameMatch(_, next) { + if (rule.valueType !== 'exact' && rule.valueType !== 'glob') { + next(); + return; + } + opts.vmapi.listVms({ + fields: 'uuid,alias,server_uuid', + owner_uuid: opts.ownerUuid, + state: 'active', + predicate: JSON.stringify({ + eq: ['alias', rule.value] // this supports '*'-glob + }) + }, { + headers: headers + }, function onListVmsMatchingAlias(err, vms_) { + if (err) { + next(err); + } else { + vms = vms_; + next(); + } + }); + }, + + function fullVmListSearch(_, next) { + if (vms.length) { + // Already got results. + next(); + return; + } + + getAllActiveVms(function onGetAllActiveVms(err, allVms) { + if (err) { + next(err); + return; + } + + switch (rule.valueType) { + case 're': + // Regex is only on container name, not id. + for (i = 0; i < allVms.length; i++) { + vm = allVms[i]; + if (vm.alias && rule.valueRe.test(vm.alias)) { + vms.push(vm); + } + } + next(); + break; + case 'glob': + // Glob is only on container name, not id. + // Dev Note: Better would be to use minimatch. + var valueRe = new RegExp( + '^' + + XRegExp.escape(rule.value) + .replace('\\*', '.*') + .replace('\\?', '.') + + '$'); + for (i = 0; i < allVms.length; i++) { + vm = allVms[i]; + if (vm.alias && valueRe.test(vm.alias)) { + vms.push(vm); + } + } + next(); + break; + case 'exact': + /* + * This is a exact name match (preferred) or id prefix. + * If there are multiple id-prefix matches, we'll + * raise an ambiguity error. + */ + var exactErr; + var idPrefixMatches = []; + var nameMatch; + for (i = 0; i < allVms.length; i++) { + vm = allVms[i]; + if (vm.alias && vm.alias === rule.value) { + nameMatch = vm; + break; + } + if (vm.docker && + vm.internal_metadata['docker:id'] && + vm.internal_metadata['docker:id'].indexOf( + rule.value) === 0) + { + idPrefixMatches.push(vm); + } + } + if (nameMatch) { + vms.push(nameMatch); + } else if (idPrefixMatches.length > 1) { + exactErr = new VError({ + name: 'AmbiguousDockerContainerIdPrefixError', + info: { + idPrefix: rule.value, + idPrefixMatches: idPrefixMatches + } + }, 'id prefix "%s" matches multiple containers', + rule.value); + } else if (idPrefixMatches.length === 1) { + vms.push(idPrefixMatches[0]); + } + next(exactErr); + break; + default: + next(new VError('unknown affinity rule valueType: ' + + rule.valueType)); + break; + } + }); + } + ]}, function onInstNameMatch(err) { + if (err) { + cb(err); + } else { + log.trace({expr: exprFromRule(rule), vms: vms}, '_vmsFromRule'); + cb(null, vms); + } + }); + } +} + + +// ---- exports + +/** + * Calculate "locality" hints for a VMAPI CreateVm payload from Docker Swarm + * "Env" and "Labels" affinity entries, if any, in a "docker run" API call. + * + * *Side-effects*: + * - This *removes* affinity entries from `container.Env`. + * - If affinities are provided in `container.Env` then + * `container.Labels['com.docker.swarm.affinities']` is updated with them. + * Docker Swarm does the same. + * + * Swarm affinities can identify containers by id, id-prefix, name, name glob, + * name regex, or via tag matches. They looks like the following: + * container + * + * where is one of `==`, `!=`, `==~`, or `!=~` (`~` means a "soft" + * affinity -- non-fatal if cannot match); and can be a plain string + * (exact match), a glob (simple '*'-only globbing), or a regexp (re2 syntax). + * E.g.: + * container==1a8dae2f-d352-4340-8122-ae76b70a47bd + * container==1a8dae2fd352 + * container!=db0 + * container==db* + * container==/^db\d+$/ + * flav!=staging + * role==/^web/ + * + * Locality hints only speak VM uuids. They look like the following (all + * fields are optional): + * { + * strict: , + * near: [], + * far: [] + * } + * + * Looking up VMs in VMAPI is necessary for the translation. + * Some failure modes: + * - VMAPI requests could fail. + * - No VMs could be found matching the filter, and the affinity is + * a strict '=='. (If we didn't fail, then we'd end up setting no ` + * locality` and the strict affinity would be blithely ignored.) + * + * @param ... + * @param {Function} cb: `function (err, locality, debugInfo)` + * where `debugInfo` is an object with a `rulesInfo` field that shows + * internal details. The caller may want to log this. + */ +function localityFromDockerContainer(opts, cb) { + assert.object(opts, 'opts'); + assert.object(opts.log, 'opts.log'); + assert.object(opts.vmapi, 'opts.vmapi'); + assert.uuid(opts.ownerUuid, 'opts.ownerUuid'); + assert.object(opts.container, 'opts.container'); + assert.func(cb, 'cb'); + + var log = opts.log; + + try { + var rules = _affinityRulesFromDockerContainer(opts); + } catch (affErr) { + cb(affErr); + return; + } + if (rules.length === 0) { + cb(); + return; + } + log.trace({rules: rules}, 'localityFromDockerContainer: rules'); + + _localityFromRules({ + log: log, + vmapi: opts.vmapi, + ownerUuid: opts.ownerUuid, + rules: rules + }, cb); +} + +/* + * Convert the given `affinity` (as accepted by CloudAPI's CreateMachine) to + * a `locality` object supported by sdc-designation (aka DAPI). + * + * @param ... + * @param {Function} cb: `function (err, locality, debugInfo)` + * where `debugInfo` is an object with a `rulesInfo` field that shows + * internal details. The caller may want to log this. + */ +function localityFromAffinity(opts, cb) { + assert.object(opts, 'opts'); + assert.object(opts.log, 'opts.log'); + assert.object(opts.vmapi, 'opts.vmapi'); + assert.uuid(opts.ownerUuid, 'opts.ownerUuid'); + assert.arrayOfString(opts.affinity, 'opts.affinity'); + assert.func(cb, 'cb'); + + var log = opts.log; + + if (opts.affinity.length === 0) { + cb(); + return; + } + + var rules; + try { + // TODO: improve this to get all parse errors and VError.errorFromList. + rules = opts.affinity.map(function (expr) { + return ruleFromExpr(expr); + }); + } catch (exprErr) { + cb(exprErr); + return; + } + log.trace({rules: rules}, 'localityFromAffinity: rules'); + + _localityFromRules({ + log: log, + vmapi: opts.vmapi, + ownerUuid: opts.ownerUuid, + rules: rules + }, cb); +} + +/* + * Convert affinity rules to locality hints, as best as possible. + * + * @param ... + * @param {Function} cb: `function (err, locality, debugInfo)` + * where `debugInfo` is an object with a `rulesInfo` field that shows + * internal details. The caller may want to log this. + */ +function _localityFromRules(opts, cb) { + assert.object(opts, 'opts'); + assert.object(opts.log, 'opts.log'); + assert.object(opts.vmapi, 'opts.vmapi'); + assert.uuid(opts.ownerUuid, 'opts.ownerUuid'); + assert.arrayOfObject(opts.rules, 'opts.rules'); + assert.func(cb, 'cb'); + + var log = opts.log; + log.trace({rules: opts.rules}, '_localityFromRules: rules'); + + // First, gather VM info that we'll need for conversion. + // TODO: Really want forEachParallel with concurrency. + var cache = {}; + vasync.forEachParallel({ + inputs: opts.rules, + func: function gatherVmsForRule(rule, next) { + if (rule.key === 'image') { + // TODO: Should we allow 'image' tag here? + log.trace({rule: rule}, 'ignore "image" affinity'); + next(); + return; + } + + _vmsFromRule({ + rule: rule, + log: log, + ownerUuid: opts.ownerUuid, + vmapi: opts.vmapi, + cache: cache + }, function onVmsFromRule(err, vms) { + rule.vms = vms; + next(err); + }); + } + }, function onVmsFromRules(err) { + if (err) { + cb(err); + return; + } + + // Second, convert to locality hints. + try { + var locality = localityFromRulesInfo({ + log: log, + rules: opts.rules + }); + } catch (convertErr) { + cb(convertErr); + return; + } + + log.trace({locality: locality}, '_localityFromRules: locality'); + cb(null, locality, {rulesInfo: opts.rules}); + }); +} + +/* + * Synchronously convert affinity rules (and required extra info) to locality + * hints, as best as possible. + * + * Dev Note: we separate "gather async details" and "convert rules -> locality" + * steps to make the latter more easily testable. + */ +function localityFromRulesInfo(opts) { + assert.object(opts, 'opts'); + assert.object(opts.log, 'opts.log'); + // Each rule object should have a 'vms' array. This code uses the 'uuid' + // and (if not null) 'server_uuid' fields of each VM. + assert.arrayOfObject(opts.rules, 'opts.rules'); + opts.rules.forEach(function (r) { + assert.arrayOfObject(r.vms, 'opts.rules[*].vms'); + r.vms.forEach(function (v) { + assert.ok(v.hasOwnProperty('uuid')); + assert.ok(v.hasOwnProperty('server_uuid')); + }); + }); + + var i; + var log = opts.log; + var rules = opts.rules; + log.trace({rules: rules}, 'localityFromRulesInfo: rules'); + + var haveHard = false; + var haveSoft = false; + var softRules = []; + var hardRules = []; + for (i = 0; i < rules.length; i++) { + var isSoft = rules[i].isSoft; + if (isSoft) { + haveSoft = true; + softRules.push(rules[i]); + } else { + haveHard = true; + hardRules.push(rules[i]); + } + } + if (haveHard && haveSoft) { + log.trace({softRules: softRules}, + 'mixed hard and soft affinity rules: dropping soft affinity rules'); + rules = hardRules; + } + + var strict = haveHard; + var near = []; + var far = []; + + var farServerUuids = new Set(); + var farRules = rules.filter(function (farRule) { + return farRule.operator === '!='; + }); + var farVmUuids = new Set(); + farRules.forEach(function (farRule) { + farRule.vms.forEach(function (vm) { + farVmUuids.add(vm.uuid); + farServerUuids.add(vm.server_uuid); + }); + }); + farVmUuids.forEach(function (farVmUuid) { + far.push(farVmUuid); + }); + + // Work through each "near" rule. + var nearRules = rules.filter(function (nearRule) { + return nearRule.operator === '=='; + }); + var nearServerUuids = null; // Servers that satisfy all near rules. + nearRules.forEach(function (nearRule) { + // Eliminate "far" servers from candidacy. + nearRule.remainingVms = nearRule.vms.filter(function (vm) { + // Provisioning VMs might not have a server_uuid. + return (vm.server_uuid && !farServerUuids.has(vm.server_uuid)); + }); + + // If there are no remaining VMs, then this rule is unsatisfiable. + if (nearRule.remainingVms.length === 0) { + if (isSoft) { + nearRule.skip = true; + return; + } else { + throw new VError('cannot satisfy affinity rule "%s", ' + + '"!=" rules eliminate all its servers', + exprFromRule(nearRule)); + } + } + + // Candidate servers are the intersection of servers for this rule + // and those from previous rules. + var ruleServerUuids = new Set(nearRule.remainingVms.map( + function (vm) { return vm.server_uuid; })); + if (nearServerUuids === null) { + nearServerUuids = ruleServerUuids; + } else { + var newCandidates = setIntersection( + nearServerUuids, ruleServerUuids); + if (newCandidates.size === 0) { + if (isSoft) { + nearRule.skip = true; + return; + } else { + throw new VError('cannot satisfy affinity rule "%s", ' + + 'its servers (%s) do not intersect with servers from ' + + 'previous rules (%s)', + exprFromRule(nearRule), + setJoin(ruleServerUuids, ', '), + setJoin(nearServerUuids, ', ')); + } + } else { + nearServerUuids = newCandidates; + } + } + }); + + if (nearServerUuids !== null) { + /* + * If there are multiple candidate servers, then we must choose one + * here (we choose at random). We can't pass through multiple servers + * because you can't provision an instance on more than one server. + * It would be better to send through all the candidates and have + * sdc-designation choose the best of those servers (considering + * available capacity, etc.), but locality hints don't support a + * list of candidates. + */ + assert.ok(nearServerUuids.size > 0); + var serverUuid = setRandomChoice(nearServerUuids); + + /* + * Locality hints speak in terms of VMs. We'll use the first VM from + * the first non-skipped rule as the representative of `serverUuid`. + */ + var vmUuid; + for (i = 0; i < nearRules.length; i++) { + var rule = nearRules[i]; + if (rule.skip) { + continue; + } + for (var j = 0; j < rule.remainingVms.length; j++) { + if (rule.remainingVms[j].server_uuid === serverUuid) { + vmUuid = rule.remainingVms[j].uuid; + break; + } + } + if (vmUuid) { + break; + } + } + assert.ok(vmUuid); + near.push(vmUuid); + } + + + var locality = { + strict: strict + }; + if (near.length > 0) { + locality.near = near; + } + if (far.length > 0) { + locality.far = far; + } + + return locality; +} + + +module.exports = { + localityFromDockerContainer: localityFromDockerContainer, + localityFromAffinity: localityFromAffinity, + + // Exported for testing. + localityFromRulesInfo: localityFromRulesInfo, + ruleFromExpr: ruleFromExpr, + exprFromRule: exprFromRule +}; diff --git a/package.json b/package.json index fef6f21a..9c037eb9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "cloudapi", "description": "SmartDataCenter CloudAPI", - "version": "8.2.1", + "version": "8.3.0", "author": "Joyent (joyent.com)", "private": true, "engines": { @@ -37,7 +37,9 @@ "aperture-config": "git+https://github.com/joyent/aperture-config.git#master", "joyent-schemas": "git+https://github.com/joyent/schemas.git#caf3a226ed0707f5da897e1da151cc6d97fccda2", "jsprim": "0.6.1", - "verror": "1.6.1" + "strsplit": "1.0.0", + "verror": "1.6.1", + "xregexp": "3.1.0" }, "devDependencies": { "tape": "3.5.0", diff --git a/test/affinity-unit.test.js b/test/affinity-unit.test.js new file mode 100644 index 00000000..ccc86e36 --- /dev/null +++ b/test/affinity-unit.test.js @@ -0,0 +1,309 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/* + * Copyright (c) 2017, Joyent, Inc. + */ + +/* + * Affinity rule *unit* tests. + * See machines/affinity.test.js for integration tests. + */ + +var assert = require('assert-plus'); +var bunyan = require('bunyan'); +var deepEqual = require('tape/node_modules/deep-equal'); +var test = require('tape').test; +var util = require('util'); +var VError = require('verror'); +var XRegExp = require('xregexp'); + +var lib_affinity = require('../lib/triton-affinity'); + + +var log = bunyan.createLogger({ + level: process.env.LOG_LEVEL || 'warn', + name: 'sdccloudapitest-affinity-unit', + stream: process.stderr, + serializers: bunyan.stdSerializers +}); + + +// ---- helpers + +/* + * Pass in an array of server objects with a 'vms' array that is the set of + * VMs on that server *for the single unnamed account*. + */ +function MockDc(servers) { + assert.arrayOfObject(servers, 'servers'); + var self = this; + + this.servers = servers; + + this.vms = []; + this.vmFromUuid = {}; + this.vmFromAlias = {}; + this.serverFromUuid = {}; + + servers.forEach(function (server) { + self.serverFromUuid[server.uuid] = server; + server.vms.forEach(function (vm) { + vm.server_uuid = server.uuid; + self.vms.push(vm); + self.vmFromUuid[vm.uuid] = vm; + self.vmFromAlias[vm.alias] = vm; + }); + }); +} + +/* + * Faking out `triton-affinity._vmsFromRule`. + * { + * key: 'instance', + * value: 'webhead*', + * valueType: 'glob', // or 're' or 'exact' + * ... + * } + * + * Limitations: + * - Not bothering with docker_id matching. + */ +MockDc.prototype.vmsFromRule = function (rule) { + var self = this; + var key = rule.key; + var val = rule.value; + var valueType = rule.valueType; + var valueRe; + var vms = []; + + if (valueType === 'exact') { + valueRe = new RegExp('^' + XRegExp.escape(val) + '$'); + } else if (valueType === 'glob') { + // Cheat. Better would be to use minimatch. + valueRe = new RegExp( + '^' + + XRegExp.escape(val) + .replace('\\*', '.*') + .replace('\\?', '.') + + '$'); + } else if (valueType === 're') { + valueRe = rule.valueRe; + } else { + throw new VError('unexpected rule data', rule); + } + + if (key === 'instance' || key === 'container') { + // exact uuid + if (valueType === 'exact' && self.vmFromUuid[val]) { + vms.push(self.vmFromUuid[val]); + } else { + // alias + self.vms.forEach(function (vm) { + if (vm.alias && valueRe.test(vm.alias)) { + vms.push(vm); + } + }); + } + } else { + // tag + self.vms.forEach(function (vm) { + if (vm.tags && vm.tags.hasOwnProperty(key) && + valueRe.test(vm.tags[key].toString())) + { + vms.push(vm); + } + }); + } + + return vms; +}; + + +function assertLocalityFromRules(opts) { + var i; + var expectedLocality; + var foundMatch; + var locality; + var locSummary; + var rulesInfo = []; + + opts.exprs.forEach(function (expr) { + var rule = lib_affinity.ruleFromExpr(expr); + // .vms is the "Info" part of rulesInfo + rule.vms = opts.dc.vmsFromRule(rule); + rulesInfo.push(rule); + }); + + try { + locality = lib_affinity.localityFromRulesInfo( + {log: log, rules: rulesInfo}); + } catch (err) { + if (opts.err) { + opts.t.ok(err, util.format( + 'error determining locality for %j', opts.exprs)); + if (opts.err.message) { + opts.t.equal(err.message, opts.err.message, + util.format('error message is %j', opts.err.message)); + } + } else { + opts.t.ifError(err, util.format( + 'no error determining locality for %j', opts.exprs)); + } + } + if (opts.locality) { + if (Array.isArray(opts.locality)) { + foundMatch = false; + for (i = 0; i < opts.locality.length; i++) { + expectedLocality = opts.locality[i]; + foundMatch = deepEqual(locality, expectedLocality); + if (foundMatch) { + break; + } + } + locSummary = opts.locality.map( + function (loc) { return JSON.stringify(loc); }); + opts.t.assert(foundMatch, util.format('%j -> one of %s', + opts.exprs, locSummary.join(', '))); + } else { + opts.t.deepEqual(locality, opts.locality, + util.format('%j -> %j', opts.exprs, opts.locality)); + } + } +} + + + +// --- Tests + +test('affinity-unit', function (tt) { + // A layout of our (unnamed) test account's VMs in the DC. We'll run + // affinity->locality tests against this setup. + /* BEGIN JSSTYLED */ + var dc = new MockDc([ + { + uuid: 'aaaaaaaa-9f2c-11e7-8d2a-7b05237c283d', + hostname: 'CNa', + vms: [ + { uuid: '02655ed2-9f2c-11e7-a596-8f1e118e27d6', alias: 'webhead0', tags: {} }, + { uuid: '48195234-9f2c-11e7-8970-3f2cc6773306', alias: 'db0', tags: {role: 'database'} } + ] + }, + { + uuid: 'bbbbbbbb-9f42-11e7-a98f-375a35af4e58', + hostname: 'CNb', + vms: [ + { uuid: '0dab5820-9f2d-11e7-a5ae-8b56e717c599', alias: 'webhead1', tags: {} }, + { uuid: '10d1edb6-9f2d-11e7-8923-2f7c2579fada', alias: 'db1', tags: {role: 'database'} } + ] + }, + { + uuid: 'cccccccc-9f2d-11e7-bfaa-03c71f6e23e9', + hostname: 'CNc', + vms: [ + { uuid: 'a22832e8-9f2d-11e7-99a4-a3ae10e549f5', alias: 'webhead2', tags: {} } + ] + }, + { + uuid: 'dddddddd-9f2d-11e7-9c48-2b216115a37d', + hostname: 'CNd', + vms: [ + { uuid: '9fec2192-9f2d-11e7-b081-fbba6b455dbd', alias: 'webhead3', tags: {} } + ] + } + ]); + /* END JSSTYLED */ + + tt.test(' localityFromRulesInfo', function (t) { + assertLocalityFromRules({ + t: t, + dc: dc, + exprs: ['instance!=webhead3'], + locality: { + strict: true, + far: ['9fec2192-9f2d-11e7-b081-fbba6b455dbd'] + } + }); + + assertLocalityFromRules({ + t: t, + dc: dc, + exprs: ['container==webhead3'], + locality: { + strict: true, + near: ['9fec2192-9f2d-11e7-b081-fbba6b455dbd'] + } + }); + + assertLocalityFromRules({ + t: t, + dc: dc, + exprs: ['role!=~datab*se'], + locality: { + strict: false, + far: [ + '48195234-9f2c-11e7-8970-3f2cc6773306', + '10d1edb6-9f2d-11e7-8923-2f7c2579fada' + ] + } + }); + + assertLocalityFromRules({ + t: t, + dc: dc, + exprs: [ + 'role==/^data/', + 'instance!=webhead*' + ], + err: { + message: 'cannot satisfy affinity rule "role==/^data/", ' + + '"!=" rules eliminate all its servers' + } + }); + + assertLocalityFromRules({ + t: t, + dc: dc, + exprs: [ + 'role==/^data/', + 'instance==webhead3' + ], + err: { + message: 'cannot satisfy affinity rule "instance==webhead3", ' + + 'its servers (dddddddd-9f2d-11e7-9c48-2b216115a37d) do ' + + 'not intersect with servers from previous rules ' + + '(aaaaaaaa-9f2c-11e7-8d2a-7b05237c283d, ' + + 'bbbbbbbb-9f42-11e7-a98f-375a35af4e58)' + } + }); + + assertLocalityFromRules({ + t: t, + dc: dc, + exprs: [ + 'instance==webhead*', + 'role==database', + 'instance!=webhead3' + ], + // We expect the 'near' to be the first VM from either CNa or CNb + // (randomly selected). + locality: [ + { + strict: true, + far: ['9fec2192-9f2d-11e7-b081-fbba6b455dbd'], + near: ['02655ed2-9f2c-11e7-a596-8f1e118e27d6'] + }, + { + strict: true, + far: ['9fec2192-9f2d-11e7-b081-fbba6b455dbd'], + near: ['0dab5820-9f2d-11e7-a5ae-8b56e717c599'] + } + ] + }); + + t.end(); + }); +}); diff --git a/test/machines.71.test.js b/test/machines.71.test.js index ef8c541c..c62de342 100644 --- a/test/machines.71.test.js +++ b/test/machines.71.test.js @@ -5,7 +5,7 @@ */ /* - * Copyright (c) 2014, Joyent, Inc. + * Copyright (c) 2017, Joyent, Inc. */ var util = require('util'); @@ -289,7 +289,7 @@ test('Wait for img create job', function (t) { IMAGE_UUID = null; } - t.ifError(err, 'create image job'); + t.ifError(err, 'create image job ' + IMAGE_JOB_UUID); t.end(); }); } else { diff --git a/test/machines.test.js b/test/machines.test.js index 65165577..5d488c7e 100644 --- a/test/machines.test.js +++ b/test/machines.test.js @@ -1323,6 +1323,16 @@ test('ListMachines with packageless/nicless machine', function (t) { test('Delete packageless/nicless machine', deleteMachine); +test('Affinity tests', function (t) { + var affinityTest = require('./machines/affinity'); + + affinityTest(t, CLIENT, OTHER, IMAGE_UUID, SDC_128.uuid, HEADNODE_UUID, + function () { + t.end(); + }); +}); + + test('teardown', function (t) { common.deletePackage(CLIENT, SDC_256, function (err) { common.deletePackage(CLIENT, SDC_256_INACTIVE, function (err2) { diff --git a/test/machines/affinity.js b/test/machines/affinity.js new file mode 100644 index 00000000..5aa9d6b8 --- /dev/null +++ b/test/machines/affinity.js @@ -0,0 +1,184 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/* + * Copyright 2017, Joyent, Inc. + */ + +var clone = require('clone'); +var format = require('util').format; +var libuuid = require('libuuid'); +var machinesCommon = require('./common'); + + +// --- Globals + +var CONTAINER_PREFIX = 'sdccloudapitest_affinity_'; + + +// --- Tests + +module.exports = +function (suite, client, other, imgUuid, pkgUuid, headnodeUuid, cb) { + var VM_UUID; + var VM2_UUID; + + + function createArgs(affinity) { + return { + image: imgUuid, + package: pkgUuid, + name: CONTAINER_PREFIX + libuuid.create().split('-')[0], + server_uuid: headnodeUuid, + firewall_enabled: true, + affinity: [affinity] + }; + } + + + // This should fail: no container with name 'sdccloudapitest_affinity_*'. + suite.test('CreateMachine with affinity "container==' + CONTAINER_PREFIX + + '*"', function (t) { + + var args = createArgs('container==' + CONTAINER_PREFIX + '*'); + + client.post('/my/machines', args, function (err, req, res, body) { + t.ok(err, 'VM with false affinity should fail'); + t.end(); + }); + }); + + + // This should work: no container with name 'sdccloudapitest_affinity_*'. + // This behaviour was changed in DAPI-306. + suite.test('CreateMachine with affinity "container!=' + CONTAINER_PREFIX + + '*"', function (t) { + + var args = createArgs('container!=' + CONTAINER_PREFIX + '*'); + + client.post('/my/machines', args, function (err, req, res, vm) { + t.ifError(err, 'VM affinity should succeed'); + t.ok(vm, 'VM should be created'); + + VM_UUID = vm.id; + + t.end(); + }); + }); + + + suite.test('Wait for running, then clean up', function (t) { + machinesCommon.waitForRunningMachine(client, VM_UUID, function (err) { + t.ifError(err); + + client.del('/my/machines/' + VM_UUID, function (err2, req, res) { + t.ifError(err2, 'Cleanup test container'); + t.end(); + }); + }); + }); + + + // This should fail: no container with label foo=bar2. + suite.test('CreateMachine with affinity "foo==bar2', function (t) { + var args = createArgs('foo==bar2'); + + client.post('/my/machines', args, function (err, req, res, vm) { + t.ok(err, 'VM with false affinity should fail'); + t.end(); + }); + }); + + + // This should work: no container with label foo=bar2, but *soft* affinity. + suite.test('CreateMachine with affinity "foo==~bar2"', function (t) { + var args = createArgs('foo==~bar2'); + + client.post('/my/machines', args, function (err, req, res, vm) { + t.ifError(err, 'VM affinity should succeed'); + t.ok(vm, 'VM should be created'); + + VM_UUID = vm.id; + + t.end(); + }); + }); + + + suite.test('Wait for running, then clean up', function (t) { + machinesCommon.waitForRunningMachine(client, VM_UUID, function (err) { + t.ifError(err); + + client.del('/my/machines/' + VM_UUID, function (err2, req, res) { + t.ifError(err2, 'Cleanup test container'); + t.end(); + }); + }); + }); + + + // This should work: no container with label foo=bar1. + suite.test('CreateMachine with affinity "foo!=bar1"', function (t) { + var args = createArgs('foo!=bar1'); + args['tag.foo'] = 'bar2'; + + client.post('/my/machines', args, function (err, req, res, vm) { + t.ifError(err, 'VM affinity should succeed'); + t.ok(vm, 'VM should be created'); + + VM_UUID = vm.id; + + t.end(); + }); + }); + + + suite.test('Wait for running', function (t) { + machinesCommon.waitForRunningMachine(client, VM_UUID, function (err) { + t.ifError(err); + t.end(); + }); + }); + + + // Now this one should work: we *do* have a container with label foo=bar2 + // (created in previous step). + suite.test('CreateMachine with affinity "foo==bar2"', function (t) { + var args = createArgs('foo==bar2'); + + client.post('/my/machines', args, function (err, req, res, vm) { + t.ifError(err, 'VM affinity should succeed'); + t.ok(vm, 'VM should be created'); + + VM2_UUID = vm.id; + + t.end(); + }); + }); + + + suite.test('Wait for running, then clean up', function (t) { + machinesCommon.waitForRunningMachine(client, VM2_UUID, function (err) { + t.ifError(err); + + client.del('/my/machines/' + VM2_UUID, function (err2, req, res) { + t.ifError(err2, 'Cleanup test container'); + t.end(); + }); + }); + }); + + + suite.test('Clean up remaining test container', function (t) { + client.del('/my/machines/' + VM_UUID, function (err, req, res) { + t.ifError(err, 'Cleanup test container'); + t.end(); + }); + }); + + + return cb(); +}; diff --git a/tools/jsl.node.conf b/tools/jsl.node.conf index c288715c..fddac08c 100644 --- a/tools/jsl.node.conf +++ b/tools/jsl.node.conf @@ -123,6 +123,7 @@ +define Buffer +define JSON +define Math ++define Set ### JavaScript Version # To change the default JavaScript version: