rda-python-miscs 3.0.4__tar.gz → 3.0.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. {rda_python_miscs-3.0.4/src/rda_python_miscs.egg-info → rda_python_miscs-3.0.5}/PKG-INFO +32 -3
  2. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/README.md +31 -2
  3. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/pyproject.toml +3 -1
  4. rda_python_miscs-3.0.5/src/rda_python_miscs/decsdata_restore.py +456 -0
  5. rda_python_miscs-3.0.5/src/rda_python_miscs/decsdata_restore.usg +101 -0
  6. rda_python_miscs-3.0.5/src/rda_python_miscs/decsdata_storage.py +196 -0
  7. rda_python_miscs-3.0.5/src/rda_python_miscs/decsdata_storage.usg +66 -0
  8. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5/src/rda_python_miscs.egg-info}/PKG-INFO +32 -3
  9. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs.egg-info/SOURCES.txt +4 -0
  10. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs.egg-info/entry_points.txt +2 -0
  11. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/tests/test_miscs.py +2 -0
  12. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/LICENSE +0 -0
  13. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/MANIFEST.in +0 -0
  14. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/setup.cfg +0 -0
  15. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/__init__.py +0 -0
  16. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/bash_qsub.py +0 -0
  17. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/bashqsub.py +0 -0
  18. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/bashqsub.usg +0 -0
  19. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/gdex_ls.py +0 -0
  20. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/gdexcp.py +0 -0
  21. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/gdexcp.usg +0 -0
  22. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/gdexkill.py +0 -0
  23. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/gdexkill.usg +0 -0
  24. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/gdexls.py +0 -0
  25. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/gdexls.usg +0 -0
  26. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/gdexls_standalone.py +0 -0
  27. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/gdexmod.py +0 -0
  28. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/gdexmod.usg +0 -0
  29. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/gdexown.py +0 -0
  30. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/gdexown.usg +0 -0
  31. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/gdexps.py +0 -0
  32. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/gdexps.usg +0 -0
  33. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/gdexsub.py +0 -0
  34. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/gdexsub.usg +0 -0
  35. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/gdexzip.py +0 -0
  36. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/gdexzip.usg +0 -0
  37. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/pg_docs.py +0 -0
  38. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/pg_rst.py +0 -0
  39. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/pg_rst.usg +0 -0
  40. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/pg_wget.py +0 -0
  41. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/pgwget.py +0 -0
  42. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/pgwget.usg +0 -0
  43. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/rda_cp.py +0 -0
  44. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/rda_kill.py +0 -0
  45. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/rda_mod.py +0 -0
  46. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/rda_own.py +0 -0
  47. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/rda_ps.py +0 -0
  48. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/rda_sub.py +0 -0
  49. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/rda_zip.py +0 -0
  50. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/rdals.py +0 -0
  51. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/rdals.usg +0 -0
  52. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/rst_templates/appendix.rst.temp +0 -0
  53. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/rst_templates/index.rst.temp +0 -0
  54. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/rst_templates/section.rst.temp +0 -0
  55. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/tcsh_qsub.py +0 -0
  56. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/tcshqsub.py +0 -0
  57. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs/tcshqsub.usg +0 -0
  58. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs.egg-info/dependency_links.txt +0 -0
  59. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs.egg-info/requires.txt +0 -0
  60. {rda_python_miscs-3.0.4 → rda_python_miscs-3.0.5}/src/rda_python_miscs.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rda_python_miscs
3
- Version: 3.0.4
3
+ Version: 3.0.5
4
4
  Summary: RDA Python package to hold RDA miscellaneous utility programs
5
5
  Author-email: Zaihua Ji <zji@ucar.edu>
6
6
  Project-URL: Homepage, https://github.com/NCAR/rda-python-miscs
@@ -45,6 +45,8 @@ The package provides two categories of programs:
45
45
  | `gdexcp` | `rdacp` | `setuid_gdexcp` / `setuid_rdacp` | Copy files and directories across local, remote, Object Store, or Globus endpoints |
46
46
  | `gdexkill` | `rdakill` | `setuid_gdexkill` / `setuid_rdakill` | Kill local processes and their children, or cancel PBS batch jobs |
47
47
  | `gdexmod` | `rdamod` | `setuid_gdexmod` / `setuid_rdamod` | Change permission modes for files and directories owned by gdexdata |
48
+ | `decsdata_storage` | | `setuid_decsdata_storage` | Move decsdata datasets into the GLADE HSM cold storage to migrate them onto tape |
49
+ | `decsdata_restore` | | `setuid_decsdata_restore` | Recall decsdata datasets out of the GLADE HSM cold storage and copy them back |
48
50
 
49
51
  ## Environment setup
50
52
 
@@ -103,8 +105,8 @@ pip install rda_python_miscs
103
105
 
104
106
  ## Setuid Setup
105
107
 
106
- The setuid programs (`gdexcp`, `gdexkill`, `gdexmod` and their `rda*` aliases)
107
- execute as the common user `PGLOG['COMMONUSER']` (default `gdexdata`) via
108
+ The setuid programs (`gdexcp`, `gdexkill`, `gdexmod`, `decsdata_storage`,
109
+ `decsdata_restore` and the `rda*` aliases) execute as the common user `PGLOG['COMMONUSER']` (default `gdexdata`) via
108
110
  the `rda_python_setuid` mechanism, which is pulled in automatically as a
109
111
  dependency. After `pip install` above, choose one of the wiring options
110
112
  below.
@@ -159,3 +161,30 @@ pywrapper-install -u|--update
159
161
  The shared setuid setup guide is shown automatically if any `setuid_*`
160
162
  connector script is invoked directly before the setuid wrapper has been
161
163
  configured.
164
+
165
+ ## Cold storage setup
166
+
167
+ `decsdata_storage` and `decsdata_restore` drive the GLADE HSM through the
168
+ `glade_hsm` script, which is not part of this package. They look it up at
169
+ `~benkirk/glade_hsm` unless the environment variable `GLADE_HSM` names another
170
+ copy of it:
171
+
172
+ ```bash
173
+ export GLADE_HSM=/path/to/glade_hsm
174
+ ```
175
+
176
+ Both programs work on the decsdata directory `PGLOG['DECSHOME']` (default
177
+ `/glade/campaign/collections/gdex/decsdata`, also reachable as
178
+ `/gdex/decsdata`), which option `-w` overrides. Only members of the DECS group
179
+ may run them, and `decsdata_restore` reads Table `sfile` in RDADB to size a
180
+ restore, so a working RDADB login is required as well.
181
+
182
+ Because the decsdata quota is limited, check the space left with `gladequota`
183
+ before a large restore:
184
+
185
+ ```bash
186
+ pgstart_gdexdata gladequota # or plain 'gladequota' when logged in as gdexdata
187
+ ```
188
+
189
+ `decsdata_restore -x` runs the same check itself and refuses to request a
190
+ recall unless twice the size of the data is still available.
@@ -28,6 +28,8 @@ The package provides two categories of programs:
28
28
  | `gdexcp` | `rdacp` | `setuid_gdexcp` / `setuid_rdacp` | Copy files and directories across local, remote, Object Store, or Globus endpoints |
29
29
  | `gdexkill` | `rdakill` | `setuid_gdexkill` / `setuid_rdakill` | Kill local processes and their children, or cancel PBS batch jobs |
