mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
chore: remove SaaS dev queue repair tooling from feature PR
This commit is contained in:
1 parent
343fc36482
commit
e4a2c433ea
3 files changed
-969
No files matched your search
@@ -1,128 +0,0 @@
|
||||
# SaaS dev workflow consumers (SNP-836)
|
||||
|
||||
This is an **opt-in repair for the existing SaaS dev host**, not a general Cloud
|
||||
worker configuration or a Staging/Production deployment change. It never starts,
|
||||
stops, scales, or restarts containers and never enqueues, deletes, or consumes jobs.
|
||||
|
||||
## Why
|
||||
|
||||
The host's `/home/ubuntu/langgenius/docker-compose.yaml` explicitly sets the main
|
||||
worker's `CELERY_QUEUES`. This overrides the image defaults and omits
|
||||
`workflow_professional`, `workflow_team`, and `workflow_sandbox`. An allowed
|
||||
Webhook can therefore return HTTP 200 while its workflow remains queued.
|
||||
|
||||
The repair appends only these three queues to the existing main worker setting.
|
||||
It preserves the existing ten queues, their order, comments, quoting, all other
|
||||
file bytes, file owner/group/mode, the two worker replicas, and every other
|
||||
service. It intentionally rejects a different or ambiguous baseline for review.
|
||||
|
||||
Do **not** remove `CELERY_QUEUES`, enable all default queues, or change the shared
|
||||
image entrypoint. Dedicated Cloud workers elsewhere must not start competing for
|
||||
other queues. In the 2026-09-20 dev audit, `schedule_poller`, `schedule_executor`,
|
||||
and `trigger_refresh_publisher` each already contained more than 51,000 messages.
|
||||
Those queues and their data are outside this repair's scope.
|
||||
|
||||
## Prerequisites and dry-run
|
||||
|
||||
Requirements: Python 3.10+, PyYAML, Docker Compose v2, and access to the existing
|
||||
`langgenius-api-1` and two main worker containers. PyYAML was already installed on
|
||||
the audited host; no new package or credential is required. The existing API
|
||||
image supplies its Redis client. Resolved Compose configuration and broker
|
||||
credentials are processed only in memory, never printed or saved by the repair.
|
||||
|
||||
From the repository root, run the script over SSH. Omitting `--apply` is a
|
||||
dry-run: the target and services are unchanged. A temporary validation candidate
|
||||
in the same directory is removed after validation.
|
||||
|
||||
```sh
|
||||
ssh -T -i /Users/yansongzhang/.ssh/id_rsa -o IdentitiesOnly=yes \
|
||||
ubuntu@ec2-100-30-236-187.compute-1.amazonaws.com \
|
||||
'python3 - --target saas-dev' \
|
||||
< .github/scripts/saas-dev/fix_workflow_queues.py
|
||||
```
|
||||
|
||||
Every dry-run and apply performs these fail-closed preflight checks:
|
||||
|
||||
- The target is precisely `/home/ubuntu/langgenius/docker-compose.yaml`, a regular
|
||||
file, not a symlink or hardlink. `services.worker.environment.CELERY_QUEUES`
|
||||
occurs exactly once and is an explicit, single-line string.
|
||||
- The resolved candidate differs only in that one environment value. The worker
|
||||
is Cloud, and `CELERY_WORKER_QUEUES` does not override the intended setting.
|
||||
- Exactly two running main worker containers match the existing Compose project
|
||||
and file. Their real PID 1 `-Q` arguments match their environment and contain
|
||||
only the reviewed original queues and the three approved workflow queues.
|
||||
- Compose API/worker and running API/worker broker URLs match exactly in memory.
|
||||
Redis prefixes, Sentinel, custom TLS, or a different topology fail closed;
|
||||
review these explicitly instead of checking the wrong broker/namespace.
|
||||
- All three workflow queues, including Redis priority suffix keys, have **zero**
|
||||
messages. Both broker `unacked` and `unacked_index` must also be **zero**.
|
||||
|
||||
If any guard fails, stop and inspect the cause. Do not purge queues, remove
|
||||
messages, restart consumers, or weaken the guard to make the repair pass. A
|
||||
nonzero `unacked` count may be legitimate in-flight work; wait for it to finish
|
||||
and rerun the read-only check. The check is a snapshot, not a lock on producers;
|
||||
recheck immediately before the planned worker deployment.
|
||||
|
||||
## Apply and deploy
|
||||
|
||||
Review the dry-run's old/new queue lists and `before_sha256`. Apply requires that
|
||||
exact SHA256 plus the explicit target and apply flag:
|
||||
|
||||
```sh
|
||||
ssh -T -i /Users/yansongzhang/.ssh/id_rsa -o IdentitiesOnly=yes \
|
||||
ubuntu@ec2-100-30-236-187.compute-1.amazonaws.com \
|
||||
'python3 - --target saas-dev --apply --expected-sha256 REVIEWED_SHA256' \
|
||||
< .github/scripts/saas-dev/fix_workflow_queues.py
|
||||
```
|
||||
|
||||
The script validates first, verifies the original content again, writes an exact
|
||||
same-directory backup with the original owner/group/mode, then atomically
|
||||
replaces the target and verifies the resulting bytes. It prints the concrete
|
||||
backup path, hashes, queue names, and `services_restarted: false`. Treat the
|
||||
backup as sensitive: it contains the original Compose configuration and must not
|
||||
be committed or attached to an issue. Record only its path and hash in the
|
||||
deployment report. An already-patched file is a no-op without another backup.
|
||||
|
||||
This repository's `deploy-saas.yml` executes the configured
|
||||
`SSH_SCRIPT_SAAS_DEV`; the audited script pulls and recreates the normal API,
|
||||
Web, main worker, priority worker, and plugin worker services from this existing
|
||||
host Compose file. It does not replace that file, so the queue change persists
|
||||
through subsequent `deploy/saas` deployments. Keep the repair and API changes on
|
||||
the original PR and synchronize to `deploy/saas` through the normal reviewed
|
||||
workflow. Do not change the deployment script variable, pipeline settings,
|
||||
Staging/Production manifests, or unrelated service configurations for this fix.
|
||||
|
||||
## Regression checklist
|
||||
|
||||
1. After the normal deployment, inspect the two main worker PID 1 queue arguments:
|
||||
the original ten queues plus exactly the three workflow queues must be present;
|
||||
replica count and other workers' queue arguments must be unchanged.
|
||||
2. Create a disposable, no-model Webhook → End workflow. Bind a denying IP policy:
|
||||
a request must return 403 and produce no workflow run or trigger log.
|
||||
3. Permit only the test source IP and send **one** Webhook request. HTTP 200 alone
|
||||
is insufficient: verify the trigger log and workflow run reach `succeeded`,
|
||||
with zero model tokens and no residual workflow queue message.
|
||||
4. Restore a denying policy, verify 403 and unchanged run count; confirm the
|
||||
existing Web/API/MCP access-control regressions remain correct.
|
||||
5. Clean up only the disposable test resources through their normal lifecycle.
|
||||
Do not clear shared queues, historical messages, or unrelated user data.
|
||||
6. Confirm none of the excluded schedule/refresh queues gained a consumer. Their
|
||||
lengths can grow naturally while the beat process continues producing tasks;
|
||||
growth is not a reason to consume or purge them during this fix.
|
||||
|
||||
If rolling back, first check for running/pending workflow work and obtain approval
|
||||
for any service operation. Inspect the exact backup path printed by the apply;
|
||||
verify that the live file still has the recorded `after_sha256` before restoring
|
||||
it, so another person's intervening edits are not overwritten. Restore the
|
||||
backup with owner/group/mode preserved, validate Compose without printing its
|
||||
secret-bearing output, and use the normal worker deployment procedure. This
|
||||
script deliberately provides no automatic destructive rollback or queue purge.
|
||||
|
||||
## Local tests
|
||||
|
||||
```sh
|
||||
uv run --project api pytest api/tests/unit_tests/commands/test_fix_workflow_queues.py
|
||||
```
|
||||
|
||||
Tests use temporary synthetic Compose files and mocked broker responses. They
|
||||
do not contact dev, invoke Docker, or use real credentials.
|
||||
@@ -1,445 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Opt-in, SaaS-dev-only repair for SNP-836; never starts a consumer.
|
||||
|
||||
Requires Python 3.10+, PyYAML, Docker Compose v2, and the existing API container.
|
||||
The host's PyYAML is used only to locate YAML scalar spans; the file is not dumped
|
||||
or reformatted, and neither resolved configuration nor credentials are printed.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
import yaml
|
||||
from yaml.nodes import MappingNode, ScalarNode
|
||||
|
||||
TARGET = Path("/home/ubuntu/langgenius/docker-compose.yaml")
|
||||
BASE_QUEUES = (
|
||||
"dataset",
|
||||
"mail",
|
||||
"ops_trace",
|
||||
"app_deletion",
|
||||
"pipeline",
|
||||
"workflow_storage",
|
||||
"conversation",
|
||||
"workflow_based_app_execution",
|
||||
"evaluation",
|
||||
"new_agent_beta",
|
||||
)
|
||||
WORKFLOW_QUEUES = ("workflow_professional", "workflow_team", "workflow_sandbox")
|
||||
|
||||
|
||||
class UnsafeConfiguration(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def field(node, name):
|
||||
if not isinstance(node, MappingNode):
|
||||
raise UnsafeConfiguration("Expected an explicit YAML mapping")
|
||||
matches = [
|
||||
(key, value)
|
||||
for key, value in node.value
|
||||
if isinstance(key, ScalarNode) and key.value == name
|
||||
]
|
||||
if len(matches) != 1:
|
||||
raise UnsafeConfiguration(f"Expected exactly one explicit {name} field")
|
||||
key, value = matches[0]
|
||||
if value.start_mark.index < key.end_mark.index:
|
||||
raise UnsafeConfiguration(f"Aliased {name} field requires manual review")
|
||||
return value
|
||||
|
||||
|
||||
def plan_patch(before):
|
||||
"""Change only the unique, direct worker queue scalar; return UTF-8 bytes."""
|
||||
text = before.decode("utf-8")
|
||||
try:
|
||||
root = yaml.compose(text, Loader=yaml.SafeLoader)
|
||||
except yaml.YAMLError:
|
||||
raise UnsafeConfiguration(
|
||||
"Invalid YAML; details suppressed to protect credentials"
|
||||
) from None
|
||||
worker = field(field(root, "services"), "worker")
|
||||
environment = field(worker, "environment")
|
||||
queue = field(environment, "CELERY_QUEUES")
|
||||
if not isinstance(queue, ScalarNode) or queue.tag != "tag:yaml.org,2002:str":
|
||||
raise UnsafeConfiguration("CELERY_QUEUES must be an explicit string")
|
||||
if (
|
||||
queue.style not in (None, "'", '"')
|
||||
or queue.start_mark.line != queue.end_mark.line
|
||||
):
|
||||
raise UnsafeConfiguration(
|
||||
"Multiline or non-scalar queue syntax requires manual review"
|
||||
)
|
||||
# Aliases can refer to a scalar outside this service. Never mutate its source.
|
||||
if not (
|
||||
worker.start_mark.index
|
||||
< queue.start_mark.index
|
||||
< queue.end_mark.index
|
||||
<= worker.end_mark.index
|
||||
):
|
||||
raise UnsafeConfiguration("Queue aliases outside worker require manual review")
|
||||
current = queue.value.split(",")
|
||||
if any(not re.fullmatch(r"[a-z_]+", item) for item in current):
|
||||
raise UnsafeConfiguration(
|
||||
"Queue interpolation, whitespace, or unsupported syntax requires review"
|
||||
)
|
||||
if len(current) != len(set(current)):
|
||||
raise UnsafeConfiguration("Duplicate queue names require manual review")
|
||||
if not set(BASE_QUEUES).issubset(current) or set(current) - set(
|
||||
BASE_QUEUES + WORKFLOW_QUEUES
|
||||
):
|
||||
raise UnsafeConfiguration(
|
||||
"Worker queues differ from the reviewed SaaS dev baseline"
|
||||
)
|
||||
missing = [item for item in WORKFLOW_QUEUES if item not in current]
|
||||
if not missing:
|
||||
return before, current, current
|
||||
updated = current + missing
|
||||
raw = text[queue.start_mark.index : queue.end_mark.index]
|
||||
quoted = queue.style or ""
|
||||
# Refuse anchors, tags, escapes and aliases instead of rewriting them.
|
||||
if raw != quoted + queue.value + quoted:
|
||||
raise UnsafeConfiguration("Decorated queue scalar requires manual review")
|
||||
replacement = quoted + ",".join(updated) + quoted
|
||||
after = (
|
||||
text[: queue.start_mark.index] + replacement + text[queue.end_mark.index :]
|
||||
).encode("utf-8")
|
||||
if plan_patch(after)[0] != after:
|
||||
raise UnsafeConfiguration("Candidate queue patch is not idempotent")
|
||||
return after, current, updated
|
||||
|
||||
|
||||
def sha256(content):
|
||||
return hashlib.sha256(content).hexdigest()
|
||||
|
||||
|
||||
def safe_command(command, *, input_text=None):
|
||||
result = subprocess.run(
|
||||
command, input=input_text, text=True, capture_output=True, timeout=45
|
||||
)
|
||||
if result.returncode:
|
||||
raise UnsafeConfiguration(
|
||||
"Read-only Docker validation failed; output suppressed to protect credentials"
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def resolved_config(path):
|
||||
return json.loads(
|
||||
safe_command(
|
||||
[
|
||||
"docker",
|
||||
"compose",
|
||||
"--project-directory",
|
||||
str(TARGET.parent),
|
||||
"-f",
|
||||
str(path),
|
||||
"config",
|
||||
"--format",
|
||||
"json",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def validate_resolved(before, after, old_queues, new_queues):
|
||||
"""Catch inherited overrides, YAML ambiguity, and accidental unrelated edits."""
|
||||
environment = before["services"]["worker"]["environment"]
|
||||
if environment.get("DEPLOYMENT_EDITION") != "CLOUD":
|
||||
raise UnsafeConfiguration("Expected CLOUD SaaS dev worker")
|
||||
if environment.get("CELERY_WORKER_QUEUES"):
|
||||
raise UnsafeConfiguration("CELERY_WORKER_QUEUES overrides CELERY_QUEUES")
|
||||
if environment.get("CELERY_QUEUES") != ",".join(old_queues):
|
||||
raise UnsafeConfiguration(
|
||||
"Resolved queues differ from the explicit queue scalar"
|
||||
)
|
||||
expected = copy.deepcopy(before)
|
||||
expected["services"]["worker"]["environment"]["CELERY_QUEUES"] = ",".join(
|
||||
new_queues
|
||||
)
|
||||
if after != expected:
|
||||
raise UnsafeConfiguration(
|
||||
"Resolved candidate changes more than worker CELERY_QUEUES"
|
||||
)
|
||||
|
||||
|
||||
BROKER_CHECK = r"""
|
||||
import json, os
|
||||
from redis import Redis
|
||||
try:
|
||||
if os.environ.get("REDIS_KEY_PREFIX"):
|
||||
raise ValueError("Unsupported broker namespace")
|
||||
client = Redis.from_url(os.environ["CELERY_BROKER_URL"], socket_timeout=10)
|
||||
counts = {}
|
||||
for queue in ("workflow_professional", "workflow_team", "workflow_sandbox"):
|
||||
name = queue.encode()
|
||||
keys = {name, *client.scan_iter(match=name + b"\x06\x16*")}
|
||||
counts[queue] = sum(client.llen(key) for key in keys)
|
||||
counts["unacked"] = client.hlen("unacked")
|
||||
counts["unacked_index"] = client.zcard("unacked_index")
|
||||
print(json.dumps(counts))
|
||||
except Exception:
|
||||
print("Broker check failed; details suppressed", file=__import__("sys").stderr)
|
||||
raise SystemExit(1)
|
||||
"""
|
||||
|
||||
|
||||
def validate_broker_environments(config, runtime_environments):
|
||||
"""Compare credentials only in memory; unsupported broker layouts fail shut."""
|
||||
expected = config["services"]["worker"]["environment"].get("CELERY_BROKER_URL")
|
||||
if not isinstance(expected, str) or not expected.startswith("redis://"):
|
||||
raise UnsafeConfiguration("Only the reviewed direct Redis broker is supported")
|
||||
environments = [
|
||||
config["services"][service]["environment"] for service in ("api", "worker")
|
||||
]
|
||||
for environment in environments + list(runtime_environments):
|
||||
if environment.get("CELERY_BROKER_URL") != expected:
|
||||
raise UnsafeConfiguration(
|
||||
"API/worker broker mismatch; refusing an unreliable backlog check"
|
||||
)
|
||||
if environment.get("REDIS_KEY_PREFIX"):
|
||||
raise UnsafeConfiguration(
|
||||
"Redis key prefixes require a separate namespace-aware review"
|
||||
)
|
||||
for setting in ("CELERY_USE_SENTINEL", "BROKER_USE_SSL"):
|
||||
if str(environment.get(setting, "false")).lower() not in (
|
||||
"",
|
||||
"false",
|
||||
"none",
|
||||
"0",
|
||||
):
|
||||
raise UnsafeConfiguration(
|
||||
"Redis Sentinel or custom TLS requires separate review"
|
||||
)
|
||||
|
||||
|
||||
def check_runtime_broker(config):
|
||||
workers = safe_command(
|
||||
[
|
||||
"docker",
|
||||
"ps",
|
||||
"-q",
|
||||
"--filter",
|
||||
"label=com.docker.compose.project=langgenius",
|
||||
"--filter",
|
||||
"label=com.docker.compose.service=worker",
|
||||
]
|
||||
).split()
|
||||
if (
|
||||
len(workers) != 2
|
||||
or config["services"]["worker"].get("deploy", {}).get("replicas") != 2
|
||||
):
|
||||
raise UnsafeConfiguration("Expected the reviewed two-worker SaaS dev topology")
|
||||
containers = json.loads(
|
||||
safe_command(["docker", "inspect", "langgenius-api-1", *workers])
|
||||
)
|
||||
environments = []
|
||||
for container in containers:
|
||||
labels = container["Config"].get("Labels", {})
|
||||
service = labels.get("com.docker.compose.service")
|
||||
if (
|
||||
not container["State"]["Running"]
|
||||
or service not in ("api", "worker")
|
||||
or labels.get("com.docker.compose.project") != "langgenius"
|
||||
or labels.get("com.docker.compose.project.config_files") != str(TARGET)
|
||||
):
|
||||
raise UnsafeConfiguration(
|
||||
"Running containers do not match the reviewed Compose deployment"
|
||||
)
|
||||
environment = dict(item.split("=", 1) for item in container["Config"]["Env"])
|
||||
environments.append(environment)
|
||||
if service == "worker":
|
||||
if environment.get("CELERY_WORKER_QUEUES"):
|
||||
raise UnsafeConfiguration(
|
||||
"Runtime CELERY_WORKER_QUEUES overrides the reviewed queue setting"
|
||||
)
|
||||
argv = safe_command(
|
||||
["docker", "exec", container["Id"], "cat", "/proc/1/cmdline"]
|
||||
).split("\0")
|
||||
if (
|
||||
argv.count("-Q") != 1
|
||||
or argv.index("-Q") + 1 >= len(argv)
|
||||
or argv[argv.index("-Q") + 1] != environment.get("CELERY_QUEUES")
|
||||
):
|
||||
raise UnsafeConfiguration(
|
||||
"Runtime worker argv and queue environment differ"
|
||||
)
|
||||
queues = environment["CELERY_QUEUES"].split(",")
|
||||
if set(queues) - set(BASE_QUEUES + WORKFLOW_QUEUES) or not set(
|
||||
BASE_QUEUES
|
||||
).issubset(queues):
|
||||
raise UnsafeConfiguration(
|
||||
"Runtime queues differ from the reviewed baseline"
|
||||
)
|
||||
validate_broker_environments(config, environments)
|
||||
|
||||
|
||||
def broker_check(config):
|
||||
check_runtime_broker(config)
|
||||
counts = json.loads(
|
||||
safe_command(
|
||||
[
|
||||
"docker",
|
||||
"exec",
|
||||
"-i",
|
||||
"langgenius-api-1",
|
||||
"/app/api/.venv/bin/python",
|
||||
"-",
|
||||
],
|
||||
input_text=BROKER_CHECK,
|
||||
)
|
||||
)
|
||||
expected = set(WORKFLOW_QUEUES) | {"unacked", "unacked_index"}
|
||||
if set(counts) != expected or any(
|
||||
type(value) is not int or value != 0 for value in counts.values()
|
||||
):
|
||||
raise UnsafeConfiguration(
|
||||
"Workflow backlog or unacked tasks exist; do not start new consumers"
|
||||
)
|
||||
return counts
|
||||
|
||||
|
||||
def regular_file(path):
|
||||
metadata = path.lstat()
|
||||
if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1:
|
||||
raise UnsafeConfiguration(
|
||||
"Target must be a regular non-symlink, non-hardlinked file"
|
||||
)
|
||||
return metadata
|
||||
|
||||
|
||||
def private_copy(path, content, metadata, prefix):
|
||||
fd, name = tempfile.mkstemp(prefix=prefix, dir=path.parent)
|
||||
copy_path = Path(name)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as handle:
|
||||
os.fchown(handle.fileno(), metadata.st_uid, metadata.st_gid)
|
||||
os.fchmod(handle.fileno(), stat.S_IMODE(metadata.st_mode))
|
||||
handle.write(content)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
return copy_path
|
||||
except BaseException:
|
||||
copy_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def apply_patch(path, before, after, expected_sha256, validate, preflight):
|
||||
"""No service operations. Keep an exact backup and fail closed on drift."""
|
||||
import fcntl
|
||||
|
||||
if sha256(before) != expected_sha256:
|
||||
raise UnsafeConfiguration("Expected SHA256 does not match the reviewed dry-run")
|
||||
metadata = regular_file(path)
|
||||
with path.open("rb") as original:
|
||||
fcntl.flock(original.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
if path.read_bytes() != before:
|
||||
raise UnsafeConfiguration("Compose changed since the dry-run")
|
||||
candidate = private_copy(path, after, metadata, ".snp836-candidate-")
|
||||
try:
|
||||
validate(candidate)
|
||||
preflight()
|
||||
if before == after:
|
||||
return None
|
||||
backup = private_copy(
|
||||
path, before, metadata, "docker-compose.yaml.snp836-backup-"
|
||||
)
|
||||
if backup.read_bytes() != before:
|
||||
raise UnsafeConfiguration("Backup verification failed")
|
||||
if path.read_bytes() != before or path.stat().st_ino != metadata.st_ino:
|
||||
raise UnsafeConfiguration(
|
||||
"Compose changed before replacement; backup retained"
|
||||
)
|
||||
os.replace(candidate, path)
|
||||
if path.read_bytes() != after:
|
||||
raise UnsafeConfiguration(
|
||||
f"Post-write verification failed; restore backup {backup}"
|
||||
)
|
||||
return backup
|
||||
finally:
|
||||
candidate.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--target", required=True, choices=["saas-dev"])
|
||||
parser.add_argument(
|
||||
"--apply",
|
||||
action="store_true",
|
||||
help="Write only the reviewed queue change; never restart",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--expected-sha256", help="Required with --apply; copy from a fresh dry-run"
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
if args.apply and not args.expected_sha256:
|
||||
parser.error("--apply requires --expected-sha256")
|
||||
try:
|
||||
metadata = regular_file(TARGET)
|
||||
before = TARGET.read_bytes()
|
||||
after, old_queues, new_queues = plan_patch(before)
|
||||
resolved_before = resolved_config(TARGET)
|
||||
|
||||
def validate(candidate):
|
||||
validate_resolved(
|
||||
resolved_before, resolved_config(candidate), old_queues, new_queues
|
||||
)
|
||||
|
||||
if args.apply:
|
||||
backup = apply_patch(
|
||||
TARGET,
|
||||
before,
|
||||
after,
|
||||
args.expected_sha256,
|
||||
validate,
|
||||
lambda: broker_check(resolved_before),
|
||||
)
|
||||
else:
|
||||
# Compose reads secrets, so keep even a temporary candidate private.
|
||||
candidate = private_copy(TARGET, after, metadata, ".snp836-candidate-")
|
||||
try:
|
||||
validate(candidate)
|
||||
broker_check(resolved_before)
|
||||
finally:
|
||||
candidate.unlink(missing_ok=True)
|
||||
backup = None
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"target": "saas-dev",
|
||||
"path": str(TARGET),
|
||||
"mode": "apply" if args.apply else "dry-run",
|
||||
"changed": before != after,
|
||||
"before_sha256": sha256(before),
|
||||
"after_sha256": sha256(after),
|
||||
"queues_before": old_queues,
|
||||
"queues_after": new_queues,
|
||||
"backup": str(backup) if backup else None,
|
||||
"services_restarted": False,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
except (
|
||||
UnsafeConfiguration,
|
||||
OSError,
|
||||
KeyError,
|
||||
ValueError,
|
||||
subprocess.SubprocessError,
|
||||
) as exc:
|
||||
# Do not leak subprocess output, YAML snippets, broker URLs, or environment.
|
||||
message = (
|
||||
str(exc) if isinstance(exc, UnsafeConfiguration) else type(exc).__name__
|
||||
)
|
||||
parser.exit(1, f"Refused: {message}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user