Skip to content

Commit

Permalink
Meteor ansible integration (#114)
Browse files Browse the repository at this point in the history
Added:
Functionality to select an Ansible playbook specified in the config file.
Live updating of the list of Ansible playbooks available if the config file is edited while the Meteor server is running.
Functionality to run a selected Ansible playbook on a specified host.
Logging of the output of the Ansible command on the UI for manual reviewing of output.

* Changed .run to .popen

prevents blocking caused by .run

* Added dummy data in testing environment

* Changed formatting

* Storing playbooks as mongo collection, show playbooks in dropdown

Potentially could cause a future issue if new playbooks need to be added, could need

* Created worst meteor method known to man

Not really, just not sure what the meteor way of grabbing the selected option from the selection box is so we're rockin with the .closest call

* alt shift f

* Added ansible-playbook command and file reading/writing for ansible inventory file

Now ready for testing

* ensure openssh server will accept root login on clients

* Added ansible command to client.provision

Also added inventory file creation to startup command instead of method call

Also changed structure of inventory file to better reflect what we're actually trying to do.

* restart ssh service after editing the config

* Added ssh key checking and adding to ansible command

* added ansible configuration to default test

* Updated Ansible command

* Update meteor-dev.tar.gz

* Fixed ansible command formatting

* Update meteor-dev.tar.gz

* Updated playbook exec call to use spawn()

Updated genisys inventory to include a provisioned field.

Added delete host button to UI w/ functionality.

* Changed return values on non-zero exit code from ansible

* Cleaned up ansible command output into plain text, placed server methods in separate file

* Delete genisys/server/external/meteor-dev.tar.gz

In light of recent CVEs, having compressed/unauditable files in repo is probably not a good idea.

* Added logging of output from ansible commands, still WIP

- Currently doesn't get any output from the ansible commands for some reason, still troubleshooting

* Implemented output log viewing/storage

* Added case for undefined log request, cleaned formatting

* Replaced depreciated pkg_resources with importlib utils.

* Return early

* Added live updating of playbooks added to config file

* Updated error message

* Removed commented code

---------

Co-authored-by: Robert Gingras <developer@three-point-five.dev>
  • Loading branch information
HenrithicusGreenson and xeluior authored Apr 10, 2024
1 parent 9d01249 commit b50414c
Show file tree
Hide file tree
Showing 20 changed files with 390 additions and 108 deletions.
6 changes: 5 additions & 1 deletion genisys/modules/firstboot/hello.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def generate(self: Self):

# ensure some programs are installed
# this should be taken care of by the preseed, but just in case
content.append("apt-get update && apt-get install -y iproute2 gawk coreutils curl jq")
content.append("apt-get update && apt-get install -y sed iproute2 gawk coreutils curl jq openssh-server")

# thanks chat gpt for this command
content.append('ip_addr="$(ip -o -4 address show scope global | awk \'{print $4}\' | cut -d/ -f1 | head -n1)"')
Expand All @@ -46,6 +46,10 @@ def generate(self: Self):
# set the machine's hostname with the response iff the response included one
content.append('[ "$hostname" != "null" ] && hostnamectl set-hostname "$hostname"')

# ensure root can login thru ssh
content.append("sed -i 's/^#PermitRootLogin prohibit-password$/PermitRootLogin yes/' /etc/ssh/sshd_config")
content.append("systemctl restart sshd.service")

# Turning array of strings into single block
formatted_string = "\n".join(content)
return formatted_string
Expand Down
21 changes: 13 additions & 8 deletions genisys/server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@
from genisys.server.genisysinventory import GenisysInventory
from genisys.server.http import GenisysHTTPServer, GenisysHTTPRequestHandler
import genisys.server.tls
import pkg_resources
import importlib.util
import tarfile
import dotenv
from pathlib import Path



DEFAULT_PORT = 15206
Expand All @@ -34,6 +36,11 @@ def run(config: YAMLParser):
network = config.get_section("Network")
server_options = cast(ServerOptions, network.get("server", {}) or {})

#Connect to database
db_uri = os.getenv("MONGO_URL") # Adjust based on your MongoDB setup
db_name = "local" # The database name
collection_name = "ClientsCollection" # The collection name

# Launch meteor server
meteor_initialization(server_options)

Expand All @@ -44,11 +51,6 @@ def run(config: YAMLParser):
warn("Unable to drop privledges to the specified user. Continuing as current user.")
server_user = pwd.getpwuid(os.getuid())

#Connect to database
db_uri = os.getenv("MONGO_URL") # Adjust based on your MongoDB setup
db_name = "genisys_db" # The database name
collection_name = "inventory_collection" # The collection name

# change working directory
workdir = server_options.get("working-directory", server_user.pw_dir)
os.chdir(workdir)
Expand Down Expand Up @@ -108,6 +110,7 @@ def meteor_initialization(server_config: ServerOptions):
# Set environment variables
os.environ['ROOT_URL'] = 'http://localhost'
os.environ['PORT'] = '8080'

if 'MONGO_URL' not in os.environ:
print('MONGO_URL not found in environment variables, cancelling Meteor server.')
return
Expand All @@ -123,7 +126,8 @@ def meteor_initialization(server_config: ServerOptions):
os.makedirs(meteor_dir, exist_ok=True)

# Get path to tar file
tar_file_path = pkg_resources.resource_filename('genisys', 'server/external/meteor-dev.tar.gz')
package_location = importlib.util.find_spec('genisys').origin
tar_file_path = Path(package_location[:package_location.rfind('/')], 'server/external/meteor-dev.tar.gz')

# Extract tarball Meteor build
file = tarfile.open(tar_file_path)
Expand All @@ -132,7 +136,8 @@ def meteor_initialization(server_config: ServerOptions):

# npm install and run
subprocess.run(['npm', 'install', '--prefix', os.path.join(meteor_dir,'bundle', 'programs', 'server'), '--unsafe-perm'], check=True)
subprocess.run(['node', os.path.join(meteor_dir,'bundle', 'main.js')], check=True)
subprocess.Popen(['node', os.path.join(meteor_dir,'bundle', 'main.js')])
print('Done running Meteor initialization.')


# end meteor_initialization
Binary file removed genisys/server/external/meteor-dev.tar.gz
Binary file not shown.
7 changes: 0 additions & 7 deletions genisys/server/genisysinventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,6 @@ def __init__(self, db_uri: str, db_name: str, collection_name: str):
self.db = self.client[db_name]
self.collection = self.db[collection_name]

# Ensure that the collection structure exists
if self.collection.count_documents({}) == 0:
# Initialize the collection with a basic structure if needed
self.collection.insert_one({"genisys": {"hosts": []}})

def __del__(self):
"""Close the MongoDB connection."""
self.client.close()
Expand Down Expand Up @@ -68,5 +63,3 @@ def get_next_hostname(self):

# Adjust the zero padding based on your maximum expected number of hosts
return f"genisys{str(new_num).zfill(5)}"


1 change: 1 addition & 0 deletions genisys/templates/preseed.cfg.jinja2
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ d-i apt-setup/use_mirror boolean true
popularity-contest popularity-contest/participate boolean false
# Package selection
tasksel tasksel/first multiselect standard
d-i pkgsel/include string sudo
# Automatically install grub to the MBR
d-i grub-installer/only_debian boolean true
# Turn off last message about the install being complete
Expand Down
4 changes: 2 additions & 2 deletions meteor-dev/.meteor/packages
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ mobile-experience@1.1.1 # Packages for a great mobile UX
mongo@1.16.8 # The database Meteor supports right now
blaze-html-templates # Compile .html files into Meteor Blaze views
jquery # Wrapper package for npm-installed jquery
reactive-var@1.0.12 # Reactive variable for tracker
reactive-var # Reactive variable for tracker
tracker@1.3.3 # Meteor's client-side reactive programming library

standard-minifier-css@1.9.2 # CSS minifier run for production mode
Expand All @@ -24,4 +24,4 @@ shell-server@0.5.0 # Server-side component of the `meteor shell` comm
hot-module-replacement@0.5.3 # Update code in development without reloading the page
blaze-hot # Update files using Blaze's API with HMR
dev-error-overlay
communitypackages:picker
communitypackages:picker
3 changes: 3 additions & 0 deletions meteor-dev/api/clients/ansible.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { Mongo } from 'meteor/mongo';

export const AnsibleCollection = new Mongo.Collection('AnsibleCollection');
3 changes: 3 additions & 0 deletions meteor-dev/api/clients/outputLogs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { Mongo } from 'meteor/mongo';

export const OutputLogsCollection = new Mongo.Collection('OutputLogs');
3 changes: 3 additions & 0 deletions meteor-dev/api/clients/playbooks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { Mongo } from 'meteor/mongo';

export const PlaybooksCollection = new Mongo.Collection('PlaybooksCollection')
226 changes: 172 additions & 54 deletions meteor-dev/api/clients/server/methods.js
Original file line number Diff line number Diff line change
@@ -1,65 +1,183 @@
import {
Meteor
} from 'meteor/meteor';
import {
check
} from 'meteor/check';
import { ClientsCollection } from '../clients';
import fs from 'fs';
import { Meteor } from "meteor/meteor"
import { check } from "meteor/check"
import { ClientsCollection } from "../clients"
import { AnsibleCollection } from "../ansible"
import { OutputLogsCollection } from "../outputLogs"
const { spawn } = require("child_process")
import fs from "fs"
const yaml = require("js-yaml")
import { CONFIG_FILE_VAR } from "../../../server/serverMethods"
import { PlaybooksCollection } from "../playbooks"

Meteor.methods({
'Clients.Provision': function (clientId) {
check(clientId, String);
"Clients.Provision": function (clientId, playbook) {
check(clientId, Mongo.ObjectID)

const client = ClientsCollection.findOne({
_id: clientId
});
// Find client in MongoDB
const client = ClientsCollection.findOne({
_id: clientId,
})

if (!client) {
throw new Meteor.Error('client-not-found', 'That client doesn\'t exist.');
// Check if client exists
if (!client) {
throw new Meteor.Error("client-not-found", "That client doesn't exist.")
}

// Check if playbook as been selected
if (playbook.localeCompare("None") === 0) {
throw new Meteor.Error(
"invalid-playbook",
"Please select a playbook to run."
)
}

// Reading inventory file and adding hostname to inventory file
fs.readFile("inventory", "utf8", function (err, fileContent) {
if (err) {
console.error(`Error reading file: ${err}`)
return
}

// Check if the hostname is in the file
if (fileContent.includes(client.hostname)) {
console.log(`${client.hostname} already exists in the file.`)
} else {
// If 'client.hostname' is not in the file, add it to the end of the file
fileContent += `${client.hostname} ansible_host=${client.ip}\n`

// Write the modified content back to the file
fs.writeFile("inventory", fileContent, "utf8", function (err) {
if (err) {
console.error(`Error writing to file: ${err}`)
return
}
console.log(`${client.hostname} added to the file.`)
console.log(`Content of file: ${fileContent}`)
})
}
})

// Building Ansible command
let command = "ansible-playbook"
let cmd_args = [
`-i`,
`inventory`,
`${playbook}`,
`--limit`,
`${client.hostname}`,
`--ssh-common-args`,
`'-o StrictHostKeyChecking=no'`,
`--user`,
`root`,
]

// If SSH key found, append to command
const ansibleObject = AnsibleCollection.findOne({
"ssh-key": { $exists: true },
})
if (ansibleObject) {
cmd_args.push(`--private-key`, `${ansibleObject["ssh-key"]}`)
}

// Run the command
const commandResult = spawn(command, cmd_args)

let cmd_result = ""
// Print the output of the command as ASCII and log output in MongoDB
commandResult.stdout.on("data", function (data) {
function hexBufferToString(buffer) {
const hexString = buffer.toString("hex")
const hexPairs = hexString.match(/.{1,2}/g)
const asciiString = hexPairs
.map((hex) => String.fromCharCode(parseInt(hex, 16)))
.join("")
return asciiString
}
res = hexBufferToString(data)

cmd_result += res
})

// Return error if cmd_args are invalid/formatted incorrectly
// NOTE: This only runs on errors associated with formatting the command,
// if the Ansible command fails because of something like the host being
// unreachable this will not trigger.
commandResult.stderr.on("data", function (data) {
console.error(data)
return {
status: 400,
message: `${client.hostname} failed provisioning due to command error, potential formatting issue with SSH key, playbook, or client hostname`,
}
})

commandResult.on(
"close",
Meteor.bindEnvironment((code) => {
console.log(`ansible-playbook returned exit code ${code}`)

playbookTimestamp = new Date().getTime()
logLabel = `${client.hostname}-${playbook}-${playbookTimestamp}`

// Insert log into mongodb
OutputLogsCollection.insert({
label: logLabel,
text: cmd_result,
timestamp: playbookTimestamp,
})
console.log("Logged:\n", cmd_result, "\nas:", logLabel)

if (code != 0) {
return {
status: 400,
message: `Ansible returned exit code ${code} while provisioning ${client.hostname}. Please see output log ${logLabel} on web UI for details.`,
}
}

fs.access('inventory', fs.constants.F_OK, (err) => {
if (err) {
console.log(`inventory does not exist`);
fs.writeFileSync('inventory', '');
} else {
console.log(`inventory exists`);
}
});

fs.readFileSync('inventory', function (file) {
// check if client.hostname exists in file, do nothing
// if client.hostname is not in file, add to the end of the file and write the file out.
console.log('file', file);
});



ClientsCollection.update({
_id: clientId,
}, {
$set: {
provisioned: true,
provisionedAt: new Date()
// provisionedBy: Meteor.user()
}
});
return {
status: 200,
message: `${client.hostname} successfully provisioned`
};
status: 100,
message: `Command exexuted on ${client.hostname}, see output logs to view Ansible details.`,
}
})
)
},
"Clients.RemoveHost": function (clientId) {
toDelete = ClientsCollection.findOne({ _id: clientId })

},
'TestYaml': function () {
const exampleYaml = Assets.get('example.yml.tpl');
if (!toDelete) {
throw new Meteor.Error("client-not-found", "That client doesn't exist.")
}
ClientsCollection.remove(toDelete)
return {
status: 200,
message: 'Client successfully deleted.'
}
},
"Logs.GetSelected": function (logLabel) {
const log = OutputLogsCollection.findOne({ label: logLabel })

const replace = {
interface: 'eth0',
subnet: '10.0.0.0/24'
}
// replace {{key}} with value
// fs.writeFile...
if (!log) {
return ""
}

return log.text
},
"RefreshConfig": async function () {
console.log("Loading Playbook Collection")
const CONFIG_FILE = yaml.load(fs.readFileSync(String(CONFIG_FILE_VAR), "utf8"))

PlaybooksCollection.dropCollectionAsync()
AnsibleCollection.dropCollectionAsync()

// Load playbooks into Mongo
CONFIG_FILE["ansible"]["playbooks"].forEach((element) => {
obj = { playbook: element }
PlaybooksCollection.insertAsync(obj)
})

// Putting ansible SSH key location into mongo collection for usage on client
if (CONFIG_FILE["ansible"]["ssh-key"]) {
obj = { "ssh-key": CONFIG_FILE["ansible"]["ssh-key"] }
AnsibleCollection.insertAsync(obj)
}
});
},
})
Loading

0 comments on commit b50414c

Please sign in to comment.