add software installation via chocolatey

This commit is contained in:
wh1te909 2020-02-02 07:43:38 +00:00
parent b7575e31e0
commit 7caeaf03c0
25 changed files with 707 additions and 4 deletions

View File

@ -0,0 +1,18 @@
# Generated by Django 3.0.2 on 2020-01-21 15:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('agents', '0016_remove_agent_status'),
]
operations = [
migrations.AddField(
model_name='agent',
name='choco_installed',
field=models.BooleanField(default=False),
),
]

View File

@ -49,6 +49,7 @@ class Agent(models.Model):
needs_reboot = models.BooleanField(default=False)
managed_by_wsus = models.BooleanField(default=False)
is_updating = models.BooleanField(default=False)
choco_installed = models.BooleanField(default=False)
def __str__(self):
return self.hostname

View File

@ -41,6 +41,7 @@ from winupdate.models import WinUpdate, WinUpdatePolicy
from agents.tasks import uninstall_agent_task, sync_salt_modules_task
from winupdate.tasks import check_for_updates_task
from agents.serializers import AgentHostnameSerializer
from software.tasks import install_chocolatey, get_installed_software
logger.configure(**settings.LOG_CONFIG)
@ -68,7 +69,7 @@ class UploadMeshAgent(APIView):
@permission_classes((IsAuthenticated,))
def trigger_patch_scan(request):
agent = get_object_or_404(Agent, agent_id=request.data["agentid"])
check_for_updates_task.delay(agent.pk)
check_for_updates_task.delay(agent.pk, wait=False)
if request.data["reboot"]:
agent.needs_reboot = True
@ -339,10 +340,14 @@ def update(request):
)
sync_salt_modules_task.delay(agent.pk)
get_installed_software.delay(agent.pk)
if not agent.choco_installed:
install_chocolatey.delay(agent.pk, wait=True)
# check for updates if this is fresh agent install
if not WinUpdate.objects.filter(agent=agent).exists():
check_for_updates_task.delay(agent.pk)
check_for_updates_task.delay(agent.pk, wait=True)
return Response("ok")

View File

View File

@ -0,0 +1,15 @@
from django.contrib import admin
from .models import ChocoSoftware, ChocoLog, InstalledSoftware
class ChocoAdmin(admin.ModelAdmin):
readonly_fields = ("added",)
class ChocoLogAdmin(admin.ModelAdmin):
readonly_fields = ("time",)
admin.site.register(ChocoSoftware, ChocoAdmin)
admin.site.register(ChocoLog, ChocoLogAdmin)
admin.site.register(InstalledSoftware)

View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class SoftwareConfig(AppConfig):
name = "software"

View File

@ -0,0 +1,23 @@
# Generated by Django 3.0.2 on 2020-01-10 00:20
import django.contrib.postgres.fields.jsonb
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='ChocoSoftware',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('chocos', django.contrib.postgres.fields.jsonb.JSONField()),
('added', models.DateTimeField(auto_now_add=True)),
],
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 3.0.2 on 2020-02-02 01:31
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('agents', '0017_agent_choco_installed'),
('software', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='ChocoLog',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('version', models.CharField(max_length=255)),
('message', models.TextField()),
('time', models.DateTimeField(auto_now_add=True)),
('agent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chocolog', to='agents.Agent')),
],
),
]

View File

@ -0,0 +1,29 @@
# Generated by Django 3.0.2 on 2020-02-02 04:27
import django.contrib.postgres.fields.jsonb
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('agents', '0017_agent_choco_installed'),
('software', '0002_chocolog'),
]
operations = [
migrations.AddField(
model_name='chocolog',
name='installed',
field=models.BooleanField(default=False),
),
migrations.CreateModel(
name='InstalledSoftware',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('software', django.contrib.postgres.fields.jsonb.JSONField()),
('agent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='agents.Agent')),
],
),
]

View File

