Skip to content

Commit ae22a53

Browse files
authored
Merge pull request #37 from rdytech/NEP-18548-import-dashboard-across-envs
NEP-18548 Transfer Dashboard across Envs
2 parents bc524a5 + d6ffda5 commit ae22a53

16 files changed

+683
-27
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
## Change Log
22

3+
## 0.2.2 - 2024-10-10
4+
5+
* add ImportDashboardAcrossEnvironments class for transfering between superset environments
6+
37
## 0.2.1 - 2024-09-17
48

59
* add Superset::Database::Export class for exporting database configurations

Gemfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ GIT
1414
PATH
1515
remote: .
1616
specs:
17-
superset (0.2.1)
17+
superset (0.2.2)
1818
dotenv (~> 2.7)
1919
enumerate_it (~> 1.7.0)
2020
faraday (~> 1.0)

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,17 @@ More examples [listed here](https://github.com/rdytech/superset-client/tree/deve
6666

6767
## Duplicating Dashboards
6868

69-
The Primary motivation behind this gem was to use the Superset API to duplicate dashboards, charts, datasets across multiple database connections.
69+
One Primary motivation behind this gem was to use the Superset API to duplicate dashboards, charts, datasets across multiple database connections.
7070

7171
Targeted use case was for superset embedded functionality implemented in a application resting on multi tenanted database setup.
7272

7373
See examples in [Duplicate Dashboards](https://github.com/rdytech/superset-client/tree/develop/doc/duplicate_dashboards.md)
7474

75+
## Moving / Transferring Dashboards across Environments
76+
77+
With a few configuration changes to an import file, the process can be codified to transfer a dashboard between environments.
78+
79+
See example in [Transferring Dashboards across Environments](https://github.com/rdytech/superset-client/tree/develop/doc/migrating_dashboards_across_environments.md)
7580

7681
## Contributing
7782

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
# Transferring Dashboards across Environments
2+
3+
In this document, we will discuss how to transfer dashboards across Superset hosting environments with the goal of heading towards an API call to automate the process.
4+
5+
Current process is limited to dashboards with all datasets based on a single database connection.
6+
7+
## Short Version
8+
9+
Assuming you want to transfer a dashboard from Env1 to Env2.
10+
11+
You will need the following:
12+
- a Env1 Dashboard Export Zip file
13+
- a Env2 Database config export yaml
14+
- a Env2 schema to point your datasets to
15+
16+
Assuming your API env for ruby is setup for your target superset environment.
17+
( ie using API creds for Env2 for this example )
18+
19+
```ruby
20+
21+
new_import_zip = Superset::Services::ImportDashboardAcrossEnvironments.new(
22+
dashboard_export_zip: 'path_to/dashboard_101_export_20241010.zip',
23+
target_database_yaml_file: 'path_to/env2_db_config.yaml',
24+
target_database_schema: 'acme',
25+
).perform
26+
27+
# now import the adjusted zip to the target superset env
28+
Superset::Dashboard::Import.new(source_zip_file: new_import_file).perform
29+
30+
```
31+
32+
## Background
33+
34+
A common practice is to set up infrastructure to deploy multiple Superset environments. For example, a simple setup might be:
35+
- Local development environment for testing version upgrades and feature exploration
36+
- Staging Superset environment for testing in a production-like environment
37+
- Production Superset environment that requires a higher level of stability and uptime
38+
39+
For the above example, the Superset staging environment often holds connections to staging databases, and the Superset production environment will hold connections to the production databases.
40+
41+
In the event where the database schema structure for the local development, staging, and production databases are exactly the same, dashboards can be replicated and transferred across Superset hosting environments.
42+
43+
That process does require some manual updating of the exported YAML files before importing them into the target environment. Also required is some understanding of the underlying dashboard export structure and how the object UUIDs work and relate to each other, especially in the context of databases and datasets.
44+
45+
## Dashboard Export/Import within the Same Environment
46+
47+
This is a fairly straightforward process.
48+
49+
There are multiple methods for exporting a dashboard:
50+
- Export from the dashboard list page in the GUI
51+
- Export via the Superset API
52+
- Export via the Superset CLI
53+
54+
Each export method will result in a zip file that contains a set of YAML files as per this list below, which is an export of customized version of the test Sales dashboard from the default example dashboards.
55+
56+
Test fixture is: https://github.com/rdytech/superset-client/blob/develop/spec/fixtures/dashboard_18_export_20240322.zip
57+
58+
```
59+
└── dashboard_export_20240321T214117
60+
├── charts
61+
│   ├── Boy_Name_Cloud_53920.yaml
62+
│   ├── Names_Sorted_by_Num_in_California_53929.yaml
63+
│   ├── Number_of_Girls_53930.yaml
64+
│   ├── Pivot_Table_53931.yaml
65+
│   └── Top_10_Girl_Name_Share_53921.yaml
66+
├── dashboards
67+
│   └── Birth_Names_18.yaml
68+
├── databases
69+
│   └── examples.yaml
70+
├── datasets
71+
│   └── examples
72+
│   └── birth_names.yaml
73+
└── metadata.yaml
74+
```
75+
76+
Each of the above YAML files holds UUID values for the primary object and any related objects.
77+
78+
- Database YAMLs hold the database connection string as well as a UUID for the database
79+
- Dataset YAMLs have their own UUID as well as a reference to the database UUID
80+
- Chart YAMLs have their own UUID as well as a reference to their dataset UUID
81+
82+
Example of the database YAML file:
83+
84+
```
85+
cat databases/examples.yaml
86+
database_name: examples
87+
sqlalchemy_uri: postgresql+psycopg2://superset:XXXXXXXXXX@superset-host:5432/superset
88+
cache_timeout: null
89+
expose_in_sqllab: true
90+
allow_run_async: true
91+
allow_ctas: true
92+
allow_cvas: true
93+
allow_dml: true
94+
allow_file_upload: true
95+
extra:
96+
metadata_params: {}
97+
engine_params: {}
98+
metadata_cache_timeout: {}
99+
schemas_allowed_for_file_upload:
100+
- examples
101+
allows_virtual_table_explore: true
102+
uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
103+
version: 1.0.0
104+
```
105+
106+
If we grep the database/examples.yaml we can see the UUID of the database.
107+
108+
```
109+
grep -r uuid databases/
110+
databases/examples.yaml:uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
111+
112+
```
113+
114+
Now if we look at the UUID values in the datasets, you will see both the dataset UUID and the reference to the database UUID.
115+
116+
```
117+
grep -r uuid datasets
118+
datasets/examples/birth_names.yaml:uuid: 283f5023-0814-40f6-b12d-96f6a86b984f
119+
datasets/examples/birth_names.yaml:database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
120+
```
121+
122+
If the above dashboard zip file `dashboard_18_export_20240322.zip` was imported as is to the same superset environment as it was exported from, this would mean all UUID's would already exist in superset and these objects would be found and updated with the imported zip data.
123+
124+
If the above zip file was imported as is to a different target Superset environment, it would fail as there would be no matching database UUID entry in that target Superset environment.
125+
126+
**Key Point:** When importing a dashboard to a different Superset environment than the original environment, the database configuration in the zip export must exist in the target Superset environment and all datasets must point to the database config.
127+
128+
## Migrate a Dashboard to a Different Superset Environment
129+
130+
With the above knowledge, we can now think about how to migrate dashboards between Superset environments.
131+
132+
Each Superset object is given a UUID. Within the exported dashboard files, we are primarily concerned with:
133+
- Replacing the staging database configuration with the production configuration
134+
- Updating all staging datasets to point to the new production database UUID
135+
136+
Given we have a request to 'transfer' a dashboard across to a different environment, say staging to production, how would we then proceed?
137+
138+
With the condition that the database in staging and production are structurally exactly the same schema, from the above discussion on UUIDs, you can then see that if we want to import a staging dashboard export into the production environment, we will need to perform the following steps:
139+
140+
1. Export the staging dashboard and unzip
141+
2. Note the staging database UUIDs in the `databases/` directory
142+
3. Get a copy of the production database YAML configuration file
143+
4. In the exported dashboard files, replace the staging database YAML with the production YAML
144+
5. In the dataset YAML files, replace all instances of the previously noted staging database UUID with the new production UUID
145+
6. Zip the files and import them to the production environment
146+
147+
The process above assumes that whoever is migrating the dashboard has a copy of the target database YAML files so that in steps 3 and 4 we can then replace the staging database YAML with the production one.
148+
149+
## Requirements
150+
151+
The overall process requires the following:
152+
- The source dashboard zip file
153+
- The target Superset environment database YAML file
154+
- Ability to copy and manipulate the source dashboard zip file
155+
- The ability to import via API to the target Superset environment
156+
157+
158+
## Gotchas!
159+
160+
Migrating a dashboard once to a new target environment, database, schema will result in:
161+
- Creating a new dashboard with the UUID from the import zip
162+
- Creating a new set of charts with their UUIDs from the import zip
163+
- Creating a new set of datasets with their UUIDs from the import zip
164+
165+
Migrating the same dashboard a second time to the same target environment, database, but different schema will NOT create a new dashboard.
166+
167+
It will attempt to update the same dashboard as the UUID for the dashboard has not changed. It will also NOT change any of the datasets to the new schema. This appears to be a limitation of the import process, which may lead to some confusing results.
168+
169+
## References
170+
171+
Some helpful references relating to cross-environment workflows:
172+
- [Managing Content Across Workspaces](https://docs.preset.io/docs/managing-content-across-workspaces)
173+
- [Superset Slack AI Explanation](https://apache-superset.slack.com/archives/C072KSLBTC1/p1722382347022689)

lib/superset/dashboard/import.rb

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
module Superset
2121
module Dashboard
2222
class Import < Request
23-
2423
attr_reader :source_zip_file, :overwrite
2524

2625
def initialize(source_zip_file: , overwrite: true)
@@ -30,7 +29,6 @@ def initialize(source_zip_file: , overwrite: true)
3029

3130
def perform
3231
validate_params
33-
3432
response
3533
end
3634

@@ -46,7 +44,9 @@ def response
4644
def validate_params
4745
raise ArgumentError, 'source_zip_file is required' if source_zip_file.nil?
4846
raise ArgumentError, 'source_zip_file does not exist' unless File.exist?(source_zip_file)
47+
raise ArgumentError, 'source_zip_file is not a zip file' unless File.extname(source_zip_file) == '.zip'
4948
raise ArgumentError, 'overwrite must be a boolean' unless [true, false].include?(overwrite)
49+
raise ArgumentError, "zip target database does not exist: #{zip_database_config_not_found_in_superset}" if zip_database_config_not_found_in_superset.present?
5050
end
5151

5252
def payload
@@ -59,6 +59,26 @@ def payload
5959
def route
6060
"dashboard/import/"
6161
end
62+
63+
def zip_database_config_not_found_in_superset
64+
zip_databases_details.select {|s| !superset_database_uuids_found.include?(s[:uuid]) }
65+
end
66+
67+
def superset_database_uuids_found
68+
@superset_database_uuids_found ||= begin
69+
zip_databases_details.map {|i| i[:uuid]}.map do |uuid|
70+
uuid if Superset::Database::List.new(uuid_equals: uuid).result.present?
71+
end.compact
72+
end
73+
end
74+
75+
def zip_databases_details
76+
zip_dashboard_config[:databases].map{|d| {uuid: d[:content][:uuid], name: d[:content][:database_name]} }
77+
end
78+
79+
def zip_dashboard_config
80+
@zip_dashboard_config ||= Superset::Services::DashboardLoader.new(dashboard_export_zip: source_zip_file).perform
81+
end
6282
end
6383
end
6484
end

lib/superset/database/list.rb

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@
44
module Superset
55
module Database
66
class List < Superset::Request
7-
attr_reader :title_contains
7+
attr_reader :title_contains, :uuid_equals
88

9-
def initialize(page_num: 0, title_contains: '')
9+
def initialize(page_num: 0, title_contains: '', uuid_equals: '')
1010
@title_contains = title_contains
11+
@uuid_equals = uuid_equals
1112
super(page_num: page_num)
1213
end
1314

@@ -34,6 +35,7 @@ def filters
3435
# TODO filtering across all list classes can be refactored to support multiple options in a more flexible way
3536
filter_set = []
3637
filter_set << "(col:database_name,opr:ct,value:'#{title_contains}')" if title_contains.present?
38+
filter_set << "(col:uuid,opr:eq,value:'#{uuid_equals}')" if uuid_equals.present?
3739
unless filter_set.empty?
3840
"filters:!(" + filter_set.join(',') + "),"
3941
end
@@ -45,6 +47,7 @@ def list_attributes
4547

4648
def validate_constructor_args
4749
raise InvalidParameterError, "title_contains must be a String type" unless title_contains.is_a?(String)
50+
raise InvalidParameterError, "uuid_equals must be a String type" unless uuid_equals.is_a?(String)
4851
end
4952
end
5053
end

lib/superset/request.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ class Request
55
class InvalidParameterError < StandardError; end
66
class ValidationError < StandardError; end
77

8-
98
PAGE_SIZE = 100
109

1110
attr_accessor :page_num
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Given a path, load all yaml files
2+
3+
require 'superset/file_utilities'
4+
require 'yaml'
5+
6+
module Superset
7+
module Services
8+
class DashboardLoader
9+
include FileUtilities
10+
11+
TMP_PATH = '/tmp/superset_dashboard_imports'.freeze
12+
13+
attr_reader :dashboard_export_zip
14+
15+
def initialize(dashboard_export_zip:)
16+
@dashboard_export_zip = dashboard_export_zip
17+
end
18+
19+
def perform
20+
unzip_source_file
21+
dashboard_config
22+
end
23+
24+
def dashboard_config
25+
@dashboard_config ||= DashboardConfig.new(
26+
dashboard_export_zip: dashboard_export_zip,
27+
tmp_uniq_dashboard_path: tmp_uniq_dashboard_path).config
28+
end
29+
30+
private
31+
32+
def unzip_source_file
33+
@extracted_files = unzip_file(dashboard_export_zip, tmp_uniq_dashboard_path)
34+
end
35+
36+
def tmp_uniq_dashboard_path
37+
@tmp_uniq_dashboard_path ||= File.join(TMP_PATH, uuid)
38+
end
39+
40+
def uuid
41+
SecureRandom.uuid
42+
end
43+
44+
class DashboardConfig < ::OpenStruct
45+
def config
46+
{
47+
tmp_uniq_dashboard_path: tmp_uniq_dashboard_path,
48+
dashboards: load_yamls_for('dashboards'),
49+
databases: load_yamls_for('databases'),
50+
datasets: load_yamls_for('datasets'),
51+
charts: load_yamls_for('charts'),
52+
metadata: load_yamls_for('metadata.yaml', pattern_sufix: nil),
53+
}
54+
end
55+
56+
def load_yamls_for(object_path, pattern_sufix: '**/*.yaml')
57+
pattern = File.join([tmp_uniq_dashboard_path, '**', object_path, pattern_sufix].compact)
58+
Dir.glob(pattern).map do |file|
59+
{ filename: file, content: load_yaml_and_symbolize_keys(file) } if File.file?(file)
60+
end.compact
61+
end
62+
63+
def load_yaml_and_symbolize_keys(path)
64+
YAML.load_file(path).deep_symbolize_keys
65+
end
66+
end
67+
end
68+
end
69+
end

0 commit comments

Comments
 (0)