├── .gitignore ├── LICENSE ├── README.md ├── Setup.hs ├── WarewulfInventory.hs ├── ansible-warewulf-inventory.cabal ├── bright.py ├── bright_devices.py ├── slurm.py └── warewulf.pl /.gitignore: -------------------------------------------------------------------------------- 1 | /dist/ 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016-2020, Dylan Simon 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above 12 | copyright notice, this list of conditions and the following 13 | disclaimer in the documentation and/or other materials provided 14 | with the distribution. 15 | 16 | * Neither the name of Dylan Simon nor the names of other 17 | contributors may be used to endorse or promote products derived 18 | from this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ansible utilities useful for HPC clusters 2 | 3 | ## Module and inventory for Warewulf systems manager 4 | 5 | The perl script `warewulf.pl` can be used as an ansible module as well as a dynamic inventory. 6 | To use it, pass it to ansible's `-M` (library) and/or `-i` (inventory) arguments, or place it in the corresponding directories. 7 | 8 | Example: 9 | 10 | ``` 11 | - name: boot all nodes from the local disk 12 | hosts: warewulf_node 13 | local_action: warewulf node={{inventory_hostname}} bootlocal=EXIT 14 | ``` 15 | 16 | This is equivalent to (though rather slower than) `wwsh provision set --bootlocal=EXIT`. 17 | 18 | Requirements: 19 | 20 | * [Warewulf](http://warewulf.lbl.gov/trac), with properly configured and populated database 21 | * Run ansible (or, in the case of the module, remotely) on the Warewulf host 22 | * Perl modules: JSON, List::MoreUtils 23 | 24 | ### Dynamic inventory 25 | 26 | When used as a dynamic inventory script (i.e., called with `--list` or `--host HOST`), `warewulf.pl` generates a list of hosts based on Warewulf's node database and vnfs list. 27 | 28 | It generates the following host groups: 29 | 30 | * warewulf_node: all nodes 31 | * enabled: enabled nodes 32 | * one per warewulf cluster 33 | * one per warewulf group 34 | * warewulf_vnfs: all VNFS images (using chroot) 35 | 36 | It provides the following host variables: 37 | 38 | * warewulf_domain 39 | * warewulf_netdevs: one key per interface, each with all defined values 40 | 41 | #### Alternate Haskell version 42 | 43 | There is also a Haskell application that provides equivalent dynamic inventory functionality. 44 | It gets information from "wwsh node print" and "wwsh vnfs list", but otherwise behaves similarly to the perl version. 45 | There is probably no reason to use it. 46 | 47 | ### Module 48 | 49 | As a module, `warewulf.pl` allows various interactions with the warewulf database. 50 | You must pass one of the search arguments (`node`, `vnfs`, `bootstrap`, `file`) to specify which objects to operate on. 51 | The argument is a name or list of name patterns by default (but you can search on other fields using `lookup=FIELD`. 52 | You may then additionally specify parameters to set values or take actions. 53 | Generally this corresponds to the `wwsh` interface. 54 | 55 | * node=ITEM 56 | * nodename=STR 57 | * cluster=STR 58 | * domain=STR 59 | * groups=LIST 60 | * groupadd=LIST 61 | * groupdel=LIST 62 | * netdev=STR, netadd=STR, netdel=STR 63 | * netrename=STR 64 | * hwaddr=STR 65 | * hwprefix=STR 66 | * ipaddr=STR 67 | * netmask=STR 68 | * network=STR 69 | * gateway=STR 70 | * fqdn=STR 71 | * mtu=STR 72 | * enabled=BOOL 73 | * bootstrapid=STR 74 | * vnfsid=STR 75 | * fileids=LIST 76 | * fileidadd=LIST 77 | * fileiddel=LIST 78 | * files=LIST 79 | * fileadd=LIST 80 | * filedel=LIST 81 | * console=STR 82 | * kargs=LIST 83 | * pxelinux=STR 84 | * master=LIST 85 | * postnetdown=BOOL 86 | * preshell=BOOL 87 | * postshell=BOOL 88 | * selinux=DISABLED|ENABLED|ENFORCED 89 | * bootlocal=UNDEF|NORMAL|EXIT 90 | * ipmi_ipaddr=STR 91 | * ipmi_netmask=STR 92 | * ipmi_username=STR 93 | * ipmi_password=STR 94 | * ipmi_uid=STR 95 | * ipmi_proto=STR 96 | * ipmi_autoconfig=STR 97 | * ipmi=COMMAND 98 | * vnfs 99 | * name=STR 100 | * checksum=STR 101 | * chroot=STR 102 | * size=STR 103 | * vnfs_import=FILE 104 | * vnfs_export=FILE 105 | * bootstrap 106 | * name=STR 107 | * checksum=STR 108 | * size=STR 109 | * bootstrap_import=FILE 110 | * bootstrap_export=PATH 111 | * delete_local_bootstrap=1 112 | * build_local_bootstrap=1 113 | * file 114 | * name=STR 115 | * mode=STR 116 | * checksum=STR 117 | * uid=STR 118 | * gid=STR 119 | * size=STR 120 | * path=STR 121 | * format=STR 122 | * interpreter=STR 123 | * origin=STR 124 | * sync=1 125 | * file_import=FILE 126 | * file_export=FILE 127 | * dhcp=update|restart 128 | * pxe=update|delete 129 | 130 | Additionally, you can specify `new=1` to create the object if it none exists, or `delete=1` to delete matching objects. 131 | Finally, if you specify `get=1` the final values of all matching objects will be included in the result, with all properties. 132 | For example, `warewulf node=node01 get=1` may return: 133 | 134 | ``` 135 | { 136 | "changed" : 0, 137 | "node" : { 138 | "node01" : { 139 | "_id" : "1", 140 | "_type" : "node", 141 | "nodename" : "node01", 142 | "name" : ["node01"], 143 | "bootstrapid" : "2", 144 | "vnfsid" : "3" 145 | "fileids" : ["4", "5"], 146 | "netdevs" : { 147 | "eth0" : { 148 | "name" : "eth0", 149 | "netmask" : "255.255.255.0", 150 | "ipaddr" : "10.0.0.1" 151 | } 152 | }, 153 | "_ipaddr" : ["10.0.0.1"], 154 | "bootlocal" : 0, 155 | "bootloader" : "sda", 156 | "diskpartition" : "sda", 157 | "diskformat" : ["sda1", "sda2"], 158 | "filesystems" : [ 159 | "mountpoint=/:dev=sda1:type=ext4:size=fill", 160 | "dev=sda2:type=swap:size=2048" 161 | ] 162 | } 163 | } 164 | } 165 | ``` 166 | 167 | Since this module expects to run on the Warewulf server, often it should be used with `local_action` delegation. 168 | 169 | ## Module for Slurm workload manager 170 | 171 | The python module `slurm.py` provides an interface to [Slurm](https://slurm.schedmd.com/). 172 | Currently it provides an interface to `sacctmgr` functionality to manage users and accounts. 173 | See the documentation within the module. 174 | 175 | ## Module for Bright Cluster module 176 | 177 | The python module `bright.py` provides an interface to [Bright Cluster Manager](https://www.brightcomputing.com/documentation) configuration. 178 | -------------------------------------------------------------------------------- /Setup.hs: -------------------------------------------------------------------------------- 1 | import Distribution.Simple 2 | main = defaultMain 3 | -------------------------------------------------------------------------------- /WarewulfInventory.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE ConstraintKinds #-} 2 | {-# LANGUAGE OverloadedStrings #-} 3 | {-# LANGUAGE RecordWildCards #-} 4 | {-# LANGUAGE TupleSections #-} 5 | {-# LANGUAGE ViewPatterns #-} 6 | 7 | import Control.Arrow (first) 8 | import Control.Monad ((<=<)) 9 | import qualified Data.Aeson.Types as JSON 10 | import Data.Aeson.Encode.Pretty (encodePretty) 11 | import qualified Data.ByteString.Lazy.Char8 as BSLC 12 | import Data.Hashable (Hashable) 13 | import qualified Data.HashMap.Strict as Map 14 | import Data.List (foldl', stripPrefix) 15 | import qualified Data.Text as T 16 | import qualified System.Console.GetOpt as Opt 17 | import System.Environment (getProgName, getArgs) 18 | import System.Exit (exitFailure) 19 | import System.IO (stderr, hPutStrLn) 20 | import System.Process (readProcess) 21 | import Text.Read (readMaybe) 22 | 23 | data Option 24 | = OptionList 25 | | OptionHost String 26 | 27 | options :: [Opt.OptDescr Option] 28 | options = 29 | [ Opt.Option [] ["list"] 30 | (Opt.NoArg OptionList) 31 | "List inventory" 32 | , Opt.Option [] ["host"] 33 | (Opt.ReqArg OptionHost "HOST") 34 | "List host variables" 35 | ] 36 | 37 | type Map = Map.HashMap -- Map 38 | type Mappable k = (Hashable k, Eq k) -- Ord k 39 | 40 | maybeList :: (a -> b) -> Maybe a -> [b] -> [b] 41 | maybeList = maybe id . (.) (:) 42 | 43 | mapMaybeWhile :: (a -> Maybe b) -> [a] -> ([b], [a]) 44 | mapMaybeWhile f ((f -> Just a):l) = first (a:) $ mapMaybeWhile f l 45 | mapMaybeWhile _ l = ([], l) 46 | 47 | mapUnionWith :: Mappable k => (v -> v -> v) -> (a -> Map k v) -> [a] -> Map k v 48 | mapUnionWith u f = foldl' (\m -> Map.unionWith u m . f) Map.empty 49 | 50 | mapUnion :: (Mappable k, Monoid v) => (a -> Map k v) -> [a] -> Map k v 51 | mapUnion = mapUnionWith mappend 52 | 53 | splitEq :: String -> Maybe (String, String) 54 | splitEq (' ':'=':' ':r) = return ([], r) 55 | splitEq (c:r) = first (c:) <$> splitEq r 56 | splitEq [] = Nothing 57 | 58 | unDef :: String -> Maybe T.Text 59 | unDef "UNDEF" = Nothing 60 | unDef s = Just $ T.pack s 61 | 62 | data Object 63 | = Node 64 | { objectName :: !T.Text 65 | , objectId :: !Int 66 | , nodeCluster :: Maybe T.Text 67 | , nodeDomain :: Maybe T.Text 68 | , nodeGroups :: [T.Text] 69 | , nodeEnabled :: !Bool 70 | , nodeNetDevs :: Map T.Text [(T.Text, T.Text)] 71 | } 72 | | VNFS 73 | { objectName :: !T.Text 74 | -- objectId :: !Int -- not included in wwsh vnfs list 75 | , vnfsSize :: !Float 76 | , vnfsPath :: !T.Text 77 | } 78 | deriving (Show) 79 | 80 | parseNode :: String -> [(T.Text, String)] -> Maybe Object 81 | parseNode n d = Node (T.pack n) 82 | <$> (readMaybe =<< get "ID") 83 | <*> (unDef <$> get "CLUSTER") 84 | <*> (unDef <$> get "DOMAIN") 85 | <*> (maybe [] (T.split (','==)) . unDef <$> get "GROUPS") 86 | <*> (bool =<< get "ENABLED") 87 | <*> Just (mapUnion netdev d) 88 | where 89 | get k = lookup k d 90 | bool "TRUE" = Just True 91 | bool "FALSE" = Just False 92 | bool _ = Nothing 93 | netdev (T.split ('.'==) -> [i, k], unDef -> Just v) = Map.singleton i [(T.toLower k, v)] 94 | netdev _ = Map.empty 95 | 96 | parseNodes :: [String] -> [Object] 97 | parseNodes [] = [] 98 | parseNodes (('#':'#':'#':'#':' ':(reverse -> '#':(dropWhile ('#'==) -> ' ':(reverse -> n)))) 99 | : (mapMaybeWhile (splitEq <=< stripPrefix (n++": ") . dropWhile (' ' ==)) -> 100 | (parseNode n . map (first $ T.dropWhileEnd (' '==) . T.pack) -> Just a, r))) = 101 | a : parseNodes r 102 | parseNodes (s:_) = error $ "error parsing wwsh node output at line: " ++ s 103 | 104 | getNodes :: [String] -> IO [Object] 105 | getNodes args = parseNodes . lines <$> readProcess "wwsh" ("node" : "print" : args) "" 106 | 107 | splitVNFS :: String -> Maybe (String, (Float, String)) 108 | splitVNFS (' ':(reads -> [(z, ' ':(dropWhile (' '==) -> p@('/':_)))])) = return ([], (z, p)) 109 | splitVNFS (c:r) = first (consStrip c) <$> splitVNFS r where 110 | consStrip ' ' [] = [] 111 | consStrip h t = h : t 112 | splitVNFS [] = Nothing 113 | 114 | parseVNFS :: String -> Object 115 | parseVNFS (splitVNFS -> Just (unDef -> Just n, (z, unDef -> Just p))) = VNFS n z p 116 | parseVNFS s = error $ "error parsing wwsh vnfs output line: " ++ s 117 | 118 | parseVNFSs :: [String] -> [Object] 119 | parseVNFSs ((words -> ["VNFS", "NAME", "SIZE", "(M)", "CHROOT", "LOCATION"]):l) = map parseVNFS l 120 | parseVNFSs (s:_) = error $ "error parsing wwsh vnfs header: " ++ s 121 | parseVNFSs [] = [] 122 | 123 | variables :: Object -> JSON.Value 124 | variables Node{..} = JSON.object 125 | $ maybeList ("warewulf_domain" JSON..=) nodeDomain 126 | [ "warewulf_id" JSON..= objectId 127 | , "warewulf_netdevs" JSON..= Map.map (JSON.object . map (uncurry (JSON..=))) nodeNetDevs 128 | ] 129 | variables VNFS{..} = JSON.object 130 | [ "warewulf_vnfs_size" JSON..= vnfsSize 131 | , "ansible_host" JSON..= vnfsPath 132 | , "ansible_connection" JSON..= ("chroot" :: T.Text) 133 | ] 134 | 135 | memberships :: Object -> [T.Text] 136 | memberships Node{..} = "warewulf_node" : 137 | (if nodeEnabled then ("enabled" :) else id) 138 | (maybeList id nodeCluster $ nodeGroups) 139 | memberships VNFS{} = ["warewulf_vnfs"] 140 | 141 | getVNFS :: [String] -> IO [Object] 142 | getVNFS args = parseVNFSs . lines <$> readProcess "wwsh" ("vnfs" : "list" : args) "" 143 | 144 | getObjects :: [String] -> IO [Object] 145 | getObjects args = (++) <$> getNodes args <*> getVNFS args 146 | 147 | run :: Option -> IO JSON.Value 148 | run OptionList = do 149 | l <- getObjects [] 150 | return $ JSON.Object 151 | $ Map.insert "_meta" (JSON.object 152 | [ "hostvars" JSON..= JSON.object 153 | (map (\n -> objectName n JSON..= variables n) l) 154 | ]) 155 | $ Map.map JSON.toJSON $ mapUnion (\n -> Map.fromList $ map ((, [objectName n])) $ memberships n) l 156 | run (OptionHost h) = do 157 | l <- getObjects [h] 158 | case l of 159 | [] -> fail "No matching host" 160 | [o] -> return $ variables o 161 | _ -> fail "Multiple matching hosts" 162 | 163 | main :: IO () 164 | main = do 165 | prog <- getProgName 166 | args <- getArgs 167 | case Opt.getOpt Opt.Permute options args of 168 | ([o], [], []) -> do 169 | BSLC.putStrLn . encodePretty =<< run o 170 | (_, _, err) -> do 171 | mapM_ (hPutStrLn stderr) err 172 | hPutStrLn stderr $ Opt.usageInfo ("Usage: " ++ prog ++ " OPTION\n\ 173 | \Ansible dynamic inventory from Warewulf node database\n\ 174 | \https://github.com/dylex/ansible-warewulf-inventory\n") 175 | options 176 | exitFailure 177 | -------------------------------------------------------------------------------- /ansible-warewulf-inventory.cabal: -------------------------------------------------------------------------------- 1 | name: ansible-warewulf-inventory 2 | version: 0.1 3 | synopsis: Ansible dynamic inventory from Warewulf node database 4 | license: BSD3 5 | license-file: LICENSE 6 | author: Dylan Simon 7 | maintainer: dylan@dylex.net 8 | category: System 9 | build-type: Simple 10 | cabal-version: >=1.10 11 | 12 | source-repository head 13 | type: git 14 | location: https://github.com/dylex/ansible-warewulf-inventory 15 | 16 | executable ansible-warewulf-inventory 17 | main-is: WarewulfInventory.hs 18 | default-language: Haskell2010 19 | ghc-options: -Wall 20 | build-depends: 21 | base >=4.8 && <5, 22 | bytestring, 23 | process, 24 | text, 25 | hashable, 26 | unordered-containers, 27 | aeson, 28 | aeson-pretty 29 | -------------------------------------------------------------------------------- /bright.py: -------------------------------------------------------------------------------- 1 | #!/cm/local/apps/python3/bin/python3 2 | # -*- coding: utf-8 -*- 3 | 4 | from __future__ import absolute_import, division, print_function 5 | __metaclass__ = type 6 | 7 | ANSIBLE_METADATA = { 8 | 'metadata_version': '1.1', 9 | 'status': ['preview'], 10 | 'supported_by': 'community' 11 | } 12 | 13 | DOCUMENTATION = """ 14 | --- 15 | module: bright 16 | short_description: Bright cm 17 | description: 18 | - Manage Bright Cluster Management entities. 19 | notes: 20 | - This was created mainly to manage Slurm configuration in Bright 9.0, and has been tested mainly for those types of entities, but should theoretically work for almost any Bright settings. It uses the C(pythoncm) Bright interface, so see the Bright Developer documentation for naming and typing conventions. 21 | - You will likely have to set C(ansible_python_interpreter=/cm/local/apps/python3/bin/python3) if running this on a bright node. 22 | - Since bright is picky about types, if you use templates to set values, you may want to set the ansible configuration C(jinja2_native) to preserve types. 23 | author: 24 | - Dylan Simon (@dylex) 25 | requirements: 26 | - pythoncm 27 | options: 28 | name: 29 | required: false 30 | description: 31 | - The name of the entity to be managed. 32 | - If omitted, just return a list of entities. 33 | type: str 34 | key: 35 | description: 36 | - The uniqueKey of the entity to be managed. 37 | type: int 38 | type: 39 | description: 40 | - The type (using the pythoncm CamelCase name) of the entity to be managed, e.g., C(PhysicalNode), C(JobQueue), etc. 41 | - Required unless key is specified. If both key and type are specified, both must match. 42 | state: 43 | description: Intended state 44 | choices: [ absent, present ] 45 | default: present 46 | clone: 47 | type: str 48 | description: 49 | - When creating an entity C(state=present), clone it from existing entity instead of creating from scratch. 50 | attrs: 51 | type: dict 52 | description: 53 | - Attributes to set on the entity C(state=present). 54 | - Referenced entities can be specified by name. Contained entities can be specified by nested dicts, including C(childType) to create specific types. 55 | - Lists are replaced entirely. Lists of contained entities can be selectived updated by dicts keyed on the name of the entity. 56 | default: {} 57 | """ 58 | 59 | EXAMPLES = """ 60 | - bright: 61 | type: SlurmWlmCluster 62 | name: slurm 63 | attrs: 64 | gresTypes: [gpu] 65 | cgroups: 66 | constrainCores: true 67 | vars: 68 | ansible_python_interpreter: /cm/local/apps/python3/bin/python3 69 | 70 | - bright: 71 | type: SlurmJobQueue 72 | name: gen 73 | attrs: 74 | maxTime: 7-0 75 | allowAccounts: ALL 76 | options: [QoS=gen] 77 | - bright: 78 | type: ConfigurationOverlay 79 | name: slurm-client-category 80 | clone: slurm-client 81 | attrs: 82 | categories: 83 | - category1 84 | - category2 85 | roles: 86 | slurmclient: 87 | childType: SlurmClientRole 88 | wlmCluster: slurm 89 | realMemory: 256000 90 | coresPerSocket: 20 91 | sockets: 2 92 | features: [skylake,ib] 93 | queues: [gen] 94 | genericResources: 95 | - alias: gpu0 96 | name: gpu 97 | count: '1' 98 | file: /dev/nvidia0 99 | type: v100 100 | """ 101 | 102 | 103 | RETURN = """ 104 | name: 105 | description: resolved name of the entity 106 | type: str 107 | returned: when entity exists at any point 108 | key: 109 | description: resolved uniqueKey of the entity 110 | type: int 111 | returned: when entity exists at any point 112 | type: 113 | description: specific type of entity 114 | type: str 115 | returned: when entity exists at any point 116 | entity: 117 | description: full entity 118 | type: dict 119 | returned: when entity exists at any point 120 | entities: 121 | description: all entries of given type 122 | type: list 123 | returned: when name is omitted 124 | """ 125 | 126 | import traceback 127 | 128 | from ansible.module_utils.basic import AnsibleModule, missing_required_lib 129 | from ansible.module_utils._text import to_native 130 | 131 | CM_IMP_ERR = None 132 | try: 133 | import pythoncm.cluster 134 | 135 | HAS_CM = True 136 | except ImportError: 137 | CM_IMP_ERR = traceback.format_exc() 138 | HAS_CM = False 139 | 140 | def getitem(l, i): 141 | try: 142 | return l[i] 143 | except IndexError: 144 | return None 145 | 146 | class Entity(object): 147 | types = [m for m in dir(pythoncm.entity) if isinstance(getattr(pythoncm.entity, m), type)] if HAS_CM else [] 148 | 149 | def __init__(self, module): 150 | self.module = module 151 | self.state = module.params['state'] 152 | self.name = module.params['name'] 153 | self.key = module.params['key'] 154 | self.type = module.params['type'] 155 | self.clone = module.params['clone'] 156 | self.attrs = module.params['attrs'] 157 | 158 | def absent(self): 159 | if not self.entity: 160 | return 161 | 162 | self.result['changed'] = True 163 | if self.module.check_mode: 164 | return 165 | 166 | r = self.entity.remove() 167 | if not r.success: 168 | self.result['failed'] = True 169 | 170 | def gettype(self, typ): 171 | return getattr(pythoncm.entity, typ) 172 | 173 | def getentity(self, name, typ): 174 | e = self.cluster.get_by_name(name, typ) 175 | if not e: 176 | raise KeyError("%s:%s"%(typ, name)) 177 | return e 178 | 179 | def makeentity(self, cur, val, field, name=None): 180 | from pythoncm.entity.meta_data import MetaData 181 | try: 182 | MetaData = MetaData.Type 183 | except AttributeError: 184 | pass 185 | 186 | if val is None: 187 | return 188 | 189 | elif field.kind == MetaData.RESOLVE: 190 | if type(val) is not str: 191 | raise TypeError('Expected %s name, not %r'%(field.instance, val)) 192 | return self.getentity(val, field.instance) 193 | 194 | elif field.kind == MetaData.ENTITY: 195 | if type(val) is str: 196 | val = {'name':val} 197 | if type(val) is not dict: 198 | raise TypeError('Expected %s attributes, not %r'%(field.instance, val)) 199 | if not cur: 200 | cur = self.gettype(val.get('childType', val.get('baseType', field.instance)))(cluster = self.cluster) 201 | self.changed.add(cur) 202 | if name and hasattr(cur, 'name'): 203 | cur.name = name 204 | self.setentity(cur, val) 205 | return cur 206 | 207 | def setentity(self, ent, src): 208 | fields = {f.name: f for f in ent.fields()} 209 | for k, v in src.items(): 210 | c = getattr(ent, k) 211 | f = fields[k] 212 | if f.instance: 213 | if f.vector: 214 | if type(v) is list: 215 | v = [self.makeentity(getitem(c, i), x, f) for (i, x) in enumerate(v)] 216 | elif type(v) is dict: 217 | l = c.copy() 218 | for n, e in v.items(): 219 | try: 220 | i = next(i for (i, x) in enumerate(l) if x and x.name == n) 221 | except StopIteration: 222 | i = len(l) 223 | l.append(None) 224 | l[i] = self.makeentity(l[i], e, f, n) 225 | v = l 226 | elif type(v) is str and issubclass(self.gettype(f.instance), pythoncm.entity.Device): 227 | from pythoncm.device_selection import DeviceSelection 228 | d = DeviceSelection(self.cluster) 229 | #d.add_devices_in_text_range(v, True) 230 | d.add_devices(pythoncm.namerange.expand.Expand.expand(v), True) 231 | v = d.get_sorted_by_name() 232 | else: 233 | raise TypeError('%s: expected %s list'%(k, f.instance)) 234 | else: 235 | v = self.makeentity(c, v, f) 236 | if c != v: 237 | if f.readonly: 238 | raise PermissionError("%s is readonly"%(k)) 239 | self.changed.add(ent) 240 | setattr(ent, k, v) 241 | 242 | def present(self): 243 | self.changed = set() 244 | 245 | if not self.entity: 246 | if self.clone: 247 | clone = self.getentity(self.clone, self.type) 248 | self.entity = clone.clone() 249 | else: 250 | self.entity = self.gettype(self.type)(cluster = self.cluster) 251 | self.changed.add(self.entity) 252 | if hasattr(self.entity, 'name'): 253 | self.entity.name = self.name 254 | 255 | self.setentity(self.entity, self.attrs) 256 | 257 | if self.changed: 258 | self.result['changed'] = True 259 | err = self.entity.check() 260 | if err: 261 | self.result['failed'] = True 262 | self.result['msg'] = err 263 | elif not self.module.check_mode: 264 | res = self.entity.commit(wait_for_remote_update=True) 265 | if not res.good: 266 | self.result['failed'] = True 267 | self.result['msg'] = str(res) 268 | 269 | def run(self): 270 | self.cluster = pythoncm.cluster.Cluster() # TODO: settings 271 | if self.key is not None: 272 | self.entity = self.cluster.get_by_key(self.key) 273 | if self.type and self.entity.baseType != self.type and self.entity.childType != self.type: 274 | return {'failed': True, 'msg': 'key/type mismatch'} 275 | elif self.name is not None: 276 | self.entity = self.cluster.get_by_name(self.name, self.type) 277 | else: 278 | l = self.cluster.get_by_type(self.gettype(self.type)) 279 | return {'entities': [e.to_dict() for e in l]} 280 | 281 | self.result = {} 282 | try: 283 | getattr(self, self.state)() 284 | except Exception as e: 285 | self.result['failed'] = True 286 | self.result['msg'] = to_native(e) 287 | if self.entity: 288 | self.result['entity'] = self.entity.to_dict() 289 | self.result['name'] = self.entity.resolve_name 290 | self.result['key'] = self.entity.uniqueKey 291 | self.result['type'] = self.entity.childType or self.entity.baseType 292 | return self.result 293 | 294 | def main(): 295 | module = AnsibleModule( 296 | argument_spec=dict( 297 | name=dict(type='str'), 298 | key=dict(type='int'), 299 | type=dict(type='str', choices=Entity.types if HAS_CM else None), 300 | state=dict(type='str', default='present', choices=['absent','present']), 301 | clone=dict(type='str'), 302 | attrs=dict(type='dict', default={}), 303 | ), 304 | mutually_exclusive=[('name','key')], 305 | required_one_of=[('type','key')], 306 | supports_check_mode=True, 307 | ) 308 | 309 | if not HAS_CM: 310 | module.fail_json(msg=missing_required_lib('pythoncm'), 311 | exception=CM_IMP_ERR) 312 | 313 | result = Entity(module).run() 314 | module.exit_json(**result) 315 | 316 | if __name__ == '__main__': 317 | main() 318 | -------------------------------------------------------------------------------- /bright_devices.py: -------------------------------------------------------------------------------- 1 | #!/cm/local/apps/python3/bin/python3 2 | # -*- coding: utf-8 -*- 3 | 4 | from __future__ import absolute_import, division, print_function 5 | __metaclass__ = type 6 | 7 | ANSIBLE_METADATA = { 8 | 'metadata_version': '1.0', 9 | 'status': ['preview'], 10 | 'supported_by': 'community' 11 | } 12 | 13 | DOCUMENTATION = """ 14 | --- 15 | module: bright_devices 16 | short_description: Bright cm device selection 17 | description: 18 | - Lookup Bright Cluster Management devices 19 | notes: 20 | - This is an interface to bright DeviceSelection. It could probably be generalized to other entity types. 21 | - Any of the arguments can take a comma-separated list 22 | - If mode is intersection and there are no selectors, all devices are returned 23 | - You will likely have to set C(ansible_python_interpreter=/cm/local/apps/python3/bin/python3) if running this on a bright node. 24 | author: 25 | - Dylan Simon (@dylex) 26 | requirements: 27 | - pythoncm 28 | options: 29 | mode: 30 | choices: [ union, intersection ] 31 | default: union 32 | description: How to combine multiple selectors (lists of items are always unioned) 33 | only_nodes: 34 | type: bool 35 | default: false 36 | description: Only include nodes (rather than all devices) 37 | full: 38 | type: bool 39 | default: false 40 | description: Include full device entities, along with names and keys 41 | category: 42 | description: select nodes which use the specified category 43 | type: str 44 | nodegroup: 45 | description: select nodes which are contained in the specified node group 46 | type: str 47 | configuration_overlay: 48 | description: select nodes which are contained in the specified configuration overlay 49 | type: str 50 | name: 51 | description: select nodes by name or text range 52 | type: str 53 | softwareimage: 54 | description: select nodes which use the specified software image 55 | type: str 56 | """ 57 | 58 | EXAMPLES = """ 59 | - bright_devices: 60 | mode: intersection 61 | category: worker 62 | name: worker[10-20],worker[50-90] 63 | ansible_python_interpreter: /cm/local/apps/python3/bin/python3 64 | register: devices 65 | """ 66 | 67 | 68 | RETURN = """ 69 | names: 70 | description: list of matching device names, sorted by name 71 | type: list 72 | returned: always 73 | keys: 74 | description: list of matching device keys, sorted by name 75 | type: list 76 | returned: always 77 | devices: 78 | description: list of matching device entities, sorted by name 79 | type: list 80 | returned: when full 81 | """ 82 | 83 | import traceback 84 | 85 | from ansible.module_utils.basic import AnsibleModule, missing_required_lib 86 | from ansible.module_utils._text import to_native 87 | 88 | CM_IMP_ERR = None 89 | try: 90 | import pythoncm.cluster 91 | from pythoncm.device_selection import DeviceSelection 92 | 93 | HAS_CM = True 94 | except ImportError: 95 | CM_IMP_ERR = traceback.format_exc() 96 | HAS_CM = False 97 | 98 | def add_softwareimage(devices, name): 99 | img = self.cluster.get_by_name(name, 'SoftwareImage') 100 | if not img: 101 | raise KeyError("SoftwareImage %s not found"%(name)) 102 | devices.add_devices(img.nodes) 103 | 104 | def add_names(devices, name): 105 | devices.add_devices(pythoncm.namerange.expand.Expand.expand(name), True) 106 | 107 | def main(): 108 | module = AnsibleModule( 109 | argument_spec=dict( 110 | mode=dict(type='str', default='union', choices=['union','intersection']), 111 | only_nodes=dict(type='bool', default=False), 112 | full=dict(type='bool', default=False), 113 | category=dict(type='str'), 114 | nodegroup=dict(type='str'), 115 | configuration_overlay=dict(type='str'), 116 | name=dict(type='str'), 117 | softwareimage=dict(type='str'), 118 | ), 119 | supports_check_mode=True, 120 | ) 121 | 122 | if not HAS_CM: 123 | module.fail_json(msg=missing_required_lib('pythoncm'), 124 | exception=CM_IMP_ERR) 125 | 126 | cluster = pythoncm.cluster.Cluster() # TODO: settings 127 | intersect = module.params['mode'] == 'intersection' 128 | only_nodes = module.params['only_nodes'] 129 | devices = None 130 | 131 | types = { 132 | 'category': DeviceSelection.add_category 133 | , 'nodegroup': DeviceSelection.add_nodegroup 134 | , 'configuration_overlay': DeviceSelection.add_configuration_overlay 135 | , 'name': add_names #DeviceSelection.add_devices_in_text_range 136 | , 'softwareimage': add_softwareimage 137 | } 138 | 139 | for p, f in types.items(): 140 | v = module.params[p] 141 | if not v: continue 142 | d = DeviceSelection(cluster, only_nodes=only_nodes) 143 | for n in v.split(','): 144 | f(d, n) 145 | if not devices: 146 | devices = d 147 | elif intersect: 148 | devices = devices.intersection(d) 149 | else: 150 | devices = devices.union(d) 151 | 152 | if not devices: 153 | devices = DeviceSelection(cluster) 154 | if intersect: 155 | devices.add_devices(cluster.get_by_type(pythoncm.entity.Node if only_nodes else pythoncm.entity.Device)) 156 | 157 | devices = devices.get_sorted_by_name() 158 | result = { 159 | 'names': [d.resolve_name for d in devices] 160 | , 'keys': [d.uniqueKey for d in devices] 161 | } 162 | if module.params['full']: 163 | result['devices'] = [d.to_dict() for d in devices] 164 | module.exit_json(**result) 165 | 166 | if __name__ == '__main__': 167 | main() 168 | -------------------------------------------------------------------------------- /slurm.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | ANSIBLE_METADATA = { 4 | 'metadata_version': '1.1', 5 | 'status': ['preview'], 6 | 'supported_by': 'community' 7 | } 8 | 9 | DOCUMENTATION = """ 10 | --- 11 | module: slurm 12 | short_description: Manage slurm clusters 13 | author: Dylan Simon (@dylex) 14 | description: 15 | - Provide an interface to sacctmgr, mostly mirrors the command-line interface 16 | options: 17 | state: 18 | description: 19 | - The action to take, either add/modify, delete, or list. 20 | - Equivalent to the first argument to sacctmgr. 21 | choices: ["present", "absent", "list"] 22 | default: present or list 23 | entity: 24 | description: 25 | - The type of entity to list or modify. 26 | - Equivalent to the second argument to sacctmgr. 27 | - To manipulate associations, specify "parent=" with account, "account=" with user, "cluster=" with resource, or "account=" with transactions. 28 | required: true 29 | choices: ["cluster", "qos", "resource", "account", "user", "events", "reservation", "transactions", "tres", "wckey"] 30 | name: 31 | description: 32 | - The name of the entity to modify, for cluster, qos, resource, account, user, reservation, tres, or wckey 33 | required: false 34 | args: 35 | type: dict 36 | description: 37 | - Other arguments are the same as to sacctmgr, except all are lower-case. 38 | - Rather than WithClusters or WithAssoc, if you specify "parent=", "account=", or "cluster=" they will be inferred. 39 | - For some arguments, sacctmgr may report values in a different format than it accepts them. In this case, you can specify a dict with C(set) as the value to set, and C(test) as the value to compare against. 40 | - For TRES values, like sacctmgr, you must explicitly set C(res=-1) to clear resource contraints. These will be ignored when comparing. 41 | """ 42 | 43 | EXAMPLES = """ 44 | - name: create slurm qos 45 | hosts: slurm 46 | slurm: 47 | entity: qos 48 | state: present 49 | name: defq 50 | args: 51 | priority: 10 52 | maxwall: 7-00:00:00 53 | grptres: node=1000,mem=10000000M 54 | maxtresperuser: cpu=1000,node=-1 55 | gracetime: 56 | set: 60 57 | test: 00:01:00 58 | 59 | - name: list all user associations 60 | hosts: slurm 61 | slurm: 62 | entity: user 63 | state: list 64 | args: 65 | account: '' 66 | 67 | - name: create slurm user association 68 | hosts: slurm 69 | slurm: 70 | entity: user 71 | state: present 72 | name: {{user}} 73 | args: 74 | account: {{slurm_account}} 75 | """ 76 | 77 | RETURN = """ 78 | entity_type: 79 | description: the list of matching entities, before any actions are taken 80 | type: list 81 | returned: always 82 | """ 83 | 84 | from ansible.module_utils.basic import AnsibleModule 85 | 86 | class Args(list): 87 | """A special list for slurm command line key=value arguments.""" 88 | def add(self, field, value=None): 89 | self.append(field + ('=' + str(value) if value is not None else '')) 90 | 91 | class Parser(object): 92 | """Generic argument parser""" 93 | def editable(self): 94 | return False 95 | 96 | def format(self, sacctmgr): 97 | pass 98 | 99 | def parse(self, sacctmgr): 100 | pass 101 | 102 | def sets(self, sacctmgr): 103 | pass 104 | 105 | class Param(Parser): 106 | """Single argument parameter""" 107 | def __init__(self, name): 108 | self.name = name.lower() 109 | 110 | def parse(self, sacctmgr): 111 | self.val = sacctmgr.params.pop(self.name, None) 112 | 113 | def set(self, sacctmgr, val): 114 | sacctmgr.sets.add(self.name, val) 115 | 116 | class Fmt(Param): 117 | """Parameter that can also be read""" 118 | def __init__(self, name, fmt=None): 119 | super(Fmt, self).__init__(name) 120 | self.fmt = fmt.lower() if fmt else self.name 121 | 122 | def format(self, sacctmgr): 123 | sacctmgr.format.append(self.fmt) 124 | 125 | def parse(self, sacctmgr): 126 | super(Fmt, self).parse(sacctmgr) 127 | 128 | def cur(self, sacctmgr): 129 | return [r[self.name] for r in sacctmgr.cur] 130 | 131 | class RO(Fmt): 132 | """Parameter that can only be read""" 133 | pass 134 | 135 | class Filt(Param): 136 | """Parameter that can only filter results""" 137 | def parse(self, sacctmgr): 138 | super(Filt, self).parse(sacctmgr) 139 | if self.val: 140 | sacctmgr.keys.append(self.fmt) 141 | sacctmgr.filter.add(self.name, self.val) 142 | 143 | class RF(RO, Filt): 144 | """Parameter that can read and filter results""" 145 | def parse(self, sacctmgr): 146 | super(RF, self).parse(sacctmgr) 147 | 148 | class Key(RF): 149 | """Required, primary key parameter""" 150 | def editable(self): 151 | return True 152 | 153 | def parse(self, sacctmgr): 154 | super(Key, self).parse(sacctmgr) 155 | if not self.val and sacctmgr.state != 'list': 156 | sacctmgr.fail('missing required argument: %s' % self.name) 157 | 158 | class RW(Fmt): 159 | """Parameter that can be read and modified""" 160 | def editable(self): 161 | return True 162 | 163 | def eq(self, val): 164 | return str(self.val) == val 165 | 166 | def parse(self, sacctmgr): 167 | if sacctmgr.state == 'present': 168 | super(RW, self).parse(sacctmgr) 169 | if type(self.val) is dict: 170 | self.set_val = self.val['set'] 171 | self.val = self.val['test'] 172 | else: 173 | self.set_val = self.val 174 | elif sacctmgr.state == 'absent': 175 | sacctmgr.params.pop(self.name, None) 176 | 177 | def sets(self, sacctmgr): 178 | if self.set_val is None: 179 | return 180 | cur = self.cur(sacctmgr) 181 | if not cur or not all(map(self.eq, cur)): 182 | self.set(sacctmgr, self.set_val) 183 | 184 | class RWSet(RW): 185 | def eq(self, val): 186 | return set(self.val.split(',')) == set(val.split(',')) 187 | 188 | class TRES(RWSet): 189 | def parse(self, sacctmgr): 190 | super(TRES, self).parse(sacctmgr) 191 | try: 192 | self.val = ','.join(v for v in self.val.split(',') if not v.endswith('=-1')) 193 | except AttributeError: 194 | pass 195 | 196 | class Act(Param): 197 | """Parameter that causes an action""" 198 | def parse(self, sacctmgr): 199 | if sacctmgr.state == 'present': 200 | super(Act, self).parse(sacctmgr) 201 | elif sacctmgr.state == 'absent': 202 | sacctmgr.params.pop(self.name, None) 203 | 204 | def sets(self, sacctmgr): 205 | if self.val is None: 206 | return 207 | if sacctmgr.cur: 208 | self.set(sacctmgr, self.val) 209 | 210 | class List(Parser): 211 | """Set of parameters""" 212 | def __init__(self, *l): 213 | self.list = l 214 | 215 | def editable(self): 216 | return self.list[0].editable() 217 | 218 | def format(self, sacctmgr): 219 | for p in self.list: 220 | p.format(sacctmgr) 221 | 222 | def parse(self, sacctmgr): 223 | for p in self.list: 224 | p.parse(sacctmgr) 225 | 226 | def sets(self, sacctmgr): 227 | for p in self.list: 228 | p.sets(sacctmgr) 229 | 230 | class With(Parser): 231 | """Parameters dependent on a With* argument, only supplied if the given key parameter is""" 232 | def __init__(self, w, k, *l): 233 | self.args = w 234 | self.key = k 235 | self.sub = List(*l) 236 | 237 | def parse(self, sacctmgr): 238 | self.key.format(sacctmgr) 239 | self.key.parse(sacctmgr) 240 | if self.key.val is not None: 241 | sacctmgr.args.append(self.args) 242 | self.sub.format(sacctmgr) 243 | self.sub.parse(sacctmgr) 244 | 245 | def sets(self, sacctmgr): 246 | if self.key.val is not None: 247 | self.sub.sets(sacctmgr) 248 | 249 | class Opt(Param): 250 | """Optional flag controlling list results""" 251 | def parse(self, sacctmgr): 252 | if sacctmgr.state != 'list': 253 | return 254 | super(Opt, self).parse(sacctmgr) 255 | if self.val is None: 256 | return 257 | try: 258 | self.val = sacctmgr.module._check_type_bool(self.val) 259 | except (TypeError, ValueError): 260 | sacctmgr.fail(msg="argument %s could not be converted to bool" % self.name) 261 | if self.val: 262 | sacctmgr.args.append(self.name) 263 | 264 | ENTITIES = dict( 265 | cluster = List(Key('Name', 'Cluster'), RF('Classification'), 266 | RW('DefaultQOS'), RO('Flags'), RO('RPC'), RW('QosLevel'), RW('Fairshare'), TRES('GrpTRES'), RW('GrpJobs'), RW('GrpMemory'), RW('GrpNodes'), RW('GrpSubmitJob'), RW('MaxTRESMins'), RW('MaxJobs'), RW('MaxNodes'), RW('MaxSubmitJobs'), RW('MaxWall')), 267 | qos = List(Key('Name'), 268 | RW('Description'), RO('Id'), RW('PreemptMode'), RW('Flags'), RW('GraceTime'), RW('GrpJobs'), RW('GrpSubmitJob'), RW('GrpTRESMins'), TRES('GrpTRES'), RW('GrpWall'), RW('MaxTRESMins'), TRES('MaxTRESPerJob'), TRES('MaxTRESPerNode'), TRES('MaxTRESPerUser'), RW('MaxJobs'), RW('MaxSubmitJobsPerUser'), RW('MaxWall'), RW('Preempt'), RW('Priority'), RW('UsageFactor'), RW('UsageThreshold'), 269 | Act('RawUsage'), Opt('WithDeleted'), 270 | TRES('MaxTRES'), RW('MaxJobsPerUser'), RW('MaxJobsAccruePerUser'), TRES('MinTRESPerJob')), 271 | resource = List(Key('Name'), 272 | RW('Description'), RW('Count'), RW('Flags'), RO('Id'), RW('ServerType'), RW('Server'), RW('Type'), 273 | With('WithClusters', RF('Cluster'), RW('PercentAllowed', 'Allocated'))), 274 | account = List(Key('Name', 'Account'), 275 | RW('Description'), RW('Organization'), 276 | With('WithAssoc', RF('Parent', 'ParentName'), RF('Cluster'), RW('DefaultQOS'), RW('QOSLevel'), RW('Fairshare'), RW('GrpTRESMins'), RW('GrpTRESRunMins'), TRES('GrpTRES'), RW('GrpJobs'), RW('GrpMemory'), RW('GrpNodes'), RW('GrpSubmitJob'), RW('GrpWall'), RW('MaxTRESMins'), TRES('MaxTRES'), RW('MaxJobs'), RW('MaxNodes'), RW('MaxSubmitJobs'), RW('MaxWall'), 277 | Act('RawUsage')), 278 | Opt('WithDeleted')), 279 | user = List(Key('Name', 'User'), 280 | RW('DefaultAccount'), RW('AdminLevel'), 281 | With('WithAssoc', RF('Account'), RF('Cluster'), RF('Partition'), RW('DefaultQOS'), RW('DefaultWCKey'), RWSet('QosLevel'), RW('Fairshare'), RW('MaxTRESMins'), TRES('MaxTRES'), RW('MaxJobs'), RW('MaxNodes'), RW('MaxSubmitJobs'), RW('MaxWall'), 282 | Act('RawUsage')), 283 | Opt('WithDeleted')), 284 | events = List(RF('Cluster'), RF('Nodes', 'ClusterNodes'), RF('Start'), RF('End'), RF('State'), RF('Reason'), RF('User'), RF('Event'), RO('CPUCount'), RO('Duration'), 285 | Filt('Start'), Filt('End'), Filt('MaxCPUs'), Filt('MinCPUs'), Opt('All_Clusters'), Opt('All_Time')), 286 | reservation = List(RF('Name'), RF('Cluster'), RO('TRES'), RF('Start'), RF('End'), RF('ID'), 287 | Filt('Nodes')), 288 | transactions = List(RO('Time'), RF('Action'), RF('Actor'), RO('Where'), RO('Info'), RF('Cluster'), RF('ID'), 289 | With('WithAssoc', RF('Account'), RF('User'))), 290 | tres = List(RF('Name'), RF('Type'), RF('ID'), 291 | Opt('WithDeleted')), 292 | wckey = List(RF('Name'), RF('Cluster'), RF('User'), RF('ID'), 293 | Filt('End'), Filt('Start'), Opt('WithDeleted')), 294 | ) 295 | 296 | class SAcctMgr(object): 297 | def __init__(self): 298 | self.module = AnsibleModule( 299 | argument_spec = dict( 300 | state = dict(choices=['present', 'absent', 'list']), 301 | entity = dict(required=True, choices=ENTITIES.keys()), 302 | name = dict(type='str'), 303 | args = dict(type='dict', default={}), 304 | ), 305 | supports_check_mode = True 306 | ) 307 | self.bin = self.module.get_bin_path('sacctmgr', True, ['/opt/slurm/bin', '/cm/shared/apps/slurm/current/bin']) 308 | self.entity = self.module.params['entity'] 309 | self.params = self.module.params['args'] 310 | try: 311 | self.params['name'] = self.module.params['name'] 312 | except KeyError: 313 | pass 314 | 315 | self.result = {} 316 | self.format = [] 317 | self.keys = [] 318 | self.filter = Args() 319 | self.args = Args() 320 | self.sets = Args() 321 | 322 | def exit(self, **args): 323 | for (k, v) in args: 324 | self.result[k] = v 325 | self.module.exit_json(**self.result) 326 | 327 | def fail(self, msg): 328 | self.result['msg'] = msg 329 | self.module.fail_json(**self.result) 330 | 331 | def change(self): 332 | """Register a change and possibly exit in check mode.""" 333 | self.result['changed'] = True 334 | if self.module.check_mode: 335 | self.exit() 336 | 337 | def cmd(self, readonly, *args): 338 | cmd = [self.bin] 339 | if readonly: 340 | cmd.append('-r') 341 | else: 342 | #self.fail(" ".join(args)) 343 | cmd.append('-i') 344 | self.change() 345 | cmd.extend(args) 346 | (_, o, e) = self.module.run_command(cmd, check_rc=True) 347 | if e != '': 348 | self.fail(e) 349 | return o 350 | 351 | def list(self): 352 | """Parse the output of a list command.""" 353 | cmd = ['-nP', 'list', self.entity, 'format=' + ','.join(self.format)] + self.args + self.filter 354 | l = [r.split('|') for r in self.cmd(True, *cmd).splitlines()] 355 | n = len(self.format) 356 | if any(len(r) != n for r in l): 357 | self.fail('unexpected list output for %s' % self.entity) 358 | #return list(filter(lambda d: all(d[k] for k in self.keys), map(lambda r: dict(zip(self.format, r)), l))) 359 | return [d for d in (dict(zip(self.format, r)) for r in l) if all(d[k] for k in self.keys)] 360 | 361 | def create(self): 362 | cmd = ['add', self.entity] + self.filter + self.sets 363 | self.cmd(False, *cmd) 364 | 365 | def modify(self): 366 | cmd = ['modify', self.entity] + self.filter + ["set"] + self.sets 367 | self.cmd(False, *cmd) 368 | 369 | def delete(self): 370 | cmd = ['delete', self.entity] + self.filter 371 | self.cmd(False, *cmd) 372 | 373 | def main(self): 374 | parser = ENTITIES[self.entity] 375 | editable = parser.editable() 376 | self.state = self.module.params.get('state') or ('present' if editable else 'list') 377 | if not editable and self.state != 'list': 378 | self.fail('cannot set state=%s for %s' % (self.state, self.entity)) 379 | parser.format(self) 380 | parser.parse(self) 381 | if self.params: 382 | self.fail('unhandled arguments: %s' % ','.join(self.params.keys())) 383 | self.cur = self.list() 384 | self.result[self.entity] = self.cur 385 | if self.state == 'list': 386 | pass 387 | elif self.state == 'present': 388 | parser.sets(self) 389 | if not self.cur: 390 | self.create() 391 | elif self.sets: 392 | self.modify() 393 | elif self.state == 'absent': 394 | if self.cur: 395 | self.delete() 396 | self.exit() 397 | 398 | if __name__ == '__main__': 399 | SAcctMgr().main() 400 | -------------------------------------------------------------------------------- /warewulf.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -w 2 | # WANT_JSON 3 | # Ansible module or dynamic inventory for warewulf. 4 | 5 | use strict; 6 | use Data::Dumper; 7 | use JSON; 8 | use List::MoreUtils qw(any); 9 | use Warewulf::Bootstrap; 10 | use Warewulf::DSO::Bootstrap; 11 | use Warewulf::DSO::File; 12 | use Warewulf::DSO::Node; 13 | use Warewulf::DSO::Vnfs; 14 | use Warewulf::DataStore; 15 | use Warewulf::File; 16 | use Warewulf::Ipmi; 17 | use Warewulf::Logger; 18 | use Warewulf::Node; 19 | use Warewulf::Provision; 20 | use Warewulf::Provision::Pxelinux; 21 | use Warewulf::Provision::DhcpFactory; 22 | use Warewulf::Vnfs; 23 | 24 | $Data::Dumper::Indent = 0; 25 | $Data::Dumper::Terse = 1; 26 | $Data::Dumper::Deepcopy = 1; 27 | sub data_eq($$) { 28 | my ($a, $b) = @_; 29 | Dumper($a) eq Dumper($b) 30 | } 31 | 32 | sub elem($@) { 33 | my $e = shift; 34 | any { $_ eq $e } @_ 35 | } 36 | 37 | my $JSON = JSON->new->utf8->allow_blessed->convert_blessed->allow_unknown; 38 | $JSON = $JSON->pretty if -t STDOUT; 39 | 40 | sub read_json { 41 | local $/; 42 | open my $f, '<', @_; 43 | $JSON->decode(<$f>) 44 | } 45 | 46 | sub to_array($) { 47 | my ($val) = @_; 48 | $val = [split(' ', $val)] 49 | unless ref $val eq 'ARRAY'; 50 | wantarray ? @$val : $val 51 | } 52 | 53 | sub to_hash($) { 54 | my ($val) = @_; 55 | $val = { map { my ($k, $v) = split /=/,$_,2; $k => $v } to_array($val) } 56 | unless ref $val eq 'HASH'; 57 | wantarray ? %$val : $val 58 | } 59 | 60 | sub deobj { 61 | local $_ = shift; 62 | if (ref =~ /^Warewulf::/) { 63 | if ($_->isa('Warewulf::ObjectSet')) { 64 | my @l = $_->get_list; 65 | $_ = {}; 66 | for my $e (@l) { 67 | $_->{$e->get('name')} = deobj($e); 68 | } 69 | } elsif ($_->isa('Warewulf::Object')) { 70 | my %h = $_->get_hash; 71 | $_ = {}; 72 | while (my ($k, $v) = each %h) { 73 | $_->{lc $k} = deobj($v); 74 | } 75 | } 76 | } elsif (ref eq 'ARRAY') { 77 | $_ = [ map { deobj($_) } @$_ ]; 78 | } elsif (ref eq 'HASH') { 79 | $_ = {%$_}; 80 | for (values %$_) { 81 | $_ = deobj($_); 82 | } 83 | } 84 | $_ 85 | } 86 | 87 | sub prop { 88 | my ($obj, $prop, $args, $check) = @_; 89 | my $val = $args->{$prop}; 90 | my $cur = $obj->$prop; 91 | return if data_eq($cur, $val); 92 | return $check if $check; 93 | $obj->$prop($val) || JSON::true 94 | } 95 | 96 | sub prop_list { 97 | my ($obj, $prop, $args, $check) = @_; 98 | my @val = to_array($args->{$prop}) 99 | or die "$prop requires array value\n"; 100 | my @cur = $obj->$prop; 101 | return if data_eq(\@cur, \@val); 102 | return $check if $check; 103 | $obj->$prop(@val) || JSON::true 104 | } 105 | 106 | sub prop_adddel { 107 | my ($obj, $prop, $args, $check) = @_; 108 | my @val = to_array($args->{$prop}); 109 | my ($base, $addrm) = $prop =~ /^(.*)(add|del)$/ 110 | or die "invalid prop_addrm: $prop"; 111 | $base .= 's'; 112 | my $sense = $addrm eq 'add'; 113 | my @cur = $obj->$base; 114 | return unless any { $sense xor elem($_, @cur) } @val; 115 | return $check if $check; 116 | $obj->$prop(@val) || JSON::true 117 | } 118 | 119 | sub prop_bool { 120 | my ($obj, $prop, $args, $check) = @_; 121 | my $val = uc($args->{$prop}); 122 | if (elem($val, '1', 'true', 'yes')) { 123 | $val = 1; 124 | } elsif (elem($val, '0', 'false', 'no')) { 125 | $val = 0; 126 | } else { 127 | die "$prop requires boolean value\n"; 128 | } 129 | my $cur = $obj->$prop; 130 | return unless $val xor $cur; 131 | return $check if $check; 132 | $obj->$prop($val) || JSON::true 133 | } 134 | 135 | sub prop_bootlocal { 136 | my ($obj, $prop, $args, $check) = @_; 137 | my $val = uc($args->{$prop}); 138 | if (elem($val, 'UNDEF', 'FALSE', 'NO', 'N', '0')) { 139 | $val = 'UNDEF'; 140 | } elsif (not elem($val, 'EXIT', 'NORMAL')) { 141 | die "$prop requires UNDEF, EXIT, or NORMAL\n"; 142 | } 143 | my $cur = $obj->$prop; 144 | $cur = defined $cur ? $cur ? "EXIT" : "NORMAL" : "UNDEF"; 145 | return if $cur eq $val; 146 | return $check if $check; 147 | $obj->$prop($val) || JSON::true 148 | } 149 | 150 | sub action { 151 | my ($obj, $prop, $args, $check) = @_; 152 | return $check if $check; 153 | $obj->$prop($args->{$prop}) || JSON::true 154 | } 155 | 156 | my @NETDEV_PROPS = qw(netrename hwaddr hwprefix ipaddr netmask network gateway fqdn mtu); 157 | 158 | sub netdev { 159 | my ($obj, $prop, $args, $check) = @_; 160 | my $netdev = $args->{$prop}; 161 | my $netobj = $obj->netdevs($netdev); 162 | my $changed = 0; 163 | unless ($netobj) { 164 | if ($prop eq 'netadd') { 165 | return $check if $check; 166 | $obj->netdev_get_add($netdev); 167 | $changed ++; 168 | } else { 169 | return; 170 | } 171 | } 172 | if ($prop eq 'netdel') { 173 | return $check if $check; 174 | return $obj->netdel($netdev) || JSON::true; 175 | } 176 | for $prop (@NETDEV_PROPS) { 177 | next unless exists $args->{$prop}; 178 | my $val = $args->{$prop}; 179 | my $cur = $obj->$prop($netdev); 180 | next if data_eq($cur, $val); 181 | return $check if $check; 182 | $obj->$prop($netdev, $args->{$prop}); 183 | $changed ++; 184 | } 185 | $changed 186 | } 187 | 188 | sub ipmi { 189 | my ($obj, $prop, $args, $check) = @_; 190 | return $check if $check; 191 | system($obj->ipmi_command($args->{$prop})) 192 | or die "IPMI command failed: $?\n"; 193 | } 194 | 195 | my %PROPS = ( 196 | node => { 197 | nodename => \&prop, 198 | cluster => \&prop, 199 | domain => \&prop, 200 | groups => \&prop_list, 201 | groupadd => \&prop_adddel, 202 | groupdel => \&prop_adddel, 203 | netdev => \&netdev, 204 | netadd => \&netdev, 205 | netdel => \&netdev, 206 | enabled => \&prop_bool, 207 | # Provision: 208 | bootstrapid => \&prop, 209 | vnfsid => \&prop, 210 | fileids => \&prop_list, 211 | files => \&resolve_fileids, 212 | console => \&prop, 213 | kargs => \&prop_list, 214 | pxelinux => \&prop, 215 | fileidadd => \&prop_adddel, 216 | fileiddel => \&prop_adddel, 217 | fileadd => \&resolve_fileids, 218 | filedel => \&resolve_fileids, 219 | master => \&prop_list, 220 | postnetdown => \&prop_bool, 221 | preshell => \&prop_bool, 222 | postshell => \&prop_bool, 223 | selinux => \&prop, 224 | bootlocal => \&prop_bootlocal, 225 | # Impi: 226 | ipmi_ipaddr => \&prop, 227 | ipmi_netmask => \&prop, 228 | ipmi_username => \&prop, 229 | ipmi_password => \&prop, 230 | ipmi_uid => \&prop, 231 | ipmi_proto => \&prop, 232 | ipmi_autoconfig => \&prop_bool, 233 | ipmi => \&ipmi, 234 | }, 235 | vnfs => { 236 | name => \&prop, 237 | checksum => \&prop, 238 | chroot => \&prop, 239 | size => \&prop, 240 | vnfs_import => \&action, 241 | vnfs_export => \&action, 242 | }, 243 | bootstrap => { 244 | name => \&prop, 245 | checksum => \&prop, 246 | size => \&prop, 247 | bootstrap_import => \&action, 248 | bootstrap_export => \&action, 249 | delete_local_bootstrap => \&action, 250 | build_local_bootstrap => \&action, 251 | }, 252 | file => { 253 | name => \&prop, 254 | mode => \&prop, 255 | checksum => \&prop, 256 | uid => \&prop, 257 | gid => \&prop, 258 | size => \&prop, 259 | path => \&prop, 260 | format => \&prop, 261 | interpreter => \&prop, 262 | origin => \&prop, 263 | sync => \&action, 264 | file_import => \&action, 265 | file_export => \&action, 266 | }, 267 | ); 268 | my %CLASS = ( 269 | node => 'Warewulf::Node', 270 | vnfs => 'Warewulf::Vnfs', 271 | bootstrap => 'Warewulf::Bootstrap', 272 | file => 'Warewulf::File', 273 | ); 274 | my @TYPES = keys %PROPS; 275 | 276 | my %DHCP = ( 277 | 'update' => 'persist', 278 | 'persist' => 'persist', 279 | 'restart' => 'restart', 280 | ); 281 | 282 | sub dhcp($$) { 283 | my ($arg, $check) = @_; 284 | return unless $arg; 285 | $arg = $DHCP{$arg}; 286 | die "Unknown dhpc argument: $arg\n" 287 | unless exists $DHCP{$arg}; 288 | my $op = $DHCP{$arg}; 289 | return $check if $check; 290 | Warewulf::Provision::DhcpFactory->new->$op() || JSON::true 291 | } 292 | 293 | my %PXE = ( 294 | 'update' => 'update', 295 | 'delete' => 'delete', 296 | ); 297 | 298 | sub pxe($$$) { 299 | my ($arg, $obj, $check) = @_; 300 | return unless $arg and $obj and $obj->count; 301 | die "Unknown pxe argument: $arg\n" 302 | unless exists $PXE{$arg}; 303 | my $op = $PXE{$arg}; 304 | return $check if $check; 305 | Warewulf::Provision::Pxelinux->new->$op($obj->get_list) || JSON::true 306 | } 307 | 308 | my $DS = Warewulf::DataStore->new(); 309 | 310 | sub resolve_ids { 311 | my ($type, $lookup, $obj, $prop, $args, $check) = @_; 312 | my @val = to_array($args->{$prop}); 313 | $prop =~ s/^$type/${type}id/ 314 | or die "invalid resolve_${type}ids $prop"; 315 | $args->{$prop} = [ map { $_->id() } 316 | map { $DS->get_objects($type, $lookup, $_)->get_list() } @val 317 | ]; 318 | return $PROPS{$obj->get('_type')}->{$prop}->($obj, $prop, $args, $check); 319 | } 320 | 321 | sub resolve_fileids { 322 | return resolve_ids("file", "name", @_); 323 | } 324 | 325 | sub match_objects($;$$) { 326 | my ($match, $field, $action) = @_; 327 | $field ||= 'name'; 328 | my(@objs, %objs); 329 | push @objs, \%objs; 330 | for my $type (@TYPES) { 331 | next unless exists $match->{$type}; 332 | my $objs = $DS->get_objects($type, $field, to_array($match->{$type})); 333 | $objs{$type} = $objs; 334 | push @objs, $objs->get_list; 335 | } 336 | @objs 337 | } 338 | 339 | sub inventory_objects(;$) { 340 | my ($host) = (@_, ''); 341 | match_objects { node => $host, vnfs => $host } 342 | } 343 | 344 | sub obj_vars($) { 345 | my ($obj) = @_; 346 | my %vars; 347 | $vars{warewulf_type} = $obj->get('_type'); 348 | if ($obj->isa('Warewulf::Node')) { 349 | $vars{warewulf_domain} = $obj->domain if $obj->domain; 350 | $vars{warewulf_netdevs} = deobj($obj->netdevs); 351 | } elsif ($obj->isa('Warewulf::Vnfs')) { 352 | $vars{ansible_host} = $obj->chroot; 353 | $vars{ansible_connection} = 'chroot'; 354 | } 355 | wantarray ? %vars : \%vars 356 | } 357 | 358 | sub obj_groups($) { 359 | my ($obj) = @_; 360 | my @groups; 361 | if ($obj->isa('Warewulf::Node')) { 362 | push @groups, 'warewulf_node'; 363 | push @groups, 'enabled' if $obj->enabled; 364 | push @groups, $obj->cluster if $obj->cluster; 365 | push @groups, $obj->groups; 366 | } elsif ($obj->isa('Warewulf::Vnfs')) { 367 | push @groups, 'warewulf_vnfs'; 368 | } 369 | wantarray ? @groups : \@groups 370 | } 371 | 372 | 373 | my %RES = (); 374 | 375 | if (@ARGV == 1 and $ARGV[0] eq '--list') { 376 | my ($objs, @objs) = inventory_objects; 377 | my %vars; 378 | for my $obj (@objs) { 379 | my $name = $obj->get('name'); 380 | $vars{$name} = obj_vars($obj); 381 | for my $group (obj_groups($obj)) { 382 | push @{$RES{$group}}, $name; 383 | } 384 | } 385 | $RES{_meta} = { hostvars => \%vars }; 386 | 387 | } elsif (@ARGV == 2 and $ARGV[0] eq '--host') { 388 | my $host = $ARGV[1]; 389 | 390 | my ($objs, @objs) = inventory_objects $host; 391 | die "No matching host: $host\n" unless @objs; 392 | die "Multiple matching hosts: $host\n" if $#objs; 393 | my ($obj) = @objs; 394 | %RES = obj_vars($obj); 395 | 396 | } elsif (@ARGV == 1 and -r $ARGV[0]) { 397 | my $args = read_json(@ARGV); 398 | $RES{changed} = 0; 399 | 400 | $SIG{__DIE__} = sub { 401 | die @_ if $^S; 402 | local $_ = "@_"; 403 | chomp $_; 404 | $RES{failed} = JSON::true; 405 | $RES{msg} = $_; 406 | print $JSON->encode(\%RES); 407 | exit 0; 408 | }; 409 | 410 | &set_log_target('SYSLOG:ansible-warewulf:' . ($args->{_ansible_syslog_facility} || 'USER'), 'ALL'); 411 | &set_log_target(\&CORE::die, 0, 1); 412 | set_log_level(($args->{_ansible_verbosity} || 0) + 1); 413 | 414 | my $check = $args->{_ansible_check_mode}; 415 | 416 | my $lookup = $args->{lookup}; 417 | my ($objs, @objs) = match_objects($args, $lookup); 418 | if ($args->{new}) { 419 | while (my ($type, $obj) = each %$objs) { 420 | next if $obj->count; 421 | my $o = $CLASS{$type}->new; 422 | $o->set('_type', $type); 423 | $o->$lookup($args->{$type}); 424 | push @objs, $o; 425 | } 426 | } 427 | 428 | for my $obj (@objs) { 429 | my $type = $obj->get('_type'); 430 | my $props = $PROPS{$type}; 431 | my $changed = 0; 432 | for my $prop (keys %$props) { 433 | next unless exists $args->{$prop}; 434 | next unless $props->{$prop}->($obj, $prop, $args, $check); 435 | $changed++; 436 | last if $check; 437 | } 438 | if (exists $args->{set}) { 439 | my %set = to_hash($args->{set}); 440 | while (my ($key, $val) = each %set) { 441 | my $cur = $obj->get($key); 442 | next if data_eq($cur, $val); 443 | $obj->set($key, $val) unless $check; 444 | $changed++; 445 | last if $check; 446 | } 447 | } 448 | ($RES{$type} //= {})->{$obj->get('name')} = deobj($obj) 449 | if $args->{get}; 450 | if ($changed) { 451 | $RES{changed} += $changed; 452 | last if $check; 453 | $DS->persist($obj); 454 | } 455 | } 456 | 457 | if ($args->{delete}) { 458 | for my $obj (values %$objs) { 459 | my $n = $obj->count; 460 | next unless $n; 461 | $RES{changed} += $n; 462 | last if $check; 463 | $DS->del_object($obj); 464 | } 465 | } 466 | 467 | $RES{changed}++ if dhcp($args->{dhcp}, $check); 468 | $RES{changed}++ if pxe($args->{pxe}, $objs->{node}, $check); 469 | 470 | } else { 471 | die "$0: ansible module or dynamic inventory\n" 472 | } 473 | 474 | print $JSON->encode(\%RES); 475 | exit 0; 476 | --------------------------------------------------------------------------------