@ -0,0 +1,41 @@
from django.db import models
from django.contrib.postgres.fields import JSONField
from agents.models import Agent
class ChocoSoftware(models.Model):
chocos = JSONField()
added = models.DateTimeField(auto_now_add=True)
@classmethod
def sort_by_highest(cls):
from .serializers import ChocoSoftwareSerializer
chocos = cls.objects.all()
sizes = [
{"size": len(ChocoSoftwareSerializer(i).data["chocos"]), "pk": i.pk}
for i in chocos
]
biggest = max(range(len(sizes)), key=lambda index: sizes[index]["size"])
return int(sizes[biggest]["pk"])
class ChocoLog(models.Model):
agent = models.ForeignKey(Agent, related_name="chocolog", on_delete=models.CASCADE)
name = models.CharField(max_length=255)
version = models.CharField(max_length=255)
message = models.TextField()
installed = models.BooleanField(default=False)
time = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.agent.hostname} | {self.name} | {self.time}"
class InstalledSoftware(models.Model):
agent = models.ForeignKey(Agent, on_delete=models.CASCADE)
software = JSONField()
def __str__(self):
return self.agent.hostname

View File

@ -0,0 +1,15 @@
from rest_framework import serializers
from .models import ChocoSoftware, InstalledSoftware
class ChocoSoftwareSerializer(serializers.ModelSerializer):
class Meta:
model = ChocoSoftware
fields = "__all__"
class InstalledSoftwareSerializer(serializers.ModelSerializer):
class Meta:
model = InstalledSoftware
fields = "__all__"

View File

@ -0,0 +1,173 @@
from time import sleep
from loguru import logger
from tacticalrmm.celery import app
from django.conf import settings
from agents.models import Agent
from .models import ChocoSoftware, ChocoLog, InstalledSoftware
logger.configure(**settings.LOG_CONFIG)
@app.task()
def install_chocolatey(pk, wait=False):
if wait:
sleep(10)
agent = Agent.objects.get(pk=pk)
r = agent.salt_api_cmd(
hostname=agent.salt_id,
timeout=300,
func="chocolatey.bootstrap",
arg="force=True",
)
try:
r.json()
except Exception as e:
return f"error installing choco on {agent.salt_id}"
if type(r.json()) is dict:
try:
output = r.json()["return"][0][agent.salt_id].lower()
except Exception:
return f"error installing choco on {agent.salt_id}"
success = ["chocolatey", "is", "now", "ready"]
if all(x in output for x in success):
agent.choco_installed = True
agent.save(update_fields=["choco_installed"])
logger.info(f"Installed chocolatey on {agent.salt_id}")
return f"Installed choco on {agent.salt_id}"
else:
return f"error installing choco on {agent.salt_id}"
@app.task
def update_chocos():
agents = Agent.objects.only("pk")
online = [x for x in agents if x.status == "online" and x.choco_installed]
while 1:
for agent in online:
try:
ret = agent.salt_api_cmd(
hostname=agent.salt_id, timeout=15, func="test.ping"
)
except Exception:
continue
try:
data = ret.json()["return"][0][agent.salt_id]
except Exception:
continue
else:
if data:
install = agent.salt_api_cmd(
hostname=agent.salt_id,
timeout=180,
func="chocolatey.bootstrap",
arg="force=True",
)
resp = agent.salt_api_cmd(
hostname=agent.salt_id, timeout=200, func="chocolatey.list"
)
ret = resp.json()["return"][0][agent.salt_id]
try:
chocos = [{"name": k, "version": v[0]} for k, v in ret.items()]
except AttributeError:
continue
else:
# somtimes chocolatey api is down or buggy and doesn't return the full list of software
if len(chocos) < 4000:
continue
else:
logger.info(
f"Chocos were updated using agent {agent.salt_id}"
)
ChocoSoftware(chocos=chocos).save()
break
break
return "ok"
@app.task
def get_installed_software(pk):
agent = Agent.objects.get(pk=pk)
r = agent.salt_api_cmd(hostname=agent.salt_id, timeout=30, func="pkg.list_pkgs")
try:
output = r.json()["return"][0][agent.salt_id]
except Exception:
logger.error(f"Unable to get installed software on {agent.salt_id}")
return "error"
try:
software = [{"name": k, "version": v} for k, v in output.items()]
except Exception:
logger.error(f"Unable to get installed software on {agent.salt_id}")
return "error"
if not InstalledSoftware.objects.filter(agent=agent).exists():
InstalledSoftware(agent=agent, software=software).save()
else:
current = InstalledSoftware.objects.filter(agent=agent).get()
current.software = software
current.save(update_fields=["software"])
return "ok"
@app.task
def install_program(pk, name, version):
agent = Agent.objects.get(pk=pk)
r = agent.salt_api_cmd(
hostname=agent.salt_id,
timeout=1000,
func="chocolatey.install",
arg=[name, f"version={version}"],
)
try:
r.json()
except Exception as e:
logger.error(f"Error installing {name} {version} on {agent.salt_id}: {e}")
return "error"
if type(r.json()) is dict:
try:
output = r.json()["return"][0][agent.salt_id].lower()
except Exception as e:
logger.error(f"Error installing {name} {version} on {agent.salt_id}: {e}")
return "error"
success = [
"install",
"of",
name.lower(),
"was",
"successful",
"has",
"been",
"installed",
]
duplicate = [name.lower(), "already", "installed", "--force", "reinstall"]
installed = False
if all(x in output for x in success):
installed = True
logger.info(f"Successfully installed {name} {version} on {agent.salt_id}")
elif all(x in output for x in duplicate):
logger.warning(f"Already installed: {name} {version} on {agent.salt_id}")
else:
logger.error(f"Something went wrong - {name} {version} on {agent.salt_id}")
ChocoLog(
agent=agent, name=name, version=version, message=output, installed=installed
).save()
get_installed_software.delay(agent.pk)
return "ok"