30
30
  | `gdexmod` | `rdamod` | `setuid_gdexmod` / `setuid_rdamod` | Change permission modes for files and directories owned by gdexdata |
31
+ | `decsdata_storage` | | `setuid_decsdata_storage` | Move decsdata datasets into the GLADE HSM cold storage to migrate them onto tape |
32
+ | `decsdata_restore` | | `setuid_decsdata_restore` | Recall decsdata datasets out of the GLADE HSM cold storage and copy them back |
31
33
 
32
34
  ## Environment setup
33
35
 
@@ -86,8 +88,8 @@ pip install rda_python_miscs
86
88
 
87
89
  ## Setuid Setup
88
90
 
89
- The setuid programs (`gdexcp`, `gdexkill`, `gdexmod` and their `rda*` aliases)
90
- execute as the common user `PGLOG['COMMONUSER']` (default `gdexdata`) via
91
+ The setuid programs (`gdexcp`, `gdexkill`, `gdexmod`, `decsdata_storage`,
92
+ `decsdata_restore` and the `rda*` aliases) execute as the common user `PGLOG['COMMONUSER']` (default `gdexdata`) via
91
93
  the `rda_python_setuid` mechanism, which is pulled in automatically as a
92
94
  dependency. After `pip install` above, choose one of the wiring options
93
95
  below.
@@ -142,3 +144,30 @@ pywrapper-install -u|--update
142
144
  The shared setuid setup guide is shown automatically if any `setuid_*`
143
145
  connector script is invoked directly before the setuid wrapper has been
144
146
  configured.
147
+
148
+ ## Cold storage setup
149
+
150
+ `decsdata_storage` and `decsdata_restore` drive the GLADE HSM through the
151
+ `glade_hsm` script, which is not part of this package. They look it up at
152
+ `~benkirk/glade_hsm` unless the environment variable `GLADE_HSM` names another
153
+ copy of it:
154
+
155
+ ```bash
156
+ export GLADE_HSM=/path/to/glade_hsm
157
+ ```
158
+
159
+ Both programs work on the decsdata directory `PGLOG['DECSHOME']` (default
160
+ `/glade/campaign/collections/gdex/decsdata`, also reachable as
161
+ `/gdex/decsdata`), which option `-w` overrides. Only members of the DECS group
162
+ may run them, and `decsdata_restore` reads Table `sfile` in RDADB to size a
163
+ restore, so a working RDADB login is required as well.
164
+
165
+ Because the decsdata quota is limited, check the space left with `gladequota`
166
+ before a large restore:
167
+
168
+ ```bash
169
+ pgstart_gdexdata gladequota # or plain 'gladequota' when logged in as gdexdata
170
+ ```
171
+
172
+ `decsdata_restore -x` runs the same check itself and refuses to request a
173
+ recall unless twice the size of the data is still available.
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
6
6
 
7
7
  [project]
8
8
  name = "rda_python_miscs"
9
- version = "3.0.4"
9
+ version = "3.0.5"
10
10
  authors = [
11
11
  { name="Zaihua Ji", email="zji@ucar.edu" },
12
12
  ]
