pydcache 0.1.6__py3-none-any.whl

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.
pydcache/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """pydcache – collection of dCache Python utilities."""
2
+
3
+ __version__ = "0.1.6"
@@ -0,0 +1,79 @@
1
+ """pydcache.config – configuration file deployment helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import shutil
8
+ import stat
9
+ from pathlib import Path
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # Bundled example / template config shipped inside the package
14
+ _TEMPLATE = Path(__file__).parent / "pydcache.yaml"
15
+
16
+ # Environment variable that overrides automatic path resolution
17
+ _ENV_VAR = "DCACHE_CONFIG"
18
+
19
+
20
+ def _default_config_path() -> Path:
21
+ """Return the platform-appropriate config path for the current user.
22
+
23
+ * root (uid 0) → /etc/pydcache/pydcache.yaml
24
+ * regular user → ~/.config/pydcache/pydcache.yaml (XDG Base Dir spec)
25
+ """
26
+ if os.getuid() == 0:
27
+ return Path("/etc/pydcache/pydcache.yaml")
28
+ xdg_config = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
29
+ return xdg_config / "pydcache" / "pydcache.yaml"
30
+
31
+
32
+ def get_config_path() -> Path:
33
+ """Return the resolved config path, deploying the template if needed.
34
+
35
+ Resolution order:
36
+
37
+ 1. ``$DCACHE_CONFIG`` environment variable (no auto-deploy, must exist).
38
+ 2. Platform default (``/etc/pydcache/`` or ``~/.config/pydcache/``);
39
+ the bundled template is copied there on first use.
40
+
41
+ The deployed file is always created with mode **0600**.
42
+
43
+ Returns:
44
+ Path to the config file to load.
45
+
46
+ Raises:
47
+ FileNotFoundError: If ``$DCACHE_CONFIG`` is set but does not exist.
48
+ """
49
+ env_override = os.environ.get(_ENV_VAR)
50
+ if env_override:
51
+ path = Path(env_override)
52
+ if not path.exists():
53
+ raise FileNotFoundError(
54
+ f"{_ENV_VAR}={path} — file does not exist"
55
+ )
56
+ return path
57
+
58
+ path = _default_config_path()
59
+ if not path.exists():
60
+ _deploy_template(path)
61
+ return path
62
+
63
+
64
+ def _deploy_template(dest: Path) -> None:
65
+ """Copy the bundled template to *dest* and set permissions to 0600.
66
+
67
+ Args:
68
+ dest: Destination path (parent directories are created as needed).
69
+
70
+ Raises:
71
+ OSError: If the directory cannot be created or the file written.
72
+ """
73
+ dest.parent.mkdir(parents=True, exist_ok=True)
74
+ shutil.copy2(_TEMPLATE, dest)
75
+ dest.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0600
76
+ logger.info(
77
+ "Deployed example config to %s — please fill in your credentials.",
78
+ dest,
79
+ )
@@ -0,0 +1,32 @@
1
+ [Unit]
2
+ Description=CTA Nanny – dCache/CTA duplicate-key repair daemon
3
+ Documentation=https://github.com/DmitryLitvintsev/pydcache
4
+ After=network-online.target
5
+ Wants=network-online.target
6
+
7
+ [Service]
8
+ Type=simple
9
+ User=root
10
+ Group=root
11
+
12
+ # Path to your filled-in config (copy from /etc/pydcache/pydcache.yaml and edit)
13
+ Environment="DCACHE_CONFIG=/etc/pydcache/pydcache.yaml"
14
+
15
+ # Adjust --cpu-count and --instance to match your site
16
+ ExecStart=CTA_NANNY_EXEC --cpu-count 10 --instance public_prd
17
+
18
+ # Restart policy
19
+ Restart=on-failure
20
+ RestartSec=30s
21
+
22
+ # Resource limits
23
+ LimitNOFILE=65536
24
+
25
+ # Send stdout/stderr to the journal
26
+ StandardOutput=journal
27
+ StandardError=journal
28
+ SyslogIdentifier=cta-nanny
29
+
30
+ [Install]
31
+ WantedBy=multi-user.target
32
+
@@ -0,0 +1,29 @@
1
+ # cta_nanny configuration example
2
+ #
3
+ # Copy this file to a secure location, fill in your real values,
4
+ # and point cta-nanny at it via the DCACHE_CONFIG environment variable:
5
+ #
6
+ # export DCACHE_CONFIG=/path/to/cta_nanny.yaml
7
+ #
8
+ # WARNING: this file contains credentials — do NOT commit it to version control.
9
+
10
+ # PostgreSQL connection URIs (require read/write access)
11
+ # Format: postgresql://<user>:<password>@<host>:<port>/<database>
12
+ cta_db: "postgresql://cta_user:changeme@cta-db.example.com:5432/cta_prd"
13
+ chimera_db: "postgresql://chimera_user:changeme@chimera-db.example.com:5432/chimera"
14
+
15
+ # dCache admin SSH shell
16
+ admin:
17
+ host: dcache-admin.example.com
18
+ port: 22224
19
+ user: dcache_admin_user
20
+
21
+ # Kafka consumer
22
+ kafka:
23
+ # Topic produced by cta-taped ingest logs
24
+ topic: "ingest.logs.cta.taped"
25
+ # topic: "ingest.dcache.billing"
26
+ group: "dcache-ctananny-consumer-group"
27
+ # group: "dcache-public-stores"
28
+ bootstrap_servers: "kafka-broker.example.com:9092"
29
+
pydcache/py.typed ADDED
File without changes
File without changes
@@ -0,0 +1,383 @@
1
+ import argparse
2
+ import json
3
+ import logging
4
+ import logging.config
5
+ import multiprocessing
6
+ import os
7
+ import re
8
+ import socket
9
+ import sys
10
+ import time
11
+ import traceback
12
+ from multiprocessing import Lock, Process, Queue
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List, Optional, Tuple, NoReturn
15
+ from urllib.parse import urlparse
16
+
17
+ import paramiko
18
+ import psycopg2
19
+ import psycopg2.extensions
20
+ import psycopg2.extras
21
+ import yaml
22
+
23
+ from pydcache.util import admin, kerberos, ostools, paramiko, psycopg
24
+ from pydcache.config import get_config_path
25
+
26
+ from kafka import KafkaConsumer
27
+
28
+ """
29
+
30
+ epoch_time\":1775496237.108132665,\"local_time\":\"2026-04-06T12:23:57-0500\",\"hostname\":\"tpsrvf2104\",\"program\":\"cta-taped\",\"log_level\":\"ERROR\",\"pid\":1160350,\"tid\":1638518,\"message\":\"In ArchiveMount::reportJobsBatchTransferred(): got an exception\",\"drive_name\":\"F1_F9B5D4\",\"instance\":\"prd\",\"sched_backend\":\"cephUser\",\"thread\":\"MainThread\",\"tapeDrive\":\"F1_F9B5D4\",\"mountId\":\"453120\",\"vo\":\"cms\",\"tapePool\":\"cms.Run2025DPrompt\",\"successfulBatchSize\":7,\"exceptionMessageValue\":\"commit problem committing the DB transaction: Database library reported: ERROR: duplicate key value violates unique constraint \"archive_file_din_dfi_un\"DETAIL: Key (disk_instance_name, disk_file_id)=(cms_prd, 00003DE869D8B37F4516A7FF64556A8A7E01) already exists. (DB Result Status:7 SQLState:23505)\"}
31
+
32
+ {'date': '2026-04-13T13:04:20.794-05:00', 'msgType': 'store', 'hsm': {'instance': 'cta', 'provider': 'dcache-cta', 'type': 'cta'}, 'transferTime': 9, 'cellName': 'rw-stkendca61a-2', 'session': 'pool:rw-stkendca61a-2@rw-stkendca61a-2Domain:1776103460794-37454', 'version': '1.0', 'storageInfo': 'dune.dune@cta', 'cellType': 'pool', 'fileSize': 2967538045, 'queuingTime': 0, 'cellDomain': 'rw-stkendca61a-2Domain', 'locations': [], 'pnfsid': '0000082C1CA24ABE411FA957DCB563929441', 'transaction': 'pool:rw-stkendca61a-2@rw-stkendca61a-2Domain:1776103460794-37454', 'billingPath': '/pnfs/fnal.gov/usr/dune/tape_backed/dunepro/beam-data/040416/foo.root', 'status': {'msg': 'io.grpc.StatusRuntimeException: ABORTED: Storage class dune.dune@cta has no archive routes', 'code': 10011}}
33
+
34
+ """
35
+
36
+ # Configure logging
37
+ logging.basicConfig(
38
+ level=logging.INFO,
39
+ format='%(asctime)s - %(levelname)s - %(message)s',
40
+ stream=sys.stderr
41
+ )
42
+ logger = logging.getLogger(__name__)
43
+
44
+ # Global locks
45
+ print_lock = Lock()
46
+ kinit_lock = Lock()
47
+
48
+ CONFIG_FILE = os.getenv("DCACHE_CONFIG") # None → auto-resolved via get_config_path()
49
+ SSH_HOST = "fndca"
50
+ SSH_PORT = 24223
51
+ SSH_USER = "enstore"
52
+ HOSTNAME = socket.getfqdn()
53
+
54
+
55
+ _VO = "vo"
56
+ _INSTANCE = "instance"
57
+
58
+ def safe_json_deserializer(v):
59
+ try:
60
+ return json.loads(v.decode('utf-8')) if v else None
61
+ except (json.JSONDecodeError, UnicodeDecodeError) as e:
62
+ return None # Or log the error
63
+
64
+
65
+ class Worker(Process):
66
+ """Worker process to query CTA DB and process results."""
67
+
68
+ def __init__(
69
+ self,
70
+ queue: Queue,
71
+ config: Dict[str, Any]
72
+ ) -> None:
73
+ """Initialize the worker.
74
+
75
+ Args:
76
+ queue: Task queue
77
+ config: Configuration dictionary
78
+ """
79
+ super().__init__()
80
+ self.queue = queue
81
+ self.config = config
82
+
83
+ def run(self) -> None:
84
+ """Process tasks from the queue."""
85
+ cta_db = chimera_db = ssh = None
86
+ try:
87
+ cta_db = psycopg.create_connection(self.config["cta_db"])
88
+ chimera_db = psycopg.create_connection(self.config["chimera_db"])
89
+ ssh = paramiko.get_shell(
90
+ self.config["admin"].get("host", SSH_HOST),
91
+ self.config["admin"].get("port", SSH_PORT),
92
+ self.config["admin"].get("user", SSH_USER)
93
+ )
94
+
95
+ for pnfsid in iter(self.queue.get, None):
96
+ self._process_file(ssh, cta_db, chimera_db, pnfsid)
97
+ except Exception as exc:
98
+ error_string = traceback.format_exc()
99
+ #logger.error("Worker failed: %s", exc)
100
+ logger.error(f"Worker failed: {error_string}")
101
+ finally:
102
+ for conn in (ssh, cta_db, chimera_db):
103
+ if conn:
104
+ try:
105
+ conn.close()
106
+ except Exception:
107
+ pass
108
+
109
+ def _process_file(
110
+ self,
111
+ ssh,
112
+ cta_db: psycopg2.extensions.connection,
113
+ chimera_db: psycopg2.extensions.connection,
114
+ pnfsid: str
115
+ ) -> None:
116
+ """Process a single file.
117
+
118
+ Args:
119
+ ssh: SSH connection
120
+ cta_db: CTA database connection
121
+ chimera_db: Chimera database connection
122
+ pnfsid: PNFS ID to process
123
+ instance: CTA instance name
124
+ """
125
+
126
+ rows = psycopg.select(
127
+ cta_db,
128
+ "select af.disk_instance_name, "
129
+ "sc.storage_class_name, "
130
+ "'cta://cta/'||af.disk_file_id||'?archiveid='||af.archive_file_id as location "
131
+ "from archive_file af inner join storage_class sc on sc.storage_class_id = af.storage_class_id "
132
+ "where af.disk_file_id = %s and af.creation_time < %s",
133
+ (pnfsid, int(time.time()) - 6 * 3600)
134
+ )
135
+
136
+ if not rows:
137
+ with print_lock:
138
+ logger.error(f"Failed to find CTA location for {pnfsid}")
139
+ return
140
+
141
+
142
+ disk_instance_name = rows[0]["disk_instance_name"]
143
+ location = rows[0]["location"]
144
+ storage_class = rows[0]["storage_class_name"]
145
+ storage_group, file_family = storage_class.split("@")[0].split(".")
146
+
147
+ with print_lock:
148
+ logger.error(f"Processing {pnfsid}, {disk_instance_name}, {storage_class}")
149
+
150
+ result = psycopg.select (
151
+ chimera_db,
152
+ "select count(*) from t_locationinfo "
153
+ "where itype = 0 and inumber = "
154
+ "(select inumber from t_inodes where ipnfsid = %s)",
155
+ (pnfsid,)
156
+ )
157
+
158
+ if result[0]["count"] != 0:
159
+ with print_lock:
160
+ logger.error(
161
+ f"File has location in chimera {pnfsid} {storage_class}, Skipping"
162
+ )
163
+ return
164
+
165
+ psycopg.insert(
166
+ chimera_db,
167
+ "insert into t_storageinfo "
168
+ "(inumber, ihsmname, istoragegroup, istoragesubgroup) "
169
+ "values (pnfsid2inumber(%s), 'cta', %s, %s)",
170
+ (pnfsid, storage_group, file_family)
171
+ )
172
+
173
+ psycopg.insert(
174
+ chimera_db,
175
+ "insert into t_locationinfo "
176
+ "(inumber, itype, ipriority, ictime, iatime, istate, ilocation) "
177
+ "values (pnfsid2inumber(%s), 0, 10, now(), now(), 1, %s)",
178
+ (pnfsid, location)
179
+ )
180
+
181
+ admin.execute_admin_command(
182
+ ssh,
183
+ f"\\sl {pnfsid} rep set cached {pnfsid}"
184
+ )
185
+
186
+ admin.execute_admin_command(
187
+ ssh,
188
+ f"\\sl {pnfsid} st kill {pnfsid}"
189
+ )
190
+
191
+
192
+ def main() -> None:
193
+
194
+ """Main function to process files that are already on tape."""
195
+ parser = argparse.ArgumentParser(
196
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
197
+ description="Process files that are already on tape and update their status"
198
+ )
199
+
200
+ parser.add_argument(
201
+ "--cpu-count",
202
+ type=int,
203
+ default=10,
204
+ help="Number of worker processes to spawn"
205
+ )
206
+
207
+ parser.add_argument(
208
+ "--verbose",
209
+ action="store_true",
210
+ help="Enable verbose logging"
211
+ )
212
+
213
+ parser.add_argument(
214
+ "-i", "--instance",
215
+ help='instance',
216
+ default="public_prd",
217
+ metavar="INSTANCE"
218
+ )
219
+
220
+ args = parser.parse_args()
221
+
222
+ # Load configuration
223
+ try:
224
+ config_path = get_config_path()
225
+ if config_path.stat().st_mode != 33152:
226
+ logger.error(
227
+ "Config file %s permissions too permissive, should be 0600",
228
+ config_path
229
+ )
230
+ sys.exit(1)
231
+
232
+ config = yaml.safe_load(config_path.read_text())
233
+ if not config:
234
+ logger.error("Failed to load configuration from %s", config_path)
235
+ sys.exit(1)
236
+
237
+ except (OSError, IOError) as exc:
238
+ if isinstance(exc, FileNotFoundError):
239
+ logger.error("Config file %s does not exist", exc)
240
+ else:
241
+ logger.error("Error reading config file: %s", exc)
242
+ sys.exit(1)
243
+ except yaml.YAMLError as exc:
244
+ logger.error("Error parsing config file: %s", exc)
245
+ sys.exit(1)
246
+
247
+ logger.info("Starting processing")
248
+
249
+ # Start Kerberos ticket refresh worker
250
+ kinit_worker = kerberos.KinitWorker()
251
+ kinit_worker.start()
252
+
253
+ # Set up worker processes
254
+ queue: Queue = Queue(maxsize=100)
255
+ workers = [
256
+ Worker(queue, config)
257
+ for _ in range(args.cpu_count)
258
+ ]
259
+
260
+ for worker in workers:
261
+ worker.start()
262
+
263
+ consumer = KafkaConsumer(config["kafka"].get("topic", "ingest.logs.cta.taped"),
264
+ group_id=config["kafka"].get("group", "dcache-ctananny-prd"), # Required to resume
265
+ bootstrap_servers=config["kafka"].get("bootstrap_servers", "lskafka:9092"),
266
+ auto_offset_reset='latest', # Fallback if no offset is found
267
+ #auto_offset_reset='earliest', # Fallback if no offset is found
268
+ enable_auto_commit=True, # Automatically save progress
269
+ value_deserializer=lambda m: safe_json_deserializer(m))
270
+ #value_deserializer=lambda m: json.loads(m.decode("utf-8")))
271
+
272
+ try:
273
+ for msg in consumer:
274
+
275
+ if os.path.exists("/tmp/STOP"):
276
+ os.unlink("/tmp/STOP")
277
+ break
278
+ # Skip invalid bytes entirely
279
+ #message = msg.value.decode('utf-8', errors='ignore')
280
+ #print(message.keys())
281
+
282
+ # Replace invalid bytes with a placeholder ()
283
+ #decoded_value = message.value.decode('utf-8', errors='replace')
284
+ message = msg.value
285
+ if not message:
286
+ print(f"WHAT {msg}")
287
+ continue
288
+ message_string = message["message"]
289
+ payload = message["cta"]
290
+ if _VO in payload and _INSTANCE in payload:
291
+ vo = payload.get(_VO)
292
+ instance = payload.get(_INSTANCE)
293
+ exception_message = payload.get("exceptionMessageValue")
294
+ if not exception_message:
295
+ continue
296
+ logger.debug(f"Found exception {vo} {instance} {exception_message}")
297
+ if exception_message.find("duplicate key value violates unique constraint") != -1:
298
+ try:
299
+ tuple = exception_message.split("=")[1].split("already")[0].strip()
300
+ disk_instance, pnfsid = [i.strip() for i in re.sub("[()]", "", tuple).split(",")]
301
+
302
+ if disk_instance == args.instance:
303
+ logger.debug(f"Found {pnfsid} {disk_instance}, putting on the queue")
304
+ queue.put(pnfsid)
305
+ except Exception as e:
306
+ logger.info(f"Caught exception {e}")
307
+ pass
308
+ except KeyboardInterrupt:
309
+ logger.info("Interrupted successfully.")
310
+ finally:
311
+ for _ in range(args.cpu_count):
312
+ queue.put(None)
313
+ # Clean up
314
+ kinit_worker.stop()
315
+
316
+
317
+ # consumer = KafkaConsumer(config["kafka"].get("topic", "ingest.dcache.billing"),
318
+ # group_id=config["kafka"].get("group", "dcache-ctananny-prd"), # Required to resume
319
+ # bootstrap_servers=config["kafka"].get("bootstrap_servers", "lskafka:9092"),
320
+ # auto_offset_reset='latest', # Fallback if no offset is found
321
+ # #auto_offset_reset='earliest', # Fallback if no offset is found
322
+ # enable_auto_commit=True, # Automatically save progress
323
+ # value_deserializer=lambda m: safe_json_deserializer(m))
324
+ # #value_deserializer=lambda m: json.loads(m.decode("utf-8")))
325
+ #
326
+ # try:
327
+ # for msg in consumer:
328
+ #
329
+ # if os.path.exists("/tmp/STOP"):
330
+ # break
331
+ # # Skip invalid bytes entirely
332
+ # #message = msg.value.decode('utf-8', errors='ignore')
333
+ # #print(message.keys())
334
+ #
335
+ # # Replace invalid bytes with a placeholder ()
336
+ # #decoded_value = message.value.decode('utf-8', errors='replace')
337
+ # message = msg.value
338
+ # if not message:
339
+ # print(f"WHAT {msg}")
340
+ # continue
341
+ # msgType = message["msgType"]
342
+ # if msgType != "store":
343
+ # continue
344
+ # error_message, error_code = message["status"].values()
345
+ #
346
+ # if error_code == 0:
347
+ # continue
348
+ # storage_info = message["storageInfo"]
349
+ # pnfsid = message["pnfsid"].strip()
350
+ # print(f"{pnfsid} {storage_info} {error_message}")
351
+ # print(message)
352
+ # continue
353
+ # message_string = message["message"]
354
+ # payload = message["cta"]
355
+ # if _VO in payload and _INSTANCE in payload:
356
+ # vo = payload.get(_VO)
357
+ # instance = payload.get(_INSTANCE)
358
+ # exception_message = payload.get("exceptionMessageValue")
359
+ # if not exception_message:
360
+ # continue
361
+ # logger.debug(f"Found exception {vo} {instance} {exception_message}")
362
+ # if exception_message.find("duplicate key value violates unique constraint") != -1:
363
+ # try:
364
+ # tuple = exception_message.split("=")[1].split("already")[0].strip()
365
+ # disk_instance, pnfsid = [i.strip() for i in re.sub("[()]", "", tuple).split(",")]
366
+ #
367
+ # if disk_instance == args.instance:
368
+ # logger.debug(f"Found {pnfsid} {disk_instance}, putting on the queue")
369
+ # queue.put(pnfsid)
370
+ # except Exception as e:
371
+ # logger.info(f"Caught exception {e}")
372
+ # pass
373
+ # except KeyboardInterrupt:
374
+ # logger.info("Interrupted successfully.")
375
+ # finally:
376
+ # for _ in range(args.cpu_count):
377
+ # queue.put(None)
378
+ # # Clean up
379
+ # kinit_worker.stop()
380
+
381
+
382
+ if __name__ == "__main__":
383
+ main()
@@ -0,0 +1,97 @@
1
+ """Install the cta-nanny systemd service unit file."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ # Bundled unit file template
11
+ _UNIT_TEMPLATE = Path(__file__).parent.parent / "config" / "cta-nanny.service"
12
+ _SERVICE_NAME = "cta-nanny.service"
13
+
14
+
15
+ def _find_cta_nanny_exec() -> str:
16
+ """Return the absolute path to the cta-nanny executable."""
17
+ path = shutil.which("cta-nanny")
18
+ if path:
19
+ return path
20
+ # Fallback: same bin/ dir as this Python interpreter
21
+ candidate = Path(sys.executable).parent / "cta-nanny"
22
+ if candidate.exists():
23
+ return str(candidate)
24
+ raise FileNotFoundError(
25
+ "Cannot locate the cta-nanny executable. "
26
+ "Make sure pydcache is installed and its bin/ directory is on PATH."
27
+ )
28
+
29
+
30
+ def _system_unit_dir() -> Path:
31
+ return Path("/etc/systemd/system")
32
+
33
+
34
+ def _user_unit_dir() -> Path:
35
+ xdg = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
36
+ return xdg / "systemd" / "user"
37
+
38
+
39
+ def main() -> None:
40
+ """Install cta-nanny.service and print next steps."""
41
+ is_root = os.getuid() == 0
42
+
43
+ # Resolve real executable path and bake it into the unit file
44
+ try:
45
+ exec_path = _find_cta_nanny_exec()
46
+ except FileNotFoundError as exc:
47
+ print(f"ERROR: {exc}", file=sys.stderr)
48
+ sys.exit(1)
49
+
50
+ unit_text = _UNIT_TEMPLATE.read_text()
51
+ unit_text = unit_text.replace("CTA_NANNY_EXEC", exec_path)
52
+
53
+ if is_root:
54
+ dest_dir = _system_unit_dir()
55
+ systemctl_scope = [] # system scope (default)
56
+ enable_cmd = ["systemctl", "enable", "--now", _SERVICE_NAME]
57
+ else:
58
+ dest_dir = _user_unit_dir()
59
+ systemctl_scope = ["--user"]
60
+ enable_cmd = ["systemctl", "--user", "enable", "--now", _SERVICE_NAME]
61
+
62
+ dest_dir.mkdir(parents=True, exist_ok=True)
63
+ dest = dest_dir / _SERVICE_NAME
64
+
65
+ dest.write_text(unit_text)
66
+ print(f"Installed unit file → {dest}")
67
+
68
+ if is_root:
69
+ # Reload systemd and (optionally) enable the service
70
+ subprocess.run(["systemctl", "daemon-reload"], check=True)
71
+ print("\nUnit file installed. Review it before enabling:\n")
72
+ print(f" nano {dest}\n")
73
+ print("Then enable and start the service:")
74
+ print(f" systemctl enable --now {_SERVICE_NAME}")
75
+ print(f"\nUseful commands:")
76
+ print(f" systemctl status {_SERVICE_NAME}")
77
+ print(f" systemctl restart {_SERVICE_NAME}")
78
+ print(f" journalctl -u {_SERVICE_NAME} -f")
79
+ else:
80
+ subprocess.run(["systemctl", "--user", "daemon-reload"], check=True)
81
+ print("\nUnit file installed (user scope). Review it before enabling:\n")
82
+ print(f" nano {dest}\n")
83
+ print("Then enable and start the service:")
84
+ print(f" systemctl --user enable --now {_SERVICE_NAME}")
85
+ print(f"\nUseful commands:")
86
+ print(f" systemctl --user status {_SERVICE_NAME}")
87
+ print(f" systemctl --user restart {_SERVICE_NAME}")
88
+ print(f" journalctl --user -u {_SERVICE_NAME} -f")
89
+ print(
90
+ "\nNOTE: for a user service to survive logout, run once as root:\n"
91
+ f" loginctl enable-linger {os.environ.get('USER', 'youruser')}"
92
+ )
93
+
94
+
95
+ if __name__ == "__main__":
96
+ main()
97
+
@@ -0,0 +1,3 @@
1
+ """pydcache – collection of dCache Python utilities."""
2
+
3
+ __version__ = "0.1.0"