View File

@ -0,0 +1 @@
from django.test import TestCase

View File

@ -0,0 +1,9 @@
from django.urls import path
from . import views
urlpatterns = [
path("chocos/", views.chocos),
path("install/", views.install),
path("installed/<pk>/", views.get_installed),
path("refresh/<pk>/", views.refresh_installed),
]

View File

@ -0,0 +1,68 @@
from django.conf import settings
from django.shortcuts import get_object_or_404
from rest_framework.decorators import (
api_view,
authentication_classes,
permission_classes,
)
from rest_framework.response import Response
from rest_framework import status
from agents.models import Agent
from .models import ChocoSoftware, InstalledSoftware
from .serializers import ChocoSoftwareSerializer, InstalledSoftwareSerializer
from .tasks import install_program
@api_view()
def chocos(request):
pk = ChocoSoftware.sort_by_highest()
choco = ChocoSoftware.objects.get(pk=pk)
return Response(ChocoSoftwareSerializer(choco).data)
@api_view(["POST"])
def install(request):
pk = request.data["pk"]
agent = get_object_or_404(Agent, pk=pk)
name = request.data["name"]
version = request.data["version"]
install_program.delay(pk, name, version)
return Response(f"{name} will be installed shortly on {agent.hostname}")
@api_view()
def get_installed(request, pk):
agent = get_object_or_404(Agent, pk=pk)
try:
software = InstalledSoftware.objects.filter(agent=agent).get()
except Exception:
return Response([])
else:
return Response(InstalledSoftwareSerializer(software).data)
@api_view()
def refresh_installed(request, pk):
agent = get_object_or_404(Agent, pk=pk)
r = agent.salt_api_cmd(hostname=agent.salt_id, timeout=30, func="pkg.list_pkgs")
try:
output = r.json()["return"][0][agent.salt_id]
except Exception:
return Response("error", status=status.HTTP_400_BAD_REQUEST)
try:
software = [{"name": k, "version": v} for k, v in output.items()]
except Exception:
return Response("error", status=status.HTTP_400_BAD_REQUEST)
if not InstalledSoftware.objects.filter(agent=agent).exists():
InstalledSoftware(agent=agent, software=software).save()
else:
current = InstalledSoftware.objects.filter(agent=agent).get()
current.software = software
current.save(update_fields=["software"])
return Response("ok")

View File

@ -1,6 +1,7 @@
from __future__ import absolute_import, unicode_literals
import os
from celery import Celery
from celery.schedules import crontab
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tacticalrmm.settings")
@ -14,6 +15,13 @@ app.task_serializer = "json"
app.conf.task_track_started = True
app.autodiscover_tasks()
app.conf.beat_schedule = {
'update-chocos': {
'task': 'software.tasks.update_chocos',
'schedule': crontab(minute=0, hour=4),
},
}
@app.task(bind=True)
def debug_task(self):

