This blog post is part of a series that intends to show in an easy step-by-step way how to use the SAP Job Scheduling Service running on SAP Business Technology Platform (aka SAP Cloud Platform).
In the current sub-series of the series, we're trying to shed some light into the dark multi tenant space of multi confusancy.
Quicklinks:
Intro
Sample Code
After yesterdays intro, today we’re doing the first steps to create a very simple multitenant application and enable Job Scheduler to do its job.
The application itself doesn’t do anything meaningful: when opened, it just displays a silly friendly text on the browser.
The app also offers a REST endpoint which is meant to be called by Job Scheduler. We call it "action"-endpoint.
We start as developer, creating a multitenant test app (MuTeTe) and deploy it.
We’re going to learn which steps are required to get Job Scheduler into the MT scenario.
Then we switch our role and become a customer who subscribes to the app.
Finally, we switch back to developer role and create a Job for our (provider) app.
This scenario represents the architecture of use case 1, as described in the intro:
In this scenario, the job is created manually in the dashboard and the action-endpoint doesn't do anything specific to subscribers.
As such, the Job Scheduler in this scenario does not need to be multitenant-aware.
Ups? Not tenant-aware?
So why do we need this blog post at all?
This is a valid use case for Job Scheduler in multitenancy.
And with this post we set the ground, as the following samples will be based on it.
Content
0. Prerequisites
Steps done by Provider:
1. Create Project
2. Create service instances
3. Create MT application
Steps done by Customer:
4. Subscribe to the app and use it
Steps done by Provider:
5. Create and run Job
And
A1 Useful commands
A2 Find subdomain
A3 Sample code
Note that my tutorials are written by a dummy, so really anybody can do it.
Nevertheless, before getting started, let’s make sure we have everything in place.
Environment
Like all other blog posts of this series, we refer to the Cloud Foundry environment of SAP Business Technology Platform.
For managing cloud platform services, we’re using the CF command line client, but anybody who prefers using the cockpit can do so.
The commands are based on MS Windows.
Provider Subaccount:
In the provider subaccount, we need to be entitled for using the “SaaS Provisioning Service” (saas-registry), along with “Job Scheduler” and “Authorization & Trust Management” (xsuaa). Means you might need to edit the "Entitlements" section of your account.
Consumer Subaccount:
For our multitenancy scenario we need at least 2 subaccounts in the same global account.
We’ll deploy our provider app in one subaccount (which acts as provider account) and we’ll subscribe to it in the second subaccount (acting as consumer account, or customer account, or tenant)
The sample apps are written in node.js
Background
The basic concepts of multitenancy in Cloud Foundry are expected, please refer to the links section for more info.
The basic concepts of Job Scheduler in multitenancy app (with other words: this blog) are expected.
We create a project folder, e.g. C:\mutete
And create the following files in it:
C:\mutete
- config-saasreg.json
- manifest.yml
- package.json
- server.js
- xs-security.json
We paste the file content which can be found in the Appendix section
Let's make sure that we create the 3 required service instances in correct order.
Note:
To avoid confusion:
All our development steps are performed in the provider sub account
So we make sure to login to the proper account before we create service instances.
Create Instance of Job Scheduling Service
If not already done, create an instance of Job Scheduler service, as described here.
In short:
cf cs jobscheduler standard myJobschedulerInstance -c "{\"enable-xsuaa-support\": true}"
Create Instance of XSUAA Service
Configuration parameters for the xsuaa service instance for today's sample are simple:
{
"xsappname": "mutetexsappname",
"tenant-mode": "shared"
}
We specify an xsappname that is easy to recognize.
"tenant-mode":"shared" means that the credentials of this instance are shared among subscribers.
More concrete, it means that all subscribers get a kind of copy of the xsuaa-instance, where the clientid/secret are the same.
Only the URL of the authorization server is different. Obvious, it has to live in the subscriber subaccount.
Why do we do that?
In our multitenant app, we need to know all our subscribers and we need to be able to call the authorization server (xsuaa) of them, to fetch a token on behalf of subscriber.
This is possible because clientid/secret are “shared”, we can use the credentials that we get from our binding.
For the moment, we aren’t defining any scope. In this first version of our sample, we aren’t dealing with security.
So why create an instance of xsuaa at all?
Because SaaS registry requires it.
Now we open command line, jump into directory c:\mutete to run following commands:
Create instance:
The command points to a config file located in the same folder where the command is executed and creates an instance with name "muteteXsuaa":
cf cs xsuaa application muteteXsuaa -c xs-security.json
Then create service key with name "sk":
cf csk muteteXsuaa sk
And view service key:
cf service-key muteteXsuaa sk
The content of service key is printed to the console:
From the displayed service key, we need to take a note of the property xsappname.
We cannot just copy the xsappname from our xs-security.json file because xsappname is generated with little postfix.
In my example: "xsappname": "mutetexsapp!t17916"
We need the concrete generated name to copy & paste it into the configuration params for the saas-registry service.
Create Instance of saas-registry Service
We need an instance of the saas-registry service in order to register our application and make it multitenant.
Our sample app will be bound to this service instance and the SaaS registry will call our app and ask the info that's needed for registration.
To create saas-registry service, we need a config file. The following snippet can be copied, but needs to be adapted:
{
"appId": "mutetexsappname!t17916",
"appName": "muteteAppNameForSaasReg",
"appUrls": {
"getDependencies": "https://mutete.cfapps.eu10.hana.ondemand.com/handleDependencies",
"onSubscription": "https://mutete.cfapps.eu10.hana.ondemand.com/handleSubscription/{tenantId}"
},
"displayName": "MuTeTe with Job Scheduler"
}
Explanation:
appId
The value must be exactly the same like the generated value of the property “xsappname” of the xsuaa-instance.
To get the generated value, we've created and viewed the service key in previous step. Don't forget to replace the value of my snippet.
appName
Can be any value of our choice.
appUrls
These URLs are called by the SaaS registry and must be valid and existing after deployment of our app.
The segment {tenantId} is a variable which will be replaced when SaaS registry calls our app.
These URLs are defined by us, we can name the routes according to our choice. Only make sure that the variable is in place.
And of course, we have to make sure that the 2 URLs we specify here, are really exposed by our app and are accessible.
Note that the URL depends on your region, so make sure to replace it (e.g. "eu10").
displayName
This property is optional, but it is useful, because this name will be visible in the cockpit, when the consumer subscribes to our app.
It will be easier to find our app if we specify a display name here.
The value is left to our choice.
Documentation of the config params for SaaS registry can be found here.
Now we can create the instance.
The creation command points to a config file located in the same folder where the command is executed.
As usual, the config file can have any name of our choice because the command will point to the config file with the -c param.
For our samples, we call it config-saasreg.json
The following command creates an instance with name "muteteSaasreg":
cf cs saas-registry application muteteSaasreg -c config-saasreg.json
The service plan application indicates that we’re going to register a multitenant application, not a multitenant reuse-service.
Now that we have the 3 service instances in place, we can create our very simple application, according to the sample code in the appendix
Create app
In package.json, we can see that we need only the basic dependency to express.
As usual, trying adhere to native libs only.
Our express based application is contained in server.js file and is as simple as possible.
Basically, it consists of a main entry point to the application (a sort of homepage) and it provides an endpoint for jobscheduler (the “/action” endpoint)
Both endpoints don’t do anything meaningful, only purpose being to serve our scenario.
Remember our scenario?
It consists of a multitenancy app and Job Scheduler.
As such, we provide the endpoint “/action” that is meant to be called by Job Scheduler. To keep things simple, we don’t protect it (see here how to protect it.)
app.get('/app', function(req, res){
res.send(`<h1>Homepage of multitenant app</h1>`)
});
app.get('/action', function(req, res){
res.send(`ACTION endpoint for jobscheduler successfully invoked.`)
});
app.listen(process.env.PORT, function () {
console.log('===> Server running')
});
In addition to the application logic, our app needs to provide 3 additional endpoints that are required by the SaaS registry, to support the multitenancy mode.
Remember:
We declared the full URLs for the callbacks in the params of the instance of SaaS registry service:
"getDependencies" : "https://mutete.cfapps.sap.hana.ondemand.com/handleDependencies",
"onSubscription" : "https://mutete.cfapps.sap.hana.ondemand.com/handleSubscription/{tenantId}"
Now we have to define the endpoints according to it.
Note that the path of the endpoint is left to our choice. We only need to make sure that the declaration matches our app code.
The getDependencies callback is optional and it is called by the SaaS registry only if we declare it in the config. And we declare it only if our MT app has a dependency that needs to be informed about consumers.
In our sample, this is the case: Job Scheduler needs to be informed, whenever a customer (consumer) subscribes to our app.
Why?
Job Scheduler will remember the tenant-ID of the consumer and when the consumer unsubscribes, then Job Scheduler will automatically remove all jobs created for this consumer.
This is a nice feature, because otherwise it would have been our job, to delete the orphaned jobs.
To inform the SaaS registry about dependencies, we need to send a JSON array in the response of the getDependencies callback.
The JSON contains the value of property “xsappname” which belongs to Job Scheduler service instance.
Note:
Don’t confuse it with OUR xsappname of the xsuaa instance that we created above.
We retrieve the Job Scheduler-xsappname from the binding information (subnode: “uaa”)
app.get('/handleDependencies', (req, res) => {
const dependencies = [{'xsappname': JOB_CREDENTIALS.uaa.xsappname }]
res.status(200).json(dependencies);
});
Note that the SaaS registry will fail if the provided value is not correct.
Note:
Frankly speacking, we don't really need this dependency in the first version of our sample, because in use case 1, the Job Scheduler is not tenant-aware.
Nevertheless, we're adding this code here because we need it in the upcoming tutorials.
The next callback is the most relevant for application logic:
In the onSubscription callback, the subscriber is actually registered in SaaS registry.
In our app, we cannot know who has pressed the subscribe button, so the SaaS registry has to inform us. We can read this information in the URL-parameter and in the request body.
Our job in the callback is to compose a URL for our app.
Why?
Because the URL will be different for each consumer.
In order to uniquely identify each consumer-URL, we concatenate the subdomain of the consumer with the “normal” app-URL.
Example:
Our app-URL in single tenant mode:
https://mutete.cfapps.sap.hana.ondemand.com/app
In multi tenant mode, the app-URL that is used after subscription by a customer:
https://cust1subdomain-mutete.cfapps.eu10.hana.ondemand.com/app
How do we get the required information about the subdomain of the consumer?
The SaaS registry calls our onSubscription endpoint with PUT request which contains the required information in the request body.
We can access it from the code:
req.body.subscribedSubdomain
Note: we can only access the body if we have added the corresponding middleware, like body-parser or
app.use(express.json())
And this is how we compose the consumer-specific homepage-URL:
app.put('/handleSubscription/:myConsumer', (req, res) => {
const subDomain = req.body.subscribedSubdomain
const appHost = req.hostname
const subscriberAppURL = `https://${subDomain}-${appHost}/app`
res.status(200).send(subscriberAppURL)
});
Note:
In the config file for SaaS registry, we specified the URL of our callback:
"onSubscription" : "https://mutete.cfapps...com/handleSubscription/{tenantId}"
In this format, the URL supports a variable, which is replaced at runtime.
When the SaaS registry calls our endpoint, it will look as follows:
https://mutete.cfapps...ondemand.com/handleSubscription/abc-123-DEF-456
This means, the SaaS registry appends the ID of the tenant which subscribes to our app.
In our callback implementation we can access that ID via the variable that we specify in our endpoint.
In above example, the abc-123-DEF-456 is contained in the variable myConsumer.
This is important, because we need to separate information about each customer.
Why we don't use it?
In our simple sample, we’re not storing any customer-specific info, so we don’t need to access that variable.
The last callback is called by the SaaS registry, whenever a customer unsubscribes from our multitenant application.
In a real application, we would use this callback to remove customer-specific data from any storage.
In our simple sample, we don’t need to do such things, so we only return a success status and a message that isn’t read by anybody.
app.delete('/handleSubscription/:myConsumer', (req, res) => {
res.status(200).end('unsubscribed')
});
Deploy app
Before we deploy, we have a quick look into our deployment descriptor manifest.yml
---
applications:
- name: mutete
routes:
- route: mutete.cfapps.sap.hana.ondemand.com
memory: 128M
services:
- muteteXsuaa
- muteteSaasreg
- myJobschedulerInstance
We can see that we've defined a route that matches the callback URLs defined in the config-saasreg.json file.
We can see that the route (i.e. our application URL) has nothing to do with customers/tenants (we'll be coming back to routes later)
And we can see that the order of service instances matches the requirement (bind xsuaa first)
Now we deploy the app with cf push
Now we change our mindset: for the next step, we take the role of a customer (or admin of customer).
So we login to our sub account which represents the customer/consumer/subscriber/tenant
Subscribe
Then we can use the BTP Cockpit to search the marketplace for our MuTeTe application and create a subscription:
Next thing a subscriber would do is to click on “Go to Application”.
However, the subscriber gets an error.
Reason:
the URL which has been composed for subscriber-specific access, is not reachable, because not existing. In Cloud Foundry, we need to specify each application-URL as a new "route" and then assign (map) it to our app.
As we can see, it is necessary, e.g. in case of multitenancy, where one application can have multiple routes for accessing it.
Create Route
Sorry for confusion, but we need to switch back to developer role to create the route for our MT app.
The following command creates a route and maps it to our app.
Make sure to adapt the "customer1" to your subdomain.
Remember that "mutete" is the name of our app.
cf map-route mutete cfapps.sap.hana.ondemand.com --hostname customer1-mutete
Not sure where to find the subdomain?
See appendix 2.
Alternative 1:
Enter the route in manifest.yml before deploy.
Alternative 2:
Use the cockpit to create a new route:
and map it to our app by clicking on “map route”:
What was that all about?
Our app can now officially be reached via 2 URLs.
So much effort, we have to do this for each and every subscription, etc??
Yes, true, but we’re here in a “MT for dummies” tutorial.
And we're in "dev"-mode and "test"-mode.
There are other options for enterprise application development ("custom domains")
Use the Application
After creating and mapping the route, we - now as customer - can finally enjoy our subscription:
Access the tenant-specific URL
https://customer1-mutete.cfapps.sap.hana.ondemand.com/app
And see the homepage of our app:
Action
Our second endpoint...... no no, the "/action" endpoint is not meant to be visible for customers in use case 1..... so before we look at it, we need to shift our mindset again...
OK, we change our mindset: we take the role of a developer or admin/operator.
Now our second endpoint, which is meant for Job Scheduler, can be tested manually:
https://mutete.cfapps.sap.hana.ondemand.com/action
Humm??
This endpoint is not customer1-specific.
It could have been designed as customer1-specific, but we haven't done so because in use case 1 we want the action to do stuff that hasn't anything to do with subscribers.
Our example was:
The app displays a list of products and the list should be updated regularly.
There's nothing specific to customers in this list.
Last step
To make our scenario complete, we create a Job.
In this first tutorial, we want to stick to the use case 1, as defined in the intro blog.
Means: we create a job that has nothing to do with subscribers.
The job is meant to call an action endpoint on the MT provider application itself.
As such, we can manually create the job (Could be done via REST API as well).
We open the dashboard and create a job with the following attributes:
name | JobForUseCase1 |
Action endpoint | https://mutete.cfapps.sap.hana.ondemand.com/action |
Note:
Make sure to adapt the URL to your own one.
Note that the action endpoint belongs to the provider app, not subscriber.
This is what we want to show: in this scenario, Job Scheduler calls an endpoint that is exposed by the multitenant provider app, with no tenant-specific URL and no tenant-specific implementation.
According to use case 1.
Afterwards we check the run logs to make sure that the job run has been successful and we can see the response of our action endpoint.
Very last step: unsubscribe and clean up
Note:
We need to make sure that we always unsubscribe, otherwise we can get a kind of dead-end-state:
If we delete the MT application without unsubscribing, then afterwards it won’t be possible to unsubscribe, because the SaaS registry doesn’t find the delete-callback, hence fails.
This is not nice, so let's unsubscribe now.
Note:
In case my warning has reached you too late, you can fix the problem by re-deploying the app (hopefully the code is not deleted yet…).
After unsubscribe, we delete the app and the services (always good to cleanup the cloud after testing)
cf d mutete -r -f
cf ds muteteSaasreg -f
cf dsk muteteXsuaa sk -f
cf ds muteteXsuaa -f
This blog post has been a first very simple tutorial to make ourselves familiar with Job Scheduler and multitenant app.
To get started, the only jobscheduler-relevant steps were:
- Implement the getDependencies callback of SaaS registry, and provide the dependency to Job Scheduler service instance (although not really needed today).
- Provide an action endpoint and decide if it is consumer-agnostic
While designing the action endpoint in this blog, we stick to the use case 1, which we defined in the intro blog:
In this case, the action endpoint is consumer-agnostic (as such, Job Scheduler is not tenant-aware, no big difference to single tenant scenario)
Next tutorial will be designed according to use case 2.
We want to make our action-endpoint tenant-specific.
Also, we want to use the Job Scheduler REST API to generate jobs.
Such generated job will be tenant-specific, which makes the Job Scheduler tenant-aware.
Introduction to Job Scheduler in different multitenancy scenarios
Enable OAuth for Job Scheduler: see here.
Multitenancy subscriber URLs explained in app router docu.
Docu about custom domains.
Cloud Foundry docu about custom domains.
Docu about xsuaa configuration in xs-security.json file.
See overview blog post for more links.
cf cs xsuaa application muteteXsuaa -c xs-security.json
cf csk muteteXsuaa sk
cf service-key muteteXsuaa sk
cf cs saas-registry application muteteSaasreg -c config-saasreg.json
cf push
cf map-route mutete cfapps.sap.hana.ondemand.com --hostname -mutete
cf d mutete -f -r
cf ds muteteSaasreg -f
cf dsk muteteXsuaa sk -f
cf ds muteteXsuaa -f
Not sure where to find the sub domain and tenant ID in your account?
Just go to the overview page of your sub account:
Note:
Everything can be copy&pasted, only the following values need to be adapted:
property "appId" and app URLs in config-saasreg.json
property "name" and "routes" in manifest.yaml
xs-security.json
{
"xsappname": "mutetexsappname",
"tenant-mode": "shared"
}
config-saasreg.json
{
"appId": "mutetexsappname!t17916",
"appName": "muteteAppNameForSaasReg",
"appUrls": {
"getDependencies" : "https://mutete.cfapps.sap.hana.ondemand.com/handleDependencies",
"onSubscription" : "https://mutete.cfapps.sap.hana.ondemand.com/handleSubscription/{tenantId}"
},
"displayName": "MuTeTe with Job Scheduler"
}
manifest.yml
---
applications:
- name: mutete
routes:
- route: mutete.cfapps.sap.hana.ondemand.com
memory: 128M
services:
- muteteXsuaa
- muteteSaasreg
- myJobschedulerInstance
package.json
{
"dependencies": {
"express": "^4.16.2"
}
}
server.js
const express = require('express')
const app = express()
app.use(express.json())
const VCAP_SERVICES = JSON.parse(process.env.VCAP_SERVICES);
const JOB_CREDENTIALS = VCAP_SERVICES.jobscheduler[0].credentials;
/* Application Server */
app.listen(process.env.PORT, function () {
console.log('===> Server started')
});
/* Application endpoints */
app.get('/app', (req, res) => {
res.send(`<h1>Homepage of multitenant app</h1>`)
});
app.get('/action', (req, res) => {
res.send(`ACTION endpoint for jobscheduler successfully invoked.`)
});
/* Multi Tenancy callbacks */
app.get('/handleDependencies', (req, res) => {
const dependencies = [{'xsappname': JOB_CREDENTIALS.uaa.xsappname }]
res.status(200).json(dependencies);
});
app.put('/handleSubscription/:myConsumer', (req, res) => {
const subDomain = req.body.subscribedSubdomain // e.g. customer1subdomain
const appHost = req.hostname //mutete.cfapps.sap.hana.ondemand.com
const subscriberAppURL = `https://${subDomain}-${appHost}/app`
res.status(200).send(subscriberAppURL)
});
app.delete('/handleSubscription/:myConsumer', (req, res) => {
res.status(200).end('unsubscribed')
});
You must be a registered user to add a comment. If you've already registered, sign in. Otherwise, register and sign in.
User | Count |
---|---|
22 | |
13 | |
10 | |
10 | |
8 | |
7 | |
6 | |
6 | |
5 | |
5 |