@@ -52,3 +52,5 @@ pythonpath = [
52
52
  "setuid_gdexmod" = "rda_python_miscs.gdexmod:main"
53
53
  "setuid_rdamod" = "rda_python_miscs.gdexmod:main"
54
54
  "pgrst" = "rda_python_miscs.pg_rst:main"
55
+ "setuid_decsdata_storage" = "rda_python_miscs.decsdata_storage:main"
56
+ "setuid_decsdata_restore" = "rda_python_miscs.decsdata_restore:main"
@@ -0,0 +1,456 @@
1
+ #!/usr/bin/env python3
2
+ ##################################################################################
3
+ # Title: decsdata_restore
4
+ # Author: Zaihua Ji, zji@ucar.edu
5
+ # Date: 2026-08-31
6
+ # Purpose: restore decsdata datasets, or parts of them, out of the GLADE HSM
7
+ # cold storage; the opposite of decsdata_storage
8
+ # Github: https://github.com/NCAR/rda-python-miscs.git
9
+ ##################################################################################
10
+ import re
11
+ import os
12
+ import sys
13
+ from os import path as op
14
+ from rda_python_common.pg_file import PgFile
15
+
16
+ class DecsRestore(PgFile):
17
+ """Restore decsdata datasets out of the GLADE HSM cold storage.
18
+
19
+ 'glade_hsm recall' only submits a request that the HSM batch processes
20
+ fulfill later on, so restoring is done in three steps:
21
+
22
+ -x submit the recall requests for the given datasets/paths
23
+ -s check the recall status, repeat until nothing is left on tape
24
+ -r copy the recalled data back under the decsdata directory
25
+
26
+ Recalled files stay readable inside cold storage for 7 days only, after
27
+ which they are migrated onto tape again, so step -r must be done within
28
+ that window.
29
+ """
30
+
31
+ def __init__(self):
32
+ """Initialize DecsRestore with default option values and runtime state."""
33
+ super().__init__()
34
+ self.HSM = os.environ.get('GLADE_HSM', op.expanduser('~benkirk/glade_hsm'))
35
+ self.VALOPTS = 'Dwl' # single-value options
36
+ self.MULOPTS = 'd' # multi-value options
37
+ self.MODOPTS = 'hf' # mode options
38
+ self.ACTOPTS = 'xsr' # action options, one and only one is required
39
+ self.OPTS = {
40
+ 'D': None, # cold storage date, in YYYYMMDD or YYYY-MM-DD
41
+ 'w': None, # decsdata directory, defaults to PGLOG['DECSHOME']
42
+ 'l': None, # dataset list file
43
+ 'd': [], # dataset IDs, a sub-path may be appended to each one
44
+ 't': None, # target directory of -r, defaults to the decsdata directory
45
+ 'h': 0, # 1 to show help message
46
+ 'f': 0, # 1 to copy back while files are still on tape
47
+ }
48
+ self.ACTION = None # one of the ACTOPTS letters
49
+ self.SIZEUNITS = { # units reported by 'gladequota', in bytes
50
+ 'B': 1, 'KIB': 1024, 'MIB': 1024**2,
51
+ 'GIB': 1024**3, 'TIB': 1024**4, 'PIB': 1024**5,
52
+ }
53
+ self.RINFO = {
54
+ 'decsdir': None, # decsdata directory the data is restored into
55
+ 'roots': [], # cold storage directories to look the data up in
56
+ 'acnt': 0, # number of dataset paths acted on successfully
57
+ }
58
+
59
+ # function to read parameters
60
+ def read_parameters(self):
61
+ """Parse the command line into the OPTS option values and the action.
62
+
63
+ Single-value options -D, -w and -l take one value each, multi-value
64
+ option -d gathers every following dataset path, mode options -h and -f
65
+ are simple flags, and action options -x, -s and -r are mutually
66
+ exclusive; -r optionally takes a target directory. Exits with usage if
67
+ -h is given or no action is specified.
68
+ """
69
+ self.set_suid(self.PGLOG['EUID'])
70
+ self.set_help_path(__file__)
71
+ self.PGLOG['LOGFILE'] = "decsdata_restore.log" # set different log file
72
+ argv = sys.argv[1:]
73
+ self.cmdlog("decsdata_restore {}".format(' '.join(argv)))
74
+ option = None
75
+ for arg in argv:
76
+ ms = re.match(r'^-(\w+)$', arg)
77
+ if ms:
78
+ option = ms.group(1)
79
+ if option in self.ACTOPTS:
80
+ self.set_action(option)
81
+ if option != 'r': option = None # -r may be followed by a target directory
82
+ elif option in self.MODOPTS:
83
+ self.OPTS[option] = 1
84
+ option = None
85
+ elif option not in self.VALOPTS and option not in self.MULOPTS:
86
+ self.pglog(arg + ": Unknown Option", self.LGEREX)
87
+ continue
88
+ if not option: self.pglog(arg + ": Value provided without option", self.LGEREX)
89
+ if option in self.MULOPTS:
90
+ self.OPTS[option].append(arg) # gather all values until the next option
91
+ else:
92
+ if option == 'r': option = 't' # the value of -r is the target directory
93
+ self.OPTS[option] = arg
94
+ option = None
95
+ if self.OPTS['h'] or not self.ACTION: self.show_usage("decsdata_restore")
96
+
97
+ # remember the action option and reject a second one
98
+ def set_action(self, option):
99
+ """Record the single action option to perform.
100
+
101
+ Args:
102
+ option (str): One of the ACTOPTS letters.
103
+ """
104
+ if self.ACTION and self.ACTION != option:
105
+ self.pglog("-{}: Cannot combine with Action -{}".format(option, self.ACTION), self.LGEREX)
106
+ self.ACTION = option
107
+
108
+ # function to start actions
109
+ def start_actions(self):
110
+ """Validate the caller, resolve the cold storage paths, and act on each dataset path."""
111
+ self.dssdb_dbname()
112
+ self.validate_decs_group('decsdata_restore', self.PGLOG['CURUID'], 1)
113
+ self.set_restore_paths()
114
+ specs = self.get_dataset_list()
115
+ if not specs: self.pglog("No dataset found to restore", self.LGWNEX)
116
+ if self.ACTION == 'x': self.check_restore_space(specs)
117
+ for spec in specs:
118
+ self.restore_one_path(spec)
119
+ acts = {'x': 'requested Recall', 's': 'checked Status', 'r': 'copied back'}
120
+ s = ('s' if self.RINFO['acnt'] > 1 else '')
121
+ self.pglog("{} of {} Dataset Path{} {}".format(self.RINFO['acnt'], len(specs),
122
+ s, acts[self.ACTION]), self.LOGWRN)
123
+ self.cmdlog()
124
+
125
+ # resolve the decsdata directory and the cold storage directories
126
+ def set_restore_paths(self):
127
+ """Fill RINFO with the decsdata directory and the cold storage directories to search.
128
+
129
+ For a given -D date only '<decsdata>/cold_storage_<date>/COLD_STORAGE' is
130
+ searched. Without -D both '<decsdata>/COLD_STORAGE' and every
131
+ '<decsdata>/cold_storage_<YYYYMMDD>/COLD_STORAGE' are searched, the most
132
+ recent dated one first.
133
+ """
134
+ decsdir = self.OPTS['w'] if self.OPTS['w'] else self.PGLOG['DECSHOME']
135
+ if not self.check_local_file(decsdir, 0, self.LOGWRN):
136
+ self.pglog(decsdir + ": decsdata directory NOT exists", self.LGEREX)
137
+ self.RINFO['decsdir'] = decsdir
138
+ if not self.OPTS['t']: self.OPTS['t'] = decsdir
139
+ roots = []
140
+ if self.OPTS['D']:
141
+ date = re.sub('-', '', self.OPTS['D'])
142
+ if not re.match(r'^\d{8}$', date):
143
+ self.pglog(date + ": Invalid cold storage date, YYYYMMDD expected", self.LGEREX)
144
+ roots.append(self.join_paths(decsdir, "cold_storage_{}/COLD_STORAGE".format(date)))
145
+ else:
146
+ roots.append(self.join_paths(decsdir, "COLD_STORAGE"))
147
+ files = self.local_glob(self.join_paths(decsdir, "cold_storage_[0-9]*/COLD_STORAGE"), 0, self.LOGWRN)
148
+ for file in sorted(files, reverse = True):
149
+ if not files[file]['isfile']: roots.append(file)
150
+ for root in roots:
151
+ if self.check_local_file(root, 0, 0): self.RINFO['roots'].append(root)
152
+ if not self.RINFO['roots']:
153
+ self.pglog("{}: No cold storage directory found in {}".format(', '.join(roots), decsdir), self.LGEREX)
154
+ self.pglog("Cold storage searched: {}".format(', '.join(self.RINFO['roots'])), self.LOGWRN)
155
+
156
+ # gather the dataset paths to restore
157
+ def get_dataset_list(self):
158
+ """Return the list of dataset paths to restore.
159
+
160
+ Uses the -d values if given. Otherwise reads the -l list file, creating
161
+ it first from every dNNNNNN directory in the cold storage directories if
162
+ it does not exist yet.
163
+
164
+ Returns:
165
+ list[str]: Dataset IDs, each optionally followed by a sub-path.
166
+ """
167
+ if self.OPTS['d']:
168
+ self.pglog("Restore {} given Dataset Path(s)".format(len(self.OPTS['d'])), self.LOGWRN)
169
+ return self.OPTS['d']
170
+ lstfile = self.OPTS['l'] if self.OPTS['l'] else "dsids_{}.lst".format(re.sub('-', '', self.curdate()))
171
+ if not op.isfile(lstfile):
172
+ dsids = self.get_coldstorage_datasets()
173
+ with open(lstfile, 'w') as OUT:
174
+ for dsid in dsids: OUT.write(dsid + "\n")
175
+ self.pglog("{}: Generated with {} Dataset(s)".format(lstfile, len(dsids)), self.LOGWRN)
176
+ specs = []
177
+ with open(lstfile, 'r') as IN:
178
+ for line in IN:
179
+ line = line.strip()
180
+ if line: specs.append(line)
181
+ self.pglog("{}: Read {} Dataset Path(s) to restore".format(lstfile, len(specs)), self.LOGWRN)
182
+ return specs
183
+
184
+ # find all dNNNNNN dataset directories in the cold storage directories
185
+ def get_coldstorage_datasets(self):
186
+ """Return the sorted dataset IDs of every dNNNNNN directory in the cold storage directories.
187
+
188
+ Returns:
189
+ list[str]: Unique dataset IDs; plain files matching the pattern are skipped.
190
+ """
191
+ dsids = []
192
+ for root in self.RINFO['roots']:
193
+ files = self.local_glob(self.join_paths(root, "d" + "[0-9]"*6), 0, self.LOGWRN)
194
+ for file in files:
195
+ if files[file]['isfile']: continue
196
+ dsid = op.basename(file)
197
+ if dsid not in dsids: dsids.append(dsid)
198
+ return sorted(dsids)
199
+
200
+ # locate a dataset path in the cold storage directories, the first match wins
201
+ def find_cold_path(self, spec):
202
+ """Look a dataset path up in each cold storage directory.
203
+
204
+ Warns and names the ignored ones if the path is found in more than one
205
+ cold storage directory.
206
+
207
+ Args:
208
+ spec (str): Dataset ID, optionally followed by a sub-path.
209
+
210
+ Returns:
211
+ tuple: (path, info dict) of the first match, or (None, None).
212
+ """
213
+ hits = {}
214
+ for root in self.RINFO['roots']:
215
+ path = self.join_paths(root, spec)
216
+ info = self.check_local_file(path, 0, 0)
217
+ if info: hits[path] = info
218
+ if not hits: return (None, None)
219
+ paths = list(hits)
220
+ if len(paths) > 1:
221
+ self.pglog("{}: Found in {} cold storage directories, use {}".format(spec, len(paths), paths[0]), self.LOGWRN)
222
+ self.pglog("{}: Ignored {}".format(spec, ', '.join(paths[1:])), self.LOGWRN)
223
+ return (paths[0], hits[paths[0]])
224
+
225
+ # count the files of a cold storage path still on tape
226
+ def hsm_offline_count(self, path, isfile):
227
+ """Return the number of files under a cold storage path that are still on tape.
228
+
229
+ Args:
230
+ path (str): Cold storage path of a file or directory.
231
+ isfile (int): 1 if path is a regular file, 0 for a directory.
232
+
233
+ Returns:
234
+ int | None: Count of offline files, or None if it cannot be determined.
235
+ """
236
+ out = self.pgsystem("{} status {}".format(self.HSM, path), self.LOGWRN, 51)
237
+ if not out: return None
238
+ if isfile: return (1 if re.search(r'migrated', out) else 0)
239
+ cnts = re.findall(r'Offline:\s*([\d,]+)', out)
240
+ if not cnts: return None
241
+ return int(re.sub(',', '', cnts[-1]))
242
+
243
+ # build the sfile condition of one dataset path
244
+ def sfile_condition(self, spec):
245
+ """Turn a dataset path into a condition on table dssdb.sfile.
246
+
247
+ A saved file lives in '<decsdata>/<dsid>/<type>/<sfile>', so the first
248
+ component of the path is the dataset ID, the second one the saved file
249
+ type, and the rest the leading part of the sfile field.
250
+
251
+ Args:
252
+ spec (str): Dataset ID, optionally followed by a sub-path.
253
+
254
+ Returns:
255
+ str: The WHERE condition of the saved files under the path.
256
+ """
257
+ paths = spec.strip('/').split('/')
258
+ if not re.match(r'^[a-z]\d{6}$', paths[0]):
259
+ self.pglog(spec + ": Invalid dataset path, dNNNNNN expected", self.LGEREX)
260
+ cnd = "dsid = '{}'".format(paths[0])
261
+ if len(paths) > 1:
262
+ if not re.match(r'^\w$', paths[1]):
263
+ self.pglog(spec + ": Invalid saved file type, one word character expected", self.LGEREX)
264
+ cnd += " AND type = '{}'".format(paths[1])
265
+ if len(paths) > 2:
266
+ sfile = '/'.join(paths[2:])
267
+ if re.search(r"['\\]", sfile):
268
+ self.pglog(spec + ": Invalid saved file path", self.LGEREX)
269
+ # the path is either a saved file itself or the directory holding them
270
+ cnd += " AND (sfile = '{}' OR sfile LIKE '{}/%')".format(sfile, re.sub(r'([%_])', r'\\\1', sfile))
271
+ return cnd
272
+
273
+ # get the archived size of one dataset path
274
+ def restore_data_size(self, spec):
275
+ """Return the total size of the saved files under one dataset path.
276
+
277
+ Args:
278
+ spec (str): Dataset ID, optionally followed by a sub-path.
279
+
280
+ Returns:
281
+ int: Number of bytes recorded in dssdb.sfile; 0 if nothing is found.
282
+ """
283
+ pgrec = self.pgget('sfile', "sum(data_size) tsize, count(sid) fcnt",
284
+ self.sfile_condition(spec), self.LOGWRN)
285
+ if not pgrec or not pgrec['tsize']:
286
+ self.pglog(spec + ": No saved file found in RDADB", self.LOGWRN)
287
+ return 0
288
+ self.pglog("{}: {} in {} saved file(s)".format(spec,
289
+ self.format_float_value(pgrec['tsize']), pgrec['fcnt']), self.LOGWRN)
290
+ return int(pgrec['tsize'])
291
+
292
+ # get the GLADE space left for the decsdata directory
293
+ def decsdata_free_size(self):
294
+ """Return the GLADE space left on the quota holding the decsdata directory.
295
+
296
+ Parses the 'Used' and 'Quota' columns of 'gladequota' and picks the
297
+ longest reported path the decsdata directory falls under.
298
+
299
+ Returns:
300
+ int | None: Number of free bytes, or None if it cannot be determined.
301
+ """
302
+ cmd = self.get_local_command("gladequota", self.PGLOG['COMMONUSER'])
303
+ out = self.pgsystem(cmd, self.LOGWRN, 21) # 1+4+16, log the command and return stdout
304
+ if not out: return None
305
+ target = op.realpath(self.RINFO['decsdir'])
306
+ (fsize, fpath) = (None, None)
307
+ for line in out.split('\n'):
308
+ ms = re.match(r'^(/\S+)\s+([\d.]+)\s*(\w+)\s+([\d.]+)\s*(\w+)', line)
309
+ if not ms: continue # skips the header and the 'n/a' quota lines
310
+ path = ms.group(1)
311
+ if not (target == path or target.startswith(path + '/')): continue
312
+ if fpath and len(fpath) >= len(path): continue # keeps the closest path only
313
+ used = self.quota_size(ms.group(2), ms.group(3))
314
+ quota = self.quota_size(ms.group(4), ms.group(5))
315
+ if used is None or quota is None: continue
316
+ (fsize, fpath) = (max(quota - used, 0), path)
317
+ return fsize
318
+
319
+ # convert one 'gladequota' size into bytes
320
+ def quota_size(self, value, unit):
321
+ """Convert one size reported by 'gladequota' into bytes.
322
+
323
+ Args:
324
+ value (str): The numeric part of the size.
325
+ unit (str): The unit of the size, such as 'TiB'.
326
+
327
+ Returns:
328
+ int | None: Number of bytes, or None for an unknown unit.
329
+ """
330
+ unit = unit.upper()
331
+ if unit not in self.SIZEUNITS: return None
332
+ return int(float(value)*self.SIZEUNITS[unit])
333
+
334
+ # make sure the decsdata directory has room for the whole restore
335
+ def check_restore_space(self, specs):
336
+ """Stop the recall if the decsdata directory cannot hold the whole restore.
337
+
338
+ The size to restore is added up from table dssdb.sfile and compared to
339
+ the GLADE space left for the decsdata directory. Twice the size is
340
+ required, since the recall brings the data back on disk inside the cold
341
+ storage first and Action -r copies it back afterwards, so both copies
342
+ live under the decsdata quota at the same time. The check is skipped,
343
+ with a warning, if either size cannot be determined.
344
+
345
+ Args:
346
+ specs (list[str]): Dataset paths to recall.
347
+ """
348
+ tsize = 0
349
+ for spec in specs:
350
+ tsize += self.restore_data_size(spec)
351
+ if not tsize:
352
+ self.pglog("Unknown size to restore, Skip checking the decsdata space", self.LOGWRN)
353
+ return
354
+ fsize = self.decsdata_free_size()
355
+ if fsize is None:
356
+ self.pglog("{}: Cannot get the space left, Skip checking the decsdata space".format(self.RINFO['decsdir']), self.LOGWRN)
357
+ return
358
+ nsize = 2*tsize # room for the recalled copy and for the copy of Action -r
359
+ msg = "{}: Restore {}, needs {} of the {} left".format(self.RINFO['decsdir'],
360
+ self.format_float_value(tsize), self.format_float_value(nsize),
361
+ self.format_float_value(fsize))
362
+ if nsize > fsize:
363
+ self.pglog(msg + ", NOT enough space", self.LGEREX)
364
+ self.pglog(msg, self.LOGWRN)
365
+
366
+ # act on one dataset path in cold storage
367
+ def restore_one_path(self, spec):
368
+ """Perform the requested action on one dataset path in cold storage.
369
+
370
+ Args:
371
+ spec (str): Dataset ID, optionally followed by a sub-path.
372
+ """
373
+ (path, info) = self.find_cold_path(spec)
374
+ if not path:
375
+ self.pglog(spec + ": NOT found in cold storage", self.LOGERR)
376
+ return
377
+ if self.ACTION == 'x':
378
+ self.recall_cold_path(spec, path)
379
+ elif self.ACTION == 's':
380
+ self.status_cold_path(spec, path, info['isfile'])
381
+ else:
382
+ self.copy_cold_path(spec, path, info['isfile'])
383
+
384
+ # submit the recall request of one cold storage path
385
+ def recall_cold_path(self, spec, path):
386
+ """Submit the HSM recall request for one cold storage path.
387
+
388
+ Args:
389
+ spec (str): Dataset ID, optionally followed by a sub-path.
390
+ path (str): Cold storage path of the data.
391
+ """
392
+ if self.pgsystem("{} recall -f {}".format(self.HSM, path), self.LOGWRN, 7):
393
+ self.RINFO['acnt'] += 1
394
+ self.pglog("{}: Recall requested, check the progress via -s".format(spec), self.LOGWRN)
395
+ else:
396
+ self.pglog("{}: Error request Recall of {}".format(spec, path), self.LOGERR)
397
+
398
+ # report the recall status of one cold storage path
399
+ def status_cold_path(self, spec, path, isfile):
400
+ """Report the HSM and recall status of one cold storage path.
401
+
402
+ Args:
403
+ spec (str): Dataset ID, optionally followed by a sub-path.
404
+ path (str): Cold storage path of the data.
405
+ isfile (int): 1 if path is a regular file, 0 for a directory.
406
+ """
407
+ offline = self.hsm_offline_count(path, isfile)
408
+ if offline is None:
409
+ self.pglog("{}: Cannot get the offline file count of {}".format(spec, path), self.LOGERR)
410
+ return
411
+ self.RINFO['acnt'] += 1
412
+ if offline > 0:
413
+ s = ('s' if offline > 1 else '')
414
+ self.pglog("{}: Recall PENDING, {} File{} still on tape".format(spec, offline, s), self.LOGWRN)
415
+ else:
416
+ self.pglog("{}: Recall COMPLETE, ready to copy back via -r".format(spec), self.LOGWRN)
417
+
418
+ # copy one recalled cold storage path back into the decsdata directory
419
+ def copy_cold_path(self, spec, path, isfile):
420
+ """Copy one recalled cold storage path back to its target directory.
421
+
422
+ Nothing is copied while files are still on tape unless -f is given.
423
+
424
+ Args:
425
+ spec (str): Dataset ID, optionally followed by a sub-path.
426
+ path (str): Cold storage path of the data.
427
+ isfile (int): 1 if path is a regular file, 0 for a directory.
428
+ """
429
+ offline = self.hsm_offline_count(path, isfile)
430
+ if offline:
431
+ s = ('s' if offline > 1 else '')
432
+ if not self.OPTS['f']:
433
+ self.pglog("{}: {} File{} still on tape, add Mode -f to copy anyway".format(spec, offline, s), self.LOGERR)
434
+ return
435
+ self.pglog("{}: {} File{} still on tape, copy it anyway".format(spec, offline, s), self.LOGWRN)
436
+ tofile = self.join_paths(self.OPTS['t'], spec)
437
+ # a directory is copied as '<path>/.' to merge into an existing target
438
+ fromfile = path if isfile else path + "/."
439
+ if self.local_copy_local(tofile, fromfile, self.LOGWRN):
440
+ self.RINFO['acnt'] += 1
441
+ self.pglog("{}: Copied back to {}".format(spec, tofile), self.LOGWRN)
442
+ else:
443
+ self.pglog("{}: Error copy {} back to {}".format(spec, path, tofile), self.LOGERR)
444
+
445
+ # main function to execute this script
446
+ def main():
447
+ """Entry point: instantiate DecsRestore, parse arguments, run, and exit."""
448
+ from rda_python_setuid.setup_guide import show_setup_guide
449
+ object = DecsRestore()
450
+ show_setup_guide(object, 'rda_python_miscs', ['decsdata_storage', 'decsdata_restore'])
451
+ object.read_parameters()
452
+ object.start_actions()
453
+ object.pgexit(0)
454
+
455
+ # call main() to start program
456
+ if __name__ == "__main__": main()
@@ -0,0 +1,101 @@
1
+
2
+ Restore decsdata datasets, or parts of them, out of the GLADE HSM cold storage.
3
+ This is the opposite of 'decsdata_storage'. A recall request is only submitted
4
+ by 'glade_hsm recall' and is fulfilled by the HSM batch processes later on, so
5
+ restoring is done in three steps:
6
+
7
+ 1. Option -x submits the recall requests for the given dataset paths;
8
+ 2. Option -s checks the recall status, repeat it until nothing is on tape;
9
+ 3. Option -r copies the recalled data back into the decsdata directory.
10
+
11
+ Recalled files stay readable inside the 'COLD_STORAGE/' path for 7 days only,
12
+ after which they are migrated onto tape again, so step 3 must be done within
13
+ that window.
14
+
15
+ Usage: decsdata_restore [-D Date] [-w DecsdataDirectory] [-l ListFile] \
16
+ [-d DatasetPathList] [-f] -x|-s|-r [TargetDirectory]
17
+
18
+ - Option -D Date, the cold storage date, in YYYYMMDD or YYYY-MM-DD. Only
19
+ '<decsdata>/cold_storage_<date>/COLD_STORAGE' is searched for the
20
+ data. Without it, '<decsdata>/COLD_STORAGE' and every
21
+ '<decsdata>/cold_storage_<YYYYMMDD>/COLD_STORAGE' are all searched,
22
+ the most recent dated one first. A dataset path found in more than
23
+ one of them is acted on in the first one only, and the ignored ones
24
+ are logged;
25
+
26
+ - Option -w DecsdataDirectory, the decsdata directory holding the cold
27
+ storage directories, and the directory the data is copied back into.
28
+ Defaults to the configured decsdata root path, /gdex/decsdata;
29
+
30
+ - Option -l ListFile, a file holding one dataset path per line. Only
31
+ used if -d is not given. Defaults to 'dsids_<today>.lst' in the
32
+ current directory, which is generated from every dNNNNNN directory
33
+ found in the cold storage directories if it does not exist yet;
34
+
35
+ - Option -d DatasetPathList, one or more dataset IDs to restore, such as
36
+ '-d d612000 d627000'. Append a sub-path to a dataset ID to restore
37
+ part of it only, such as '-d d612000/2020/01';
38
+
39
+ - Option -f, copy the data back even while some of its files are still on
40
+ tape. Without it, Action -r refuses to copy such data;
41
+
42
+ - Option -h, display this help document;
43
+
44
+ - Action -x, submit the recall requests to bring the data back on disk.
45
+ The decsdata area is limited, so the size to restore is added up
46
+ from Table 'sfile' in RDADB and compared to the space left for the
47
+ decsdata directory, as reported by 'gladequota', before any recall
48
+ is requested. TWICE the size is required, since the recalled copy
49
+ in the cold storage and the copy made by Action -r later on both
50
+ live under the decsdata quota; the recall is stopped if the space
51
+ left is not enough;
52
+
53
+ - Action -s, report the HSM status of the data, including the number of
54
+ files still on tape and the log of any outstanding recall request;
55
+
56
+ - Action -r [TargetDirectory], copy the recalled data back under
57
+ '<decsdata>/<dsid>', or under the given TargetDirectory. A restored
58
+ sub-path keeps its relative position, so 'd612000/2020/01' is copied
59
+ back to '<TargetDirectory>/d612000/2020/01'. The dataset directory,
60
+ and any sub-directory of it, is created if it does not exist yet.
61
+
62
+ One and only one of the Actions -x, -s and -r is required; this help document
63
+ is displayed without any of them. This utility can be run from any directory.
64
+ It is executed under the effective user 'gdexdata' via setuid, so the restored
65
+ data is owned by 'gdexdata'. The cold storage copy of the data is left in
66
+ place by Action -r; move it out of the 'COLD_STORAGE/' path manually to take
67
+ a dataset out of the HSM permanently.
68
+
69
+ Examples:
70
+
71
+ 1. Restore a whole dataset from the cold storage of the root decsdata
72
+ directory, /gdex/decsdata/COLD_STORAGE/d612000:
73
+
74
+ decsdata_restore -d d612000 -x
75
+ decsdata_restore -d d612000 -s
76
+ decsdata_restore -d d612000 -r
77
+
78
+ 2. Restore it from the cold storage directory of a specific date,
79
+ /gdex/decsdata/cold_storage_20250529/COLD_STORAGE/d612000:
80
+
81
+ decsdata_restore -D 20250529 -d d612000 -x
82
+
83
+ 3. Restore one year of a dataset only:
84
+
85
+ decsdata_restore -d d612000/2020 -x
86
+ decsdata_restore -d d612000/2020 -s
87
+ decsdata_restore -d d612000/2020 -r
88
+
89
+ 4. Copy the recalled data back to a directory other than the decsdata one:
90
+
91
+ decsdata_restore -d d612000 -r /PathTo/OtherDirectory
92
+
93
+ 5. Check the status of every dataset in every cold storage directory,
94
+ generating the dataset list file automatically:
95
+
96
+ decsdata_restore -s
97
+
98
+ 6. Copy a dataset back even though some of its files are still on tape,
99
+ which makes the HSM read them off tape while they are being copied:
100
+
101
+ decsdata_restore -d d612000 -f -r
@@ -0,0 +1,196 @@
1
+ #!/usr/bin/env python3
2
+ ##################################################################################
3
+ # Title: decsdata_storage
4
+ # Author: Zaihua Ji, zji@ucar.edu
5
+ # Date: 2026-08-31
6
+ # Purpose: move decsdata datasets into a dated cold storage directory and hand
7
+ # them to the GLADE HSM to be migrated onto tape
8
+ # Github: https://github.com/NCAR/rda-python-miscs.git
9
+ ##################################################################################
10
+ import re
11
+ import os
12
+ import sys
13
+ from os import path as op
14
+ from rda_python_common.pg_file import PgFile
15
+
16
+ class DecsStorage(PgFile):
17
+ """Move decsdata datasets into cold storage and migrate them onto tape.
18
+
19
+ Each given dataset directory is moved from the decsdata directory into
20
+ '<decsdata>/cold_storage_<date>/' and then handed to 'glade_hsm migrate',
21
+ which relocates it once more into a 'COLD_STORAGE/' sub-directory and lets
22
+ the HSM batch processes migrate every large file onto tape. Use
23
+ decsdata_restore to bring the data back.
24
+ """
25
+
26
+ def __init__(self):
27
+ """Initialize DecsStorage with default option values and runtime state."""
28
+ super().__init__()
29
+ self.HSM = os.environ.get('GLADE_HSM', op.expanduser('~benkirk/glade_hsm'))
30
+ self.VALOPTS = 'Dwl' # single-value options
31
+ self.MULOPTS = 'd' # multi-value options
32
+ self.MODOPTS = 'hx' # mode options
33
+ self.OPTS = {
34
+ 'D': None, # cold storage date, in YYYYMMDD or YYYY-MM-DD
35
+ 'w': None, # decsdata directory, defaults to PGLOG['DECSHOME']
36
+ 'l': None, # dataset list file
37
+ 'd': [], # dataset IDs
38
+ 'h': 0, # 1 to show help message
39
+ 'x': 0, # 1 to execute, mandatory
40
+ }
41
+ self.SINFO = {
42
+ 'decsdir': None, # decsdata directory the datasets are stored from
43
+ 'coldstor': None, # cold storage directory the datasets are moved to
44
+ 'date': None, # cold storage date
45
+ 'dcnt': 0, # number of datasets moved into cold storage
46
+ }
47
+
48
+ # function to read parameters
49
+ def read_parameters(self):
50
+ """Parse the command line into the OPTS option values.
51
+
52
+ Single-value options -D, -w and -l take one value each, multi-value
53
+ option -d gathers every following dataset ID, and mode options -h and -x
54
+ are simple flags. Exits with usage if -h is given or -x is missing.
55
+ """
56
+ self.set_suid(self.PGLOG['EUID'])
57
+ self.set_help_path(__file__)
58
+ self.PGLOG['LOGFILE'] = "decsdata_storage.log" # set different log file
59
+ argv = sys.argv[1:]
60
+ self.cmdlog("decsdata_storage {}".format(' '.join(argv)))
61
+ option = None
62
+ for arg in argv:
63
+ ms = re.match(r'^-(\w+)$', arg)
64
+ if ms:
65
+ option = ms.group(1)
66
+ if option in self.MODOPTS:
67
+ self.OPTS[option] = 1
68
+ option = None
69
+ elif option not in self.VALOPTS and option not in self.MULOPTS:
70
+ self.pglog(arg + ": Unknown Option", self.LGEREX)
71
+ continue
72
+ if not option: self.pglog(arg + ": Value provided without option", self.LGEREX)
73
+ if option in self.MULOPTS:
74
+ self.OPTS[option].append(arg) # gather all values until the next option
75
+ else:
76
+ self.OPTS[option] = arg
77
+ option = None
78
+ if self.OPTS['h'] or not self.OPTS['x']: self.show_usage("decsdata_storage")
79
+
80
+ # function to start actions
81
+ def start_actions(self):
82
+ """Validate the caller, resolve the cold storage paths, and store each dataset."""
83
+ self.dssdb_dbname()
84
+ self.validate_decs_group('decsdata_storage', self.PGLOG['CURUID'], 1)
85
+ self.set_storage_paths()
86
+ dsids = self.get_dataset_list()
87
+ if not dsids: self.pglog("No dataset found for cold storage", self.LGWNEX)
88
+ for dsid in dsids:
89
+ self.store_one_dataset(dsid)
90
+ s = ('s' if self.SINFO['dcnt'] > 1 else '')
91
+ self.pglog("{} of {} Dataset{} moved into {}".format(self.SINFO['dcnt'],
92
+ len(dsids), s, self.SINFO['coldstor']), self.LOGWRN)
93
+ self.cmdlog()
94
+
95
+ # resolve the decsdata and cold storage directories
96
+ def set_storage_paths(self):
97
+ """Fill SINFO with the decsdata directory, cold storage date and cold storage path.
98
+
99
+ Defaults the decsdata directory to PGLOG['DECSHOME'] and the date to
100
+ today. Dashes are stripped from the given date, which must then be 8
101
+ digits.
102
+ """
103
+ decsdir = self.OPTS['w'] if self.OPTS['w'] else self.PGLOG['DECSHOME']
104
+ if not self.check_local_file(decsdir, 0, self.LOGWRN):
105
+ self.pglog(decsdir + ": decsdata directory NOT exists", self.LGEREX)
106
+ date = re.sub('-', '', self.OPTS['D']) if self.OPTS['D'] else re.sub('-', '', self.curdate())
107
+ if not re.match(r'^\d{8}$', date):
108
+ self.pglog(date + ": Invalid cold storage date, YYYYMMDD expected", self.LGEREX)
109
+ self.SINFO['decsdir'] = decsdir
110
+ self.SINFO['date'] = date
111
+ self.SINFO['coldstor'] = self.join_paths(decsdir, "cold_storage_" + date)
112
+
113
+ # gather the dataset IDs to move into cold storage
114
+ def get_dataset_list(self):
115
+ """Return the list of dataset IDs to store.
116
+
117
+ Uses the -d values if given. Otherwise reads the -l list file, creating
118
+ it first from every dNNNNNN directory in the decsdata directory if it
119
+ does not exist yet.
120
+
121
+ Returns:
122
+ list[str]: Dataset IDs, empty if none is found.
123
+ """
124
+ if self.OPTS['d']:
125
+ self.pglog("Store {} given Dataset(s) into cold storage".format(len(self.OPTS['d'])), self.LOGWRN)
126
+ return self.OPTS['d']
127
+ lstfile = self.OPTS['l'] if self.OPTS['l'] else "dsids_{}.lst".format(self.SINFO['date'])
128
+ if not op.isfile(lstfile):
129
+ dsids = self.get_decsdata_datasets()
130
+ with open(lstfile, 'w') as OUT:
131
+ for dsid in dsids: OUT.write(dsid + "\n")
132
+ self.pglog("{}: Generated with {} Dataset(s)".format(lstfile, len(dsids)), self.LOGWRN)
133
+ dsids = []
134
+ with open(lstfile, 'r') as IN:
135
+ for line in IN:
136
+ line = line.strip()
137
+ if line: dsids.append(line)
138
+ self.pglog("{}: Read {} Dataset(s) for cold storage".format(lstfile, len(dsids)), self.LOGWRN)
139
+ return dsids
140
+
141
+ # find all dNNNNNN dataset directories in the decsdata directory
142
+ def get_decsdata_datasets(self):
143
+ """Return the sorted dataset IDs of every dNNNNNN directory in the decsdata directory.
144
+
145
+ Returns:
146
+ list[str]: Dataset IDs; plain files matching the pattern are skipped.
147
+ """
148
+ pattern = self.join_paths(self.SINFO['decsdir'], "d" + "[0-9]"*6)
149
+ files = self.local_glob(pattern, 0, self.LOGWRN)
150
+ dsids = []
151
+ for file in files:
152
+ if not files[file]['isfile']: dsids.append(op.basename(file))
153
+ return sorted(dsids)
154
+
155
+ # move one dataset into cold storage and migrate it onto tape
156
+ def store_one_dataset(self, dsid):
157
+ """Move one dataset into the cold storage directory and migrate it onto tape.
158
+
159
+ Skips the dataset if it is not an existing directory in the decsdata
160
+ directory or if the move fails. Increments SINFO['dcnt'] for each
161
+ dataset successfully migrated.
162
+
163
+ Args:
164
+ dsid (str): Dataset ID, such as 'd612000'.
165
+ """
166
+ fromfile = self.join_paths(self.SINFO['decsdir'], dsid)
167
+ info = self.check_local_file(fromfile, 0, self.LOGWRN)
168
+ if not info:
169
+ self.pglog(fromfile + ": Dataset NOT exists", self.LOGERR)
170
+ return
171
+ if info['isfile']:
172
+ self.pglog(fromfile + ": Not a dataset directory", self.LOGERR)
173
+ return
174
+ tofile = self.join_paths(self.SINFO['coldstor'], dsid)
175
+ if not self.move_local_file(tofile, fromfile, self.LOGWRN):
176
+ self.pglog("{}: Error move {} into cold storage".format(fromfile, dsid), self.LOGERR)
177
+ return
178
+ cmd = "{} migrate -f {}".format(self.HSM, tofile)
179
+ if self.pgsystem(cmd, self.LOGWRN, 7):
180
+ self.SINFO['dcnt'] += 1
181
+ self.pglog("{}: Migrated onto tape from {}".format(dsid, tofile), self.LOGWRN)
182
+ else:
183
+ self.pglog("{}: Error migrate onto tape from {}".format(dsid, tofile), self.LOGERR)
184
+
185
+ # main function to execute this script
186
+ def main():
187
+ """Entry point: instantiate DecsStorage, parse arguments, run, and exit."""
188
+ from rda_python_setuid.setup_guide import show_setup_guide
189
+ object = DecsStorage()
190
+ show_setup_guide(object, 'rda_python_miscs', ['decsdata_storage', 'decsdata_restore'])
191
+ object.read_parameters()
192
+ object.start_actions()
193
+ object.pgexit(0)
194
+
195
+ # call main() to start program
196
+ if __name__ == "__main__": main()
@@ -0,0 +1,66 @@
1
+
2
+ Move decsdata datasets into a dated cold storage directory and hand them to the
3
+ GLADE HSM to be migrated onto tape. Each dataset directory is moved from the
4
+ decsdata directory into '<decsdata>/cold_storage_<date>/' and then passed to
5
+ 'glade_hsm migrate', which relocates it once more into a 'COLD_STORAGE/'
6
+ sub-directory, ending up as:
7
+
8
+ <decsdata>/cold_storage_<date>/COLD_STORAGE/<dsid>
9
+
10
+ The HSM batch processes then migrate every large file (>= 100MB) underneath the
11
+ 'COLD_STORAGE/' path onto tape. All files inside a 'COLD_STORAGE/' path are
12
+ marked immutable, which prevents modification of their contents and metadata.
13
+ Use 'decsdata_restore' to bring the data back.
14
+
15
+ Usage: decsdata_storage [-D Date] [-w DecsdataDirectory] [-l ListFile] \
16
+ [-d DatasetList] -x
17
+
18
+ - Option -D Date, the cold storage date, in YYYYMMDD or YYYY-MM-DD. It
19
+ names the cold storage directory '<decsdata>/cold_storage_<date>'.
20
+ Defaults to today;
21
+
22
+ - Option -w DecsdataDirectory, the decsdata directory the datasets are
23
+ stored from, and the directory the cold storage directory is created
24
+ in. Defaults to the configured decsdata root path, /gdex/decsdata;
25
+
26
+ - Option -l ListFile, a file holding one dataset ID per line. Only used
27
+ if -d is not given. Defaults to 'dsids_<date>.lst' in the current
28
+ directory, which is generated from every dNNNNNN directory found in
29
+ the decsdata directory if it does not exist yet;
30
+
31
+ - Option -d DatasetList, one or more dataset IDs to move into cold
32
+ storage, such as '-d d612000 d627000';
33
+
34
+ - Option -h, display this help document;
35
+
36
+ - Option -x, execute the cold storage moves. This option is mandatory;
37
+ this help document is displayed without it. Note that no
38
+ confirmation is asked for once -x is given.
39
+
40
+ This utility can be run from any directory. It is executed under the
41
+ effective user 'gdexdata' via setuid, so the moved data keeps its ownership.
42
+ A dataset is skipped, and an error is logged, if it is not an existing
43
+ directory in the decsdata directory or if it cannot be moved.
44
+
45
+ Examples:
46
+
47
+ 1. Move two given datasets into today's cold storage:
48
+
49
+ decsdata_storage -d d612000 d627000 -x
50
+
51
+ 2. Move them into the cold storage directory of a specific date:
52
+
53
+ decsdata_storage -D 20250529 -d d612000 -x
54
+
55
+ The data then ends up in:
56
+
57
+ /gdex/decsdata/cold_storage_20250529/COLD_STORAGE/d612000
58
+
59
+ 3. Move every dataset listed in a list file:
60
+
61
+ decsdata_storage -l my_dsids.lst -x
62
+
63
+ 4. Move every dNNNNNN dataset found in a non-default decsdata directory,
64
+ generating the dataset list file automatically:
65
+
66
+ decsdata_storage -w /PathTo/decsdata -x
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rda_python_miscs
3
- Version: 3.0.4
3
+ Version: 3.0.5
4
4
  Summary: RDA Python package to hold RDA miscellaneous utility programs
5
5
  Author-email: Zaihua Ji <zji@ucar.edu>
6
6
  Project-URL: Homepage, https://github.com/NCAR/rda-python-miscs
@@ -45,6 +45,8 @@ The package provides two categories of programs:
45
45
  | `gdexcp` | `rdacp` | `setuid_gdexcp` / `setuid_rdacp` | Copy files and directories across local, remote, Object Store, or Globus endpoints |
46
46
  | `gdexkill` | `rdakill` | `setuid_gdexkill` / `setuid_rdakill` | Kill local processes and their children, or cancel PBS batch jobs |
47
47
  | `gdexmod` | `rdamod` | `setuid_gdexmod` / `setuid_rdamod` | Change permission modes for files and directories owned by gdexdata |
48
+ | `decsdata_storage` | | `setuid_decsdata_storage` | Move decsdata datasets into the GLADE HSM cold storage to migrate them onto tape |
49
+ | `decsdata_restore` | | `setuid_decsdata_restore` | Recall decsdata datasets out of the GLADE HSM cold storage and copy them back |
48
50
 
49
51
  ## Environment setup
50
52
 
@@ -103,8 +105,8 @@ pip install rda_python_miscs
103
105
 
104
106
  ## Setuid Setup
105
107
 
106
- The setuid programs (`gdexcp`, `gdexkill`, `gdexmod` and their `rda*` aliases)
107
- execute as the common user `PGLOG['COMMONUSER']` (default `gdexdata`) via
108
+ The setuid programs (`gdexcp`, `gdexkill`, `gdexmod`, `decsdata_storage`,
109
+ `decsdata_restore` and the `rda*` aliases) execute as the common user `PGLOG['COMMONUSER']` (default `gdexdata`) via
108
110
  the `rda_python_setuid` mechanism, which is pulled in automatically as a
109
111
  dependency. After `pip install` above, choose one of the wiring options
110
112
  below.
@@ -159,3 +161,30 @@ pywrapper-install -u|--update
159
161
  The shared setuid setup guide is shown automatically if any `setuid_*`
160
162
  connector script is invoked directly before the setuid wrapper has been
161
163
  configured.
164
+
165
+ ## Cold storage setup
166
+
167
+ `decsdata_storage` and `decsdata_restore` drive the GLADE HSM through the
168
+ `glade_hsm` script, which is not part of this package. They look it up at
169
+ `~benkirk/glade_hsm` unless the environment variable `GLADE_HSM` names another
170
+ copy of it:
171
+
172
+ ```bash
173
+ export GLADE_HSM=/path/to/glade_hsm
174
+ ```
175
+
176
+ Both programs work on the decsdata directory `PGLOG['DECSHOME']` (default
177
+ `/glade/campaign/collections/gdex/decsdata`, also reachable as
178
+ `/gdex/decsdata`), which option `-w` overrides. Only members of the DECS group
179
+ may run them, and `decsdata_restore` reads Table `sfile` in RDADB to size a
180
+ restore, so a working RDADB login is required as well.
181
+
182
+ Because the decsdata quota is limited, check the space left with `gladequota`
183
+ before a large restore:
184
+
185
+ ```bash
186
+ pgstart_gdexdata gladequota # or plain 'gladequota' when logged in as gdexdata
187
+ ```
188
+
189
+ `decsdata_restore -x` runs the same check itself and refuses to request a
190
+ recall unless twice the size of the data is still available.
@@ -6,6 +6,10 @@ src/rda_python_miscs/__init__.py
6
6
  src/rda_python_miscs/bash_qsub.py
7
7
  src/rda_python_miscs/bashqsub.py
8
8
  src/rda_python_miscs/bashqsub.usg
9
+ src/rda_python_miscs/decsdata_restore.py
10
+ src/rda_python_miscs/decsdata_restore.usg
11
+ src/rda_python_miscs/decsdata_storage.py
12
+ src/rda_python_miscs/decsdata_storage.usg
9
13
  src/rda_python_miscs/gdex_ls.py
10
14
  src/rda_python_miscs/gdexcp.py
11
15
  src/rda_python_miscs/gdexcp.usg
@@ -11,6 +11,8 @@ rdaown = rda_python_miscs.gdexown:main
11
11
  rdaps = rda_python_miscs.gdexps:main
12
12
  rdasub = rda_python_miscs.gdexsub:main
13
13
  rdazip = rda_python_miscs.gdexzip:main
14
+ setuid_decsdata_restore = rda_python_miscs.decsdata_restore:main
15
+ setuid_decsdata_storage = rda_python_miscs.decsdata_storage:main
14
16
  setuid_gdexcp = rda_python_miscs.gdexcp:main
15
17
  setuid_gdexkill = rda_python_miscs.gdexkill:main
16
18
  setuid_gdexmod = rda_python_miscs.gdexmod:main
@@ -14,3 +14,5 @@ def test_miscs():
14
14
  import rda_python_miscs.gdexps
15
15
  import rda_python_miscs.gdexsub
16
16
  import rda_python_miscs.gdexzip
17
+ import rda_python_miscs.decsdata_storage
18
+ import rda_python_miscs.decsdata_restore