View File

@ -23,6 +23,7 @@ INSTALLED_APPS = [
'checks',
'services',
'winupdate',
'software',
]
MIDDLEWARE = [

View File

@ -17,4 +17,5 @@ urlpatterns = [
path("checks/", include("checks.urls")),
path("services/", include("services.urls")),
path("winupdate/", include("winupdate.urls")),
path("software/", include("software.urls")),
]

View File

@ -411,6 +411,7 @@ export default {
this.$store.dispatch("loadSummary", pk);
this.$store.dispatch("loadChecks", pk);
this.$store.dispatch("loadWinUpdates", pk);
this.$store.dispatch("loadInstalledSoftware", pk);
},
overdueAlert(category, pk, alert_action) {
const action = alert_action ? "enabled" : "disabled";

View File

@ -0,0 +1,116 @@
<template>
<div v-if="!Array.isArray(software) || !software.length">No software</div>
<div v-else>
<q-btn
size="sm"
color="grey-5"
icon="fas fa-plus"
label="Install Software"
text-color="black"
@click="showInstallSoftware = true"
/>
<q-btn dense flat push @click="refreshSoftware" icon="refresh" />
<q-table
class="software-sticky-header-table"
dense
:data="software"
:columns="columns"
:pagination.sync="pagination"
binary-state-sort
hide-bottom
row-key="name"
:loading="loading"
>
<template v-slot:loading>
<q-inner-loading showing color="primary" />
</template>
</q-table>
<q-dialog v-model="showInstallSoftware">
<InstallSoftware @close="showInstallSoftware = false" :agentpk="selectedAgentPk" />
</q-dialog>
</div>
</template>
<script>
import axios from "axios";
import { mapGetters } from "vuex";
import { mapState } from "vuex";
import InstallSoftware from "@/components/modals/software/InstallSoftware";
export default {
name: "SoftwareTab",
components: {
InstallSoftware
},
data() {
return {
showInstallSoftware: false,
loading: false,
pagination: {
rowsPerPage: 0,
sortBy: "name",
descending: false
},
columns: [
{
name: "name",
align: "left",
label: "Name",
field: "name",
sortable: true
},
{
name: "version",
align: "left",
label: "Version",
field: "version",
sortable: false
}
]
};
},
methods: {
refreshSoftware() {
const pk = this.selectedAgentPk;
this.loading = true;
axios
.get(`/software/refresh/${pk}`)
.then(r => {
this.$store.dispatch("loadInstalledSoftware", pk);
this.loading = false;
})
.catch(e => {
this.loading = false;
this.notifyError("Unable to contact the agent");
});
}
},
computed: {
...mapGetters(["selectedAgentPk"]),
...mapState({
software: state => state.installedSoftware
})
}
};
</script>
<style lang="stylus">
.software-sticky-header-table {
/* max height is important */
.q-table__middle {
max-height: 400px;
}
.q-table__top, .q-table__bottom, thead tr:first-child th {
background-color: #f5f4f2;
}
thead tr:first-child th {
position: sticky;
top: 0;
opacity: 1;
z-index: 1;
}
}
</style>

View File

@ -14,6 +14,7 @@
<q-tab name="summary" icon="fas fa-server" size="xs" label="Summary" />
<q-tab name="checks" icon="computer" label="Checks" />
<q-tab name="patches" label="Patches" />
<q-tab name="software" label="Software" />
</q-tabs>
<q-separator />
<q-tab-panels v-model="subtab" :animated="false">
@ -26,6 +27,9 @@
<q-tab-panel name="patches">
<WindowsUpdates />
</q-tab-panel>
<q-tab-panel name="software">
<SoftwareTab />
</q-tab-panel>
</q-tab-panels>
</div>
</template>
@ -34,12 +38,14 @@
import SummaryTab from '@/components/SummaryTab';
import ChecksTab from '@/components/ChecksTab';
import WindowsUpdates from '@/components/WindowsUpdates';
import SoftwareTab from '@/components/SoftwareTab';
export default {
name: "SubTableTabs",
components: {
SummaryTab,
ChecksTab,
WindowsUpdates
WindowsUpdates,
SoftwareTab,
},
data() {
return {

View File

@ -0,0 +1,131 @@
<template>
<q-card style="width: 50vw; max-width: 80vw;">
<q-card-section>
<q-table
class="choco-sticky-header-table"
title="Software"
dense
:data="chocos"
:columns="columns"
:pagination.sync="pagination"
:filter="filter"
binary-state-sort
hide-bottom
virtual-scroll
:rows-per-page-options="[0]"
row-key="name"
>
<template v-slot:top>
<q-input v-model="filter" outlined label="Search" dense clearable>
<template v-slot:prepend>
<q-icon name="search" />
</template>
</q-input>
<q-space />
<q-btn icon="close" flat round dense v-close-popup />
</template>
<template slot="body" slot-scope="props" :props="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
size="sm"
color="grey-5"
icon="fas fa-plus"
text-color="black"
@click="install(props.row.name, props.row.version)"
/>
</q-td>
<q-td @click="showDescription(props.row.name)">
<span style="cursor:pointer;color:blue;text-decoration:underline">{{ props.row.name }}</span>
</q-td>
<q-td>{{ props.row.version }}</q-td>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
</template>
<script>
import axios from "axios";
import { mapState } from "vuex";
import mixins from "@/mixins/mixins";
export default {
name: "InstallSoftware",
props: ["agentpk"],
mixins: [mixins],
data() {
return {
filter: "",
chocos: [],
pagination: {
rowsPerPage: 0,
sortBy: "name",
descending: false
},
columns: [
{ name: "install", align: "left", label: "Install", sortable: false },
{
name: "name",
align: "left",
label: "Name",
field: "name",
sortable: true
},
{
name: "version",
align: "left",
label: "Version",
field: "version",
sortable: false
}
]
};
},
methods: {
getChocos() {
axios.get("/software/chocos/").then(r => {
this.chocos = r.data.chocos;
});
},
showDescription(name) {
window.open(`https://chocolatey.org/packages/${name}`, "_blank");
},
install(name, version) {
const data = { name: name, version: version, pk: this.agentpk };
axios
.post("/software/install/", data)
.then(r => {
this.$emit("close");
this.notifySuccess(r.data);
})
.catch(e => {
this.notifyError("Something went wrong");
});
}
},
created() {
this.getChocos();
}
};
</script>
<style lang="stylus">
.choco-sticky-header-table {
/* max height is important */
.q-table__middle {
max-height: 30vw;
}
.q-table__top, .q-table__bottom, thead tr:first-child th {
background-color: #f5f4f2;
}
thead tr:first-child th {
position: sticky;
top: 0;
opacity: 1;
z-index: 1;
}
}
</style>

View File

@ -22,7 +22,8 @@ export const store = new Vuex.Store({
winUpdates: {},
agentChecks: {},
agentTableLoading: false,
treeLoading: false
treeLoading: false,
installedSoftware: []
},
getters: {
loggedIn(state) {
@ -76,6 +77,9 @@ export const store = new Vuex.Store({
SET_WIN_UPDATE(state, updates) {
state.winUpdates = updates;
},
SET_INSTALLED_SOFTWARE(state, software) {
state.installedSoftware = software;
},
setChecks(state, checks) {
state.agentChecks = checks;
},
@ -87,6 +91,11 @@ export const store = new Vuex.Store({
}
},
actions: {
loadInstalledSoftware(context, pk) {
axios.get(`/software/installed/${pk}`).then(r => {
context.commit("SET_INSTALLED_SOFTWARE", r.data.software);
});
},
loadWinUpdates(context, pk) {
axios.get(`/winupdate/${pk}/getwinupdates/`).then(r => {
context.commit("SET_WIN_UPDATE", r.data);

View File

@ -183,6 +183,7 @@ export default {
this.$store.dispatch("loadSummary", pk);
this.$store.dispatch("loadChecks", pk);
this.$store.dispatch("loadWinUpdates", pk);
this.$store.dispatch("loadInstalledSoftware", pk);
}
},
loadFrame(activenode) {