Several months ago I was tasked to prove that our brand new Caching Server was actually doing it’s job. I had already setup CacheWarmer with an hourly custom script/cronjob, so I knew when new iOS builds would come out, we wouldn’t be crippled.
SNMP could technically be an option but the Caching Service relies on random high ports. We could make assumptions, but that still wouldn’t be good enough to justify why we had this installed in out data center. Anecdotally, I knew it was working: Our users noticed app store improvements, I noticed app store improvements, but our executives wanted metrics.
Apple’s Server information is very minimal and that’s being generous.
E-mailing screenshots filled with squiggly line isn’t good enough. Maybe the logs can help?
After looking at the generated log files, I knew I was on to something. The logs gave me everything I needed, but with roughly 30,000 machines possibly hitting our caching server, the logs were enormous. I needed to figure out something.
Create a root cronjob. This is required for the alert mechanism to work. Alternatively you could create a LaunchDaemon as well, but I find this easier.
sudo env EDITOR=nano crontab -e
Nano will open. Put in your preferred e-mail time alert. I personally have an e-mail sent at 6:30 AM every day.
30 6 * * * /Users/Shared/Cacher/Cacher
After entering this into nano, use hit CTRL+X and save it. If successful, crontab will give you an installation status. If you need more information on setting up a different time, please see this maclife article
Now sit back, wait until tomorrow to see your new statistics and wonder why Apple doesn’t have something like already.
If you’re here, chances are you’re interested in Imagr and read a blog post or two or three. Unfortunately, something is holding you back. Maybe it’s time, your imaging process or a very specific function of DeployStudio that you absolutely need. As you’ll quickly find out, most barriers are short lived.
Sir Gilbert has opted for the Wiki route for a few reasons:
– Smaller codebase to maintain
– Easier entry for admins to contribute to the project.
Currently there are only a few scripts. As you begin to transition over to Imagr, if there is a configuration setting that you find to script, please add it to the list.
Breaking down your DeployStudio Workflows
I’ve taken DeployStudio for granted for many years. While I document many other processes, due to DeployStudio being mostly WYSIWYG, I’ve never felt compelled to actually list out each process I used.
If you want a successful transition you’re going to want to document. You know those checkboxes you use in DeployStudio? Document them!
Deploy Studio Breakdown Example
1. DS Restore Task
- Restore System Recovery Partition
- Set as Default Startup Volume
- Preventative Volume Repair
- Convert to CoreStorage
2. DS HostName Task
3. DS Configure Task
- Skip Apple Setup Assistant
- Disable Gatekeeper
4. DS Generic Task
- Munki Manifest Selector
5. DS Package Install Task
- Munki
6. DS SoftwareUpdate Task
7. Time Task
8. DS Active Directory Task
9. Automatic Reboot after completion (built into DS)
Let’s tackle these one by one. All of these tasks should be added to the components array
DS Restore Task
This one can is rather simple. By default, Imagr will automatically bless the volume.
Rich Trouton has a great script to accomplish this.
<dict>
<key>type</key>
<string>script</string>
<key>content</key>
<string>#!/bin/sh
#Primary Time server for Company Macs
TimeServer1=timeserver1.company.com
#Secondary Time server for Company Macs
TimeServer2=timeserver2.company.com
#Tertiary Time Server for Company Macs, used outside of Company network
TimeServer3=time.apple.com
# Time zone for Company Macs
TimeZone=America/New_York
# Configure network time server and region
# Set the time zone
/usr/sbin/systemsetup -settimezone $TimeZone
# Set the primary network server with systemsetup -setnetworktimeserver
# Using this command will clear /etc/ntp.conf of existing entries and
# add the primary time server as the first line.
/usr/sbin/systemsetup -setnetworktimeserver $TimeServer1
# Add the secondary time server as the second line in /etc/ntp.conf
echo "server $TimeServer2" >> /etc/ntp.conf
# Add the tertiary time server as the third line in /etc/ntp.conf
echo "server $TimeServer3" >> /etc/ntp.conf
# Enables the Mac to set its clock using the network time server(s)
/usr/sbin/systemsetup -setusingnetworktime on
</string>
</dict>
DS Active Directory Task
I would highly recommend that you package this as saving this directly in the imagr_config.plist will leave your AD binding account exposed.
With that said, Sir Gilbert has a great script for this (taken from DS.)
<dict>
<key>type</key>
<string>script</string>
<key>content</key>
<string>#!/bin/sh
# This was stolen from DeployStudio. I didn't write it, but dammit, I'm going to use it.
#
# Script config
#
AD_DOMAIN="ad.company.com"
COMPUTER_ID=`/usr/sbin/scutil --get LocalHostName`
COMPUTERS_OU="OU=Macs,OU=London,DC=ad,DC=company,DC=com"
ADMIN_LOGIN="bindUser"
ADMIN_PWD="bindPassword"
MOBILE="enable"
MOBILE_CONFIRM="disable"
LOCAL_HOME="enable"
USE_UNC_PATHS="enable"
UNC_PATHS_PROTOCOL="smb"
PACKET_SIGN="allow"
PACKET_ENCRYPT="allow"
PASSWORD_INTERVAL="0"
ADMIN_GROUPS="COMPANY\Domain Admins,COMPANY\Enterprise Admins"
# UID_MAPPING=
# GID_MAPPING=
# GGID_MAPPING==
# disable history characters
histchars=
SCRIPT_NAME=`basename "${0}"`
echo "${SCRIPT_NAME} - v1.26 ("`date`")"
#
# functions
#
is_ip_address() {
IP_REGEX="\b(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b"
IP_CHECK=`echo ${1} | egrep ${IP_REGEX}`
if [ ${#IP_CHECK} -gt 0 ]
then
return 0
else
return 1
fi
}
#
# Wait for the naming script to have run
#
if [ ${COMPUTER_ID} -eq "" ]
then
echo "The mac doesn't have a name, exiting."
exit 1
fi
# AD can only use a 15 character name
COMPUTER_ID=`echo ${COMPUTER_ID} | cut -c1-15`
#
# Wait for network services to be initialized
#
echo "Checking for the default route to be active..."
ATTEMPTS=0
MAX_ATTEMPTS=18
while ! (netstat -rn -f inet | grep -q default)
do
if [ ${ATTEMPTS} -le ${MAX_ATTEMPTS} ]
then
echo "Waiting for the default route to be active..."
sleep 10
ATTEMPTS=`expr ${ATTEMPTS} + 1`
else
echo "Network not configured, AD binding failed (${MAX_ATTEMPTS} attempts), will retry at next boot!" 2>&1
exit 1
fi
done
#
# Wait for the related server to be reachable
# NB: AD service entries must be correctly set in DNS
#
SUCCESS=
is_ip_address "${AD_DOMAIN}"
if [ ${?} -eq 0 ]
then
# the AD_DOMAIN variable contains an IP address, let's try to ping the server
echo "Testing ${AD_DOMAIN} reachability" 2>&1
if ping -t 5 -c 1 "${AD_DOMAIN}" | grep "round-trip"
then
echo "Ping successful!" 2>&1
SUCCESS="YES"
else
echo "Ping failed..." 2>&1
fi
else
ATTEMPTS=0
MAX_ATTEMPTS=12
while [ -z "${SUCCESS}" ]
do
if [ ${ATTEMPTS} -lt ${MAX_ATTEMPTS} ]
then
AD_DOMAIN_IPS=( `host "${AD_DOMAIN}" | grep " has address " | cut -f 4 -d " "` )
for AD_DOMAIN_IP in ${AD_DOMAIN_IPS[@]}
do
echo "Testing ${AD_DOMAIN} reachability on address ${AD_DOMAIN_IP}" 2>&1
if ping -t 5 -c 1 ${AD_DOMAIN_IP} | grep "round-trip"
then
echo "Ping successful!" 2>&1
SUCCESS="YES"
else
echo "Ping failed..." 2>&1
fi
if [ "${SUCCESS}" = "YES" ]
then
break
fi
done
if [ -z "${SUCCESS}" ]
then
echo "An error occurred while trying to get ${AD_DOMAIN} IP addresses, new attempt in 10 seconds..." 2>&1
sleep 10
ATTEMPTS=`expr ${ATTEMPTS} + 1`
fi
else
echo "Cannot get any IP address for ${AD_DOMAIN} (${MAX_ATTEMPTS} attempts), aborting lookup..." 2>&1
break
fi
done
fi
if [ -z "${SUCCESS}" ]
then
echo "Cannot reach any IP address of the domain ${AD_DOMAIN}." 2>&1
echo "AD binding failed, will retry at next boot!" 2>&1
exit 1
fi
#
# Unbinding computer first
#
echo "Unbinding computer..." 2>&1
dsconfigad -remove -username "${ADMIN_LOGIN}" -password "${ADMIN_PWD}" 2>&1
#
# Try to bind the computer
#
ATTEMPTS=0
MAX_ATTEMPTS=12
SUCCESS=
while [ -z "${SUCCESS}" ]
do
if [ ${ATTEMPTS} -le ${MAX_ATTEMPTS} ]
then
echo "Binding computer to domain ${AD_DOMAIN}..." 2>&1
dsconfigad -add "${AD_DOMAIN}" -computer "${COMPUTER_ID}" -ou "${COMPUTERS_OU}" -username "${ADMIN_LOGIN}" -password "${ADMIN_PWD}" -force 2>&1
IS_BOUND=`dsconfigad -show | grep "Active Directory Domain"`
if [ -n "${IS_BOUND}" ]
then
SUCCESS="YES"
else
echo "An error occured while trying to bind this computer to AD, new attempt in 10 seconds..." 2>&1
sleep 10
ATTEMPTS=`expr ${ATTEMPTS} + 1`
fi
else
echo "AD binding failed (${MAX_ATTEMPTS} attempts), will retry at next boot!" 2>&1
SUCCESS="NO"
fi
done
if [ "${SUCCESS}" = "YES" ]
then
#
# Update AD plugin options
#
echo "Setting AD plugin options..." 2>&1
dsconfigad -mobile ${MOBILE} 2>&1
sleep 1
dsconfigad -mobileconfirm ${MOBILE_CONFIRM} 2>&1
sleep 1
dsconfigad -localhome ${LOCAL_HOME} 2>&1
sleep 1
dsconfigad -useuncpath ${USE_UNC_PATHS} 2>&1
sleep 1
dsconfigad -protocol ${UNC_PATHS_PROTOCOL} 2>&1
sleep 1
dsconfigad -packetsign ${PACKET_SIGN} 2>&1
sleep 1
dsconfigad -packetencrypt ${PACKET_ENCRYPT} 2>&1
sleep 1
dsconfigad -passinterval ${PASSWORD_INTERVAL} 2>&1
if [ -n "${ADMIN_GROUPS}" ]
then
sleep 1
dsconfigad -groups "${ADMIN_GROUPS}" 2>&1
fi
sleep 1
if [ -n "${AUTH_DOMAIN}" ] && [ "${AUTH_DOMAIN}" != 'All Domains' ]
then
dsconfigad -alldomains disable 2>&1
else
dsconfigad -alldomains enable 2>&1
fi
AD_SEARCH_PATH=`dscl /Search -read / CSPSearchPath | grep "Active Directory" | sed 's/^ *//' | sed 's/ *$//'`
if [ -n "${AD_SEARCH_PATH}" ]
then
echo "Deleting '${AD_SEARCH_PATH}' from authentication search path..." 2>&1
dscl localhost -delete /Search CSPSearchPath "${AD_SEARCH_PATH}" 2>/dev/null
echo "Deleting '${AD_SEARCH_PATH}' from contacts search path..." 2>&1
dscl localhost -delete /Contact CSPSearchPath "${AD_SEARCH_PATH}" 2>/dev/null
fi
dscl localhost -create /Search SearchPolicy CSPSearchPath 2>&1
dscl localhost -create /Contact SearchPolicy CSPSearchPath 2>&1
AD_DOMAIN_NODE=`dscl localhost -list "/Active Directory" | head -n 1`
if [ "${AD_DOMAIN_NODE}" = "All Domains" ]
then
AD_SEARCH_PATH="/Active Directory/All Domains"
elif [ -n "${AUTH_DOMAIN}" ] && [ "${AUTH_DOMAIN}" != 'All Domains' ]
then
AD_SEARCH_PATH="/Active Directory/${AD_DOMAIN_NODE}/${AUTH_DOMAIN}"
else
AD_SEARCH_PATH="/Active Directory/${AD_DOMAIN_NODE}/All Domains"
fi
echo "Adding '${AD_SEARCH_PATH}' to authentication search path..." 2>&1
dscl localhost -append /Search CSPSearchPath "${AD_SEARCH_PATH}"
echo "Adding '${AD_SEARCH_PATH}' to contacts search path..." 2>&1
dscl localhost -append /Contact CSPSearchPath "${AD_SEARCH_PATH}"
if [ -n "${UID_MAPPING}" ]
then
sleep 1
dsconfigad -uid "${UID_MAPPING}" 2>&1
fi
if [ -n "${GID_MAPPING}" ]
then
sleep 1
dsconfigad -gid "${GID_MAPPING}" 2>&1
fi
if [ -n "${GGID_MAPPING}" ]
then
sleep 1
dsconfigad -ggid "${GGID_MAPPING}" 2>&1
fi
GROUP_MEMBERS=`dscl /Local/Default -read /Groups/com.apple.access_loginwindow GroupMembers 2>/dev/null`
NESTED_GROUPS=`dscl /Local/Default -read /Groups/com.apple.access_loginwindow NestedGroups 2>/dev/null`
if [ -z "${GROUP_MEMBERS}" ] && [ -z "${NESTED_GROUPS}" ]
then
echo "Enabling network users login..." 2>&1
dseditgroup -o edit -n /Local/Default -a netaccounts -t group com.apple.access_loginwindow 2>/dev/null
fi
#
# Self-removal
#
if [ "${SUCCESS}" = "YES" ]
then
if [ -e "/System/Library/CoreServices/ServerVersion.plist" ]
then
DEFAULT_REALM=`more /Library/Preferences/edu.mit.Kerberos | grep default_realm | awk '{ print $3 }'`
if [ -n "${DEFAULT_REALM}" ]
then
echo "The binding process looks good, will try to configure Kerberized services on this machine for the default realm ${DEFAULT_REALM}..." 2>&1
/usr/sbin/sso_util configure -r "${DEFAULT_REALM}" -a "${ADMIN_LOGIN}" -p "${ADMIN_PWD}" all
fi
#
# Give OD a chance to fully apply new settings
#
echo "Applying changes..." 2>&1
sleep 10
fi
if [ -e "${CONFIG_FILE}" ]
then
/usr/bin/srm -mf "${CONFIG_FILE}"
fi
/usr/bin/srm -mf "${0}"
exit 0
fi
fi
exit 1
</string>
</dict>
DS Automatic Reboot
If you would like Image to automatically reboot after deploying your image (and begin it’s first boot processes), add this somewhere in your workflow.
As you can see, many of DeployStudio’s checkboxes are very small configuration changes. While none of these examples contain any error checking, they work quite well.
Through the years my workflows have slimmed down considerably. Most have been converted to mobile configuration files, while others have simply moved into munki/outset. While you may consider some aspects of DeployStudio essential, I continue to find myself decreasing the amount of steps needed for a “successful image”. As you transfer your workflows over to Imagr, think about trimming down as much fat as possible. In the end your results will be much more stable and you’ll become a more dynamic admin.
There is one tool that is integral to my technicians workflow and my sanity. With several hundred locations, I needed a way to properly set Munki’s ClientIdentifier. At previous employers, I was able to define a machine naming convention that worked well, but with so many moving parts where I am at now, I needed something dynamic, consistent and stable: Munki Manifest Selector
If you have never used it, I highly recommend at least trying it out. It requires minimal work and although I use an internal fork, the functionality is for the most part identical. Joe has a great post here
As I began to test and move my workflows to Imagr, I hit a road block. While both DeployStudio and Imagr have a generic task, they are radically different architecturally.
DeployStudio vs Imagr Generic Task:
DeployStudio
Since DeployStudio mounts a network volume, the Runtime has direct access to MMS. All an admin needs to do is place both Munki Manifest Selector.app and a corresponding script into the DeployStudio Scripts folder and then call it with a Generic Task. Here is an example script.
Unlike DeployStudio, all Imagr components are downloaded individually. What’s an admin to do?
Get Creative – Curl to the rescue!
To begin, let’s download MMS here. For this example please rename the dmg to “Munki_Manifest_Selector.dmg”
If you’ve followed Nick McSpadden’s Imagr Guide, in your recently made Imagr folder, create a new folder called “packages” and place the DMG there.
mkdir -p /yourmunkirepo/imagr/packages
After placing it there, we need to add a workflow to your imagr_config.plist but let’s break this down first.
After pulling down the image and verifying a successful deployment, we are going to utilize Imagr’s Generic Task with first_boot disabled. This task is going to do a few things:
– Curl the DMG (using the new deployment’s curl binary)
– Mount the dmg using hdiutil from the NBI
– Run Munki Manifest Selector and wait for user input
– Unmount the DMG using hdiutil from the NBI
– Remove the DMG from the deployment
Currently, AutoNBI does not add the curl binary. Don’t fret though – it is going to happen. For now we will use this somewhat hacky method.
That’s it. There’s no need to re-architect MMS – just simply wrap it in a DMG. Obviously this is a first version and there isn’t any download verification but with MMS being so small (~100k) for most deployments this should be sufficient.
Don’t stop yourself from moving to Imagr. Get creative and you will be happy with the results.
Customizing munki can be quite rewarding when done right. By utilizing the client_resources.zip file you can make a great looking GUI for your users.
Trick 1: Add CSS into footer_template.html
Bart Reardon first documented this trick. You can modify (for better or worse) the css Munki utilizes by adding your desired changes directly into the footer_template.html. The following is what I am currently using to make Munki look more like the App Store in Yosemite.
This simply changes the background color to white and adds a subtle gradient to the bottom of the window. You can add CSS to any template file, but the footer_template is on every page view.
Trick 2: Adding an icon to the sidebar (sidebar_template.html)
Adding an icon to the sidebar is quite simple – simply add your .png file to your resources folder and use a relative link. Make sure you center it (and don’t go over 128×128 px) or it will look terrible!
Trick 3: Linking banners to Optional Installs (showcase_template.html)
This isn’t really a trick, but more of a feature that isn’t completely documented. You can link individual banners to specific items in your repository.
Below you will find three examples: All Categories, Music and iMovie. Make sure you add .html to each item or it will not work.
Attached below are several banners that I have modified from the Mac App Store. These banners are the correct dimension (1158×200) and configured in a way so no matter how small MSC is, items will not be cut off. Enjoy!
For the past few weeks I have been trying to rid myself of all Default User Template changes. The last piece to the puzzle is Setup Assistant.
Beginning with Yosemite, Apple introduced a new page for submitting diagnostics and usage. Rich Trouton and Tim Sutton had documented this fairly well, but I wanted to put this in a mobile configuration profile. After digging around, it looks as if Apple has now added a feature in Profile Manager for this feature.
Attached is an example of the settings you will need to manage.
I am currently in the midst of radically changing the OS X experience at my company. Users will be going from monolithic/unmanaged to a self-service/managed model. This could be jarring for them and without proper user training, they could easily miss some of the key features we would like them to know about.
Enter Yo. Written by Craig Shea and utilizing Swift, it allows you to target Notification Center.
By utilizing Outset, Luggage, and Yo, we can create a user login condition that will notify the user of available documentation.
Create a working directory. When playing with things like this, I prefer the desktop. Let’s open terminal.
mkdir -p ~/Desktop/YoExample
If you have Pages, go ahead and make a document, export as a PDF and save it to our working directory. In this example, let’s use yo.pdf.
Outset is a powerful tool that allows you to run packages/scripts at various stages. Let’s create a script and save it in our working directory. Outset requires that your scripts contain the proper extension, so don’t forget it.
Once again in terminal:
cd ~/Desktop/YoExample
nano yo.sh
This will bring up a command line editor called nano. Nano is a replacement for pico and recommended by Apple for modifying configuration files.
Inside nano let’s copy/paste the following:
#!/bin/sh
/Applications/Utilities/Yo.app/Contents/MacOS/yo\
-t "Yo is awesome" \
-s "To view the document" \
-n "Please click on Open PDF" \
-b "Open PDF" \
-a "/Library/Documentation/Yo.pdf" \
-p \
-z "sms_alert_note"
Once entered, press ctrl+o to write the file. It will confirm the name you previously entered – hit enter to save. If done correctly, you should now have a bash script file located in your working directory.
Craig has some great documentation. Let’s look at the values we have setup.
-t, –title:
Title for notification. REQUIRED.
-s, –subtitle:
Subtitle for notification.
-n, –info:
Informative text.
-b, –action-btn:
Include an action button, with the button label text supplied to this argument.
-p, –poofs-on-cancel:
Set to make your notification ‘poof’ when the cancel button is hit.
-a, –action-path:
Application to open if user selects the action button. Provide the full path as the argument. This option only does something if -b/–action-btn is also specified. Defaults to opening nothing.
-z, –delivery-sound:
The name of the sound to play when delivering. Usually this is the filename of a system sound minus the extension. See the README for more info.
We are going to have a title, subtitle and informative text. For a little flair, we will have the notification “poof” if a user hits cancel. By pointing to a private framework sound, we can also use the same alert that a user will hear when receiving an iMessage (located at /System/Library/PrivateFrameworks/ToneLibrary.framework/Versions/A/Resources/AlertTones/Modern/). The action path itself can point to anything and invokes the open command. The options are endless.
If you are paying attention, you’ll notice this script is pointing to /Library/Documentation/yo.pdf. How might one get this file to this location? You could manually move it for this test or build a pkg with Packages, but let’s take a slightly different approach.
Enter Luggage, a great tool that doesn’t seem to get a lot of blog posts. For this post we won’t go too in depth, but let’s do a quick overview.
Luggage allows you to easily create a package, based on a set of parameters through the use of a Makefile.
Let’s briefly discuss what we are doing here.
l_usr_local_outset_login_once tells Luggage to make the directories for Outset (if they don’t already exist) and ensure proper permissions. l_Library_Documentation does the same thing for our Documentation folder.
pack-usr-local-outset-login-once tells Luggage to install our script into the correct Outset folder (calling the l_usr_local_outset_login_once) and pack-Library-Documentation installs our PDF.
With our Makefile in hand, let’s generate our pkg file.
In terminal type:
make pkg
Luggage will ask you for your password. If everything goes right, a package will be generated in your working folder called YoDocumentationExample-1.0 and terminal will look something like this.
Please note that Luggage is very particular with tab spacing and it is possible that wordpress will strip them. See the bottom of the post for the actual files.
With your package in tow, install it, reboot (or logout) and log back in to see your notification appear.
If you click on Open PDF, you should see something like this:
Great! You have a fully functioning setup for all users, and a deployable package. You can stop here but what if you want more?
What if you want to change the icon on the alert? For this, we will need to download the source files and modify them in Xcode. Back in terminal let’s use git to clone Craig’s github repository. Make sure you have already installed Xcode and the command line tools prior to running this command or it will fail.
cd ~/Desktop/YoExample
git clone https://github.com/sheagcraig/yo.git
Browse to your YoExample folder, and a new folder called yo will now exist. Double click on yo.xcodeproj to open the project in Xcode.
Expand the root yo folder, the sub folder called yo and the Supporting Files folder. Click on Images.xcassets and then click on AppIcon. Drag and drop any PNG image (size 128×128) into the Mac 128pt 1x area and replace the original icon. Here is a great icon you could use.
NeagleCon!
Now that we’ve replaced the icon, let’s change the BundleIdentifier to ensure our new icon will take effect. Click on Info.plist and change the bundle identifier to something you see fit. In this example let’s use com.github.erikng.
With our changes made, let’s save the project (CMD +S) and build our project (CMD+B).
By default Xcode saves projects to ~/Library/Developer/Xcode/DerivedData.
Once there, you should find a folder called “yo-xxxxxxxxx”. Expand that folder and continue down the rabbit hole.
~/Library/Developer/Xcode/DerivedData/yo-xxxxxxxxx/Build/Products/Debug
You should see an application bundle with your new (awesome) icon. Copy this bundle to our original working folder ~/Desktop/YoExample.
Have I told you how awesome Luggage is? It’s awesome. Let’s change the payload section of our Makefile. Using nano or your favorite text editor, change the payload section to the following:
Once again in terminal browse to your working directory and make pkg.
cd ~/Desktop/YoExample
nano Makefile
Your package will regenerate and now include your customized application. Install the new package, log out and log back in and your notification should re-appear, but look different.
Congrats! You now have a customized notification that will appear for all of your users and a package you can deploy out with your favorite deployment tool.