gowalk-cicd 1.0.187 → 1.0.189
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.
- package/README.md +3 -1
- package/backend-action/memory_agent.py +229 -0
- package/backend-action/native_profile.py +4 -2
- package/backend-action/runtime_host.py +26 -3
- package/docs/BACKEND_MEMORY.md +85 -0
- package/docs/CONFIGURATION.md +3 -0
- package/package.json +2 -1
- package/src/config/settings.mjs +5 -2
- package/src/config/workflows.mjs +3 -2
package/README.md
CHANGED
|
@@ -341,7 +341,9 @@ Other factory containers plus the proposed stack may reserve at most 70% of phys
|
|
|
341
341
|
10% (and 512 MiB) stays available after additional reservations. One-minute load above twice the CPU
|
|
342
342
|
count or full memory pressure of 1% refuses a start. Starts share a host lock (120-second acquisition
|
|
343
343
|
limit); image builds stay parallel. A failed admission emits `backend_runtime_refused` with a fixed
|
|
344
|
-
`gowalk-cicd/backend-runtime-refused.v1 code=…` and leaves existing containers running.
|
|
344
|
+
`gowalk-cicd/backend-runtime-refused.v1 code=…` and leaves existing containers running. A shared host's
|
|
345
|
+
[memory agent](docs/BACKEND_MEMORY.md) keeps running reservations at measured use, and a redeploy is charged only
|
|
346
|
+
for growth beyond the reservations its project already holds.
|
|
345
347
|
|
|
346
348
|
An operator-managed `/opt/gowalk-backend-runtime/<app>.json`, outside the application mirror, may set
|
|
347
349
|
`schema: "gowalk-cicd/backend-runtime.v1"`, `disabled: true`, `remove_services: ["db"]`, and per-service
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Keep running backend reservations at measured use and compress idle app memory into zswap."""
|
|
2
|
+
import argparse
|
|
3
|
+
import calendar
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import time
|
|
11
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from native_profile import running_native
|
|
15
|
+
from runtime_host import AGENT_STATE
|
|
16
|
+
from runtime_model import MIB
|
|
17
|
+
|
|
18
|
+
SCHEMA = "gowalk-cicd/backend-memory.v1"
|
|
19
|
+
APPS = "/opt/gowalk-backends/"
|
|
20
|
+
PROC = Path("/proc")
|
|
21
|
+
CGROUP = Path("/sys/fs/cgroup")
|
|
22
|
+
ZSWAP = Path("/sys/module/zswap/parameters/enabled")
|
|
23
|
+
SETTLE_SECONDS = 600
|
|
24
|
+
DEFAULT, FLOOR, STEP, HYSTERESIS = 64 * MIB, 16 * MIB, 4 * MIB, 8 * MIB
|
|
25
|
+
DATASTORES = ("postgres", "postgis", "timescale", "redis", "valkey", "keydb", "mysql", "mariadb", "mongo",
|
|
26
|
+
"memcached")
|
|
27
|
+
TEMPLATE = ('{"id":{{json .Id}},"name":{{json .Name}},"running":{{.State.Running}},"pid":{{.State.Pid}},'
|
|
28
|
+
'"started":{{json .State.StartedAt}},"image":{{json .Config.Image}},"cmd":{{json .Config.Cmd}},'
|
|
29
|
+
'"labels":{{json .Config.Labels}},"reservation":{{.HostConfig.MemoryReservation}},'
|
|
30
|
+
'"limit":{{.HostConfig.Memory}}}')
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def discover():
|
|
34
|
+
"""Every existing container ID (a stopped one keeps its reservation), and the running ones in admission's scope."""
|
|
35
|
+
try:
|
|
36
|
+
ids = subprocess.run(["docker", "ps", "-a", "-q", "--no-trunc"], capture_output=True, text=True, timeout=30,
|
|
37
|
+
check=True).stdout.split()
|
|
38
|
+
# Unchecked: a container removed between both calls is only missing from inspect's output.
|
|
39
|
+
output = subprocess.run(["docker", "inspect", "--format", TEMPLATE, *ids], capture_output=True, text=True,
|
|
40
|
+
timeout=60).stdout if ids else ""
|
|
41
|
+
except (OSError, subprocess.SubprocessError):
|
|
42
|
+
raise ValueError("docker_unavailable") from None
|
|
43
|
+
containers = []
|
|
44
|
+
for line in output.splitlines():
|
|
45
|
+
container = json.loads(line)
|
|
46
|
+
labels = container.pop("labels") or {}
|
|
47
|
+
if container.pop("running") and labels.get("com.docker.compose.project.working_dir", "").startswith(APPS):
|
|
48
|
+
containers.append(dict(container, project=labels.get("com.docker.compose.project", ""),
|
|
49
|
+
service=labels.get("com.docker.compose.service", "")))
|
|
50
|
+
return ids, containers
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def meminfo():
|
|
54
|
+
pairs = (line.split(":", 1) for line in (PROC / "meminfo").read_text().splitlines())
|
|
55
|
+
return {key: int(value.split()[0]) * 1024 for key, value in pairs}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def cgroup_dir(container):
|
|
59
|
+
for line in (PROC / str(container["pid"]) / "cgroup").read_text().splitlines():
|
|
60
|
+
# A reused PID names another cgroup, which is never measured or reclaimed.
|
|
61
|
+
if line.startswith("0::") and container["id"] in line:
|
|
62
|
+
return CGROUP / line[3:].lstrip("/")
|
|
63
|
+
raise FileNotFoundError(container["id"]) # the container's process has exited
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def measure(directory, store):
|
|
67
|
+
"""The working set, anon and pressure; a datastore's working set also leaves out its active file cache."""
|
|
68
|
+
stat = dict(line.split() for line in (directory / "memory.stat").read_text().splitlines())
|
|
69
|
+
cache = int(stat["inactive_file"]) + (int(stat["active_file"]) if store else 0)
|
|
70
|
+
working_set = int((directory / "memory.current").read_text()) - cache
|
|
71
|
+
pressure = 0.0
|
|
72
|
+
for line in (directory / "memory.pressure").read_text().splitlines():
|
|
73
|
+
if line.startswith("some "):
|
|
74
|
+
pressure = float(dict(part.split("=") for part in line.split()[1:])["avg60"])
|
|
75
|
+
return working_set, int(stat["anon"]), pressure
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def next_reservation(current, working_set, limit):
|
|
79
|
+
"""Working set plus 20% in 4 MiB steps, 16 MiB to the limit; rise at once, fall by 8 MiB or more."""
|
|
80
|
+
target = max(FLOOR, -(-working_set * 6 // (5 * STEP)) * STEP)
|
|
81
|
+
target = min(target, limit) if limit > 0 else target
|
|
82
|
+
return target if not current or target > current or target <= current - HYSTERESIS else current
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def datastore(container):
|
|
86
|
+
names = " ".join([container["image"], container["service"],
|
|
87
|
+
os.path.basename((container["cmd"] or [""])[0])]).lower()
|
|
88
|
+
return container["service"].lower() in ("db", "database") or any(word in names for word in DATASTORES)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def record(container_id, reservation):
|
|
92
|
+
"""Keep the reservation admission counted before the agent first changes it; a record is never overwritten."""
|
|
93
|
+
path = AGENT_STATE / container_id
|
|
94
|
+
if path.exists():
|
|
95
|
+
return
|
|
96
|
+
AGENT_STATE.mkdir(mode=0o755, exist_ok=True)
|
|
97
|
+
descriptor, temporary = tempfile.mkstemp(prefix=".record-", dir=str(AGENT_STATE))
|
|
98
|
+
try:
|
|
99
|
+
with os.fdopen(descriptor, "w") as stream:
|
|
100
|
+
stream.write("%d\n" % reservation)
|
|
101
|
+
stream.flush()
|
|
102
|
+
os.fsync(stream.fileno()) # Docker persists the update: a crash must not leave an empty record
|
|
103
|
+
os.chmod(temporary, 0o644)
|
|
104
|
+
os.replace(temporary, str(path))
|
|
105
|
+
except OSError:
|
|
106
|
+
os.unlink(temporary)
|
|
107
|
+
raise
|
|
108
|
+
directory = os.open(str(AGENT_STATE), os.O_RDONLY)
|
|
109
|
+
try:
|
|
110
|
+
os.fsync(directory)
|
|
111
|
+
finally:
|
|
112
|
+
os.close(directory)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def prune(ids):
|
|
116
|
+
"""Forget the records of containers that no longer exist; no other name is ever touched."""
|
|
117
|
+
for name in os.listdir(str(AGENT_STATE)) if AGENT_STATE.is_dir() else []:
|
|
118
|
+
if re.fullmatch("[0-9a-f]{64}", name) and name not in ids:
|
|
119
|
+
(AGENT_STATE / name).unlink()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def update_reservation(container_id, reservation):
|
|
123
|
+
subprocess.run(["docker", "update", "--memory-reservation", str(reservation), container_id],
|
|
124
|
+
capture_output=True, timeout=30, check=True)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def reclaim(directory, mib):
|
|
128
|
+
descriptor = os.open(str(directory / "memory.reclaim"), os.O_WRONLY)
|
|
129
|
+
try:
|
|
130
|
+
os.write(descriptor, b"%dM" % mib)
|
|
131
|
+
finally:
|
|
132
|
+
os.close(descriptor)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def handle(container, now, zswap, dry_run):
|
|
136
|
+
"""Measure one container first, then record and update its reservation and reclaim its cold memory."""
|
|
137
|
+
entry = {"name": container["name"].lstrip("/"), "id": container["id"][:12], "project": container["project"],
|
|
138
|
+
"service": container["service"]}
|
|
139
|
+
current, store = container["reservation"], datastore(container)
|
|
140
|
+
try:
|
|
141
|
+
started = calendar.timegm(time.strptime(container["started"][:19], "%Y-%m-%dT%H:%M:%S"))
|
|
142
|
+
if now - started < SETTLE_SECONDS: # startup imports have not settled yet
|
|
143
|
+
return dict(entry, settling=True)
|
|
144
|
+
directory = cgroup_dir(container)
|
|
145
|
+
working_set, anon, pressure = measure(directory, store)
|
|
146
|
+
except (FileNotFoundError, ProcessLookupError):
|
|
147
|
+
return dict(entry, exited=True)
|
|
148
|
+
except (OSError, ValueError, KeyError):
|
|
149
|
+
return dict(entry, error="measure_failed")
|
|
150
|
+
target = next_reservation(current, working_set, container["limit"])
|
|
151
|
+
if store and target <= (current or DEFAULT):
|
|
152
|
+
target = current # datastores only rise: a quiet moment must not cut the cache they refill under load
|
|
153
|
+
amount = 0 if not zswap or pressure >= 0.5 or store else anon // 4 // MIB
|
|
154
|
+
entry.update(working_set_mib=working_set // MIB, reservation_mib=current // MIB, target_mib=target // MIB,
|
|
155
|
+
reclaim_mib=amount, updated=target != current)
|
|
156
|
+
if dry_run:
|
|
157
|
+
return entry
|
|
158
|
+
if target != current:
|
|
159
|
+
error = "record_failed" # admission credits the declared reservation only from this record
|
|
160
|
+
try:
|
|
161
|
+
record(container["id"], current or DEFAULT)
|
|
162
|
+
error = "reservation_update_failed"
|
|
163
|
+
update_reservation(container["id"], target)
|
|
164
|
+
except (OSError, subprocess.SubprocessError):
|
|
165
|
+
entry.update(updated=False, error=error)
|
|
166
|
+
if amount:
|
|
167
|
+
try:
|
|
168
|
+
reclaim(directory, amount)
|
|
169
|
+
except OSError: # EAGAIN: the kernel reclaimed less than requested
|
|
170
|
+
entry["partial"] = True
|
|
171
|
+
return entry
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def run(dry_run, now=None):
|
|
175
|
+
ids, containers = discover()
|
|
176
|
+
now = time.time() if now is None else now
|
|
177
|
+
try:
|
|
178
|
+
zswap = meminfo().get("SwapTotal", 0) > 0 and ZSWAP.read_text().strip() == "Y"
|
|
179
|
+
except OSError:
|
|
180
|
+
zswap = False
|
|
181
|
+
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
182
|
+
entries = list(pool.map(lambda container: handle(container, now, zswap, dry_run), containers))
|
|
183
|
+
reserved = sum((entry["target_mib"] * MIB if entry.get("updated") else container["reservation"]) or DEFAULT
|
|
184
|
+
for container, entry in zip(containers, entries))
|
|
185
|
+
if not dry_run:
|
|
186
|
+
try:
|
|
187
|
+
prune(set(ids))
|
|
188
|
+
except OSError:
|
|
189
|
+
entries.append({"error": "prune_failed"})
|
|
190
|
+
for entry in entries:
|
|
191
|
+
if dry_run or "error" in entry:
|
|
192
|
+
print(json.dumps(entry))
|
|
193
|
+
elif entry.get("updated"): # the rollback history
|
|
194
|
+
print(json.dumps({"name": entry["name"], "id": entry["id"],
|
|
195
|
+
"from_mib": entry["reservation_mib"] or DEFAULT // MIB, "to_mib": entry["target_mib"]}))
|
|
196
|
+
try:
|
|
197
|
+
natives = sum(unit["HostConfig"]["MemoryReservation"] for unit in running_native())
|
|
198
|
+
except (OSError, ValueError, KeyError, TypeError, subprocess.SubprocessError):
|
|
199
|
+
natives = None
|
|
200
|
+
memory = meminfo()
|
|
201
|
+
reserved_mib, cap_mib = (reserved + (natives or 0)) // MIB, int(memory["MemTotal"] * 0.7) // MIB
|
|
202
|
+
|
|
203
|
+
def count(key):
|
|
204
|
+
return sum(1 for entry in entries if entry.get(key))
|
|
205
|
+
|
|
206
|
+
return {"schema": SCHEMA, "dry_run": dry_run, "containers": len(containers), "settling": count("settling"),
|
|
207
|
+
"updated": count("updated"), "partial": count("partial"), "errors": count("error"), "zswap": zswap,
|
|
208
|
+
"reclaimed_mib": sum(entry.get("reclaim_mib", 0) for entry in entries if not entry.get("partial")),
|
|
209
|
+
"released_mib": sum((entry["reservation_mib"] or DEFAULT // MIB) - entry["target_mib"]
|
|
210
|
+
for entry in entries if entry.get("updated")),
|
|
211
|
+
"reserved_mib": reserved_mib, "headroom_mib": cap_mib - reserved_mib, "cap_mib": cap_mib,
|
|
212
|
+
"native_mib": None if natives is None else natives // MIB, "available_mib": memory["MemAvailable"] // MIB,
|
|
213
|
+
"swap_used_mib": (memory.get("SwapTotal", 0) - memory.get("SwapFree", 0)) // MIB}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def main(argv=None):
|
|
217
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
218
|
+
parser.add_argument("--dry-run", action="store_true", help="print the plan and write nothing")
|
|
219
|
+
try:
|
|
220
|
+
summary = run(parser.parse_args(argv).dry_run)
|
|
221
|
+
except ValueError as error:
|
|
222
|
+
print(json.dumps({"schema": SCHEMA, "error": str(error)}))
|
|
223
|
+
return 1
|
|
224
|
+
print(json.dumps(summary))
|
|
225
|
+
return 0
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
if __name__ == "__main__":
|
|
229
|
+
sys.exit(main())
|
|
@@ -98,8 +98,10 @@ def running_native():
|
|
|
98
98
|
for unit, limits in model(profile)['services'].items():
|
|
99
99
|
if states[unit]['ActiveState'] not in ('active', 'activating', 'reloading'):
|
|
100
100
|
continue
|
|
101
|
-
reservation
|
|
101
|
+
# Others count the larger of the applied and declared reservation; the unit's own deploy only the applied.
|
|
102
|
+
applied = int(states[unit]['MemoryLow'])
|
|
103
|
+
reservation = max(applied, limits['mem_reservation'])
|
|
102
104
|
containers.append({'Config': {'Labels': {'com.docker.compose.project': path.stem,
|
|
103
105
|
'com.docker.compose.project.working_dir': str(APPS / path.stem)}},
|
|
104
|
-
'HostConfig': {'MemoryReservation': reservation}})
|
|
106
|
+
'HostConfig': {'MemoryReservation': reservation}, 'Applied': applied})
|
|
105
107
|
return containers
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"""Admit backend starts against live host headroom and existing container reservations."""
|
|
2
2
|
import json
|
|
3
3
|
import os
|
|
4
|
+
import re
|
|
5
|
+
import stat
|
|
4
6
|
import subprocess
|
|
5
7
|
import time
|
|
6
8
|
import fcntl
|
|
@@ -11,6 +13,7 @@ from runtime_model import MIB
|
|
|
11
13
|
from native_profile import running_native
|
|
12
14
|
|
|
13
15
|
HOST_POLICY = Path('/etc/gowalk-backend-ssh.json')
|
|
16
|
+
AGENT_STATE = Path("/var/lib/gowalk-backend-memory")
|
|
14
17
|
ROOT_UID = 0
|
|
15
18
|
|
|
16
19
|
|
|
@@ -63,7 +66,7 @@ def running_containers():
|
|
|
63
66
|
ids = result.stdout.split()
|
|
64
67
|
if not ids:
|
|
65
68
|
return running_native()
|
|
66
|
-
template = ('{"Config":{"Labels":{"com.docker.compose.project":'
|
|
69
|
+
template = ('{"Id":{{json .Id}},"Config":{"Labels":{"com.docker.compose.project":'
|
|
67
70
|
'{{json (index .Config.Labels "com.docker.compose.project")}},'
|
|
68
71
|
'"com.docker.compose.project.working_dir":'
|
|
69
72
|
'{{json (index .Config.Labels "com.docker.compose.project.working_dir")}}}},'
|
|
@@ -73,20 +76,40 @@ def running_containers():
|
|
|
73
76
|
return [json.loads(line) for line in result.stdout.splitlines()] + running_native()
|
|
74
77
|
|
|
75
78
|
|
|
79
|
+
def declared(container, live):
|
|
80
|
+
"""The reservation a running container started with, as the memory agent recorded it before resizing."""
|
|
81
|
+
identifier = str(container.get("Id"))
|
|
82
|
+
if not re.fullmatch("[0-9a-f]{64}", identifier):
|
|
83
|
+
return live
|
|
84
|
+
try:
|
|
85
|
+
info = (AGENT_STATE / identifier).lstat()
|
|
86
|
+
if not stat.S_ISREG(info.st_mode) or info.st_uid != ROOT_UID or info.st_mode & 0o022:
|
|
87
|
+
return live
|
|
88
|
+
value = int((AGENT_STATE / identifier).read_text())
|
|
89
|
+
except (OSError, ValueError):
|
|
90
|
+
return live
|
|
91
|
+
return value if value > 0 else live
|
|
92
|
+
|
|
93
|
+
|
|
76
94
|
def admit(app, model, facts, containers):
|
|
77
95
|
host_enabled()
|
|
78
96
|
required = sum(service["mem_reservation"] for service in model["services"].values())
|
|
79
|
-
reserved, current = 0, 0
|
|
97
|
+
reserved, current, held = 0, 0, 0
|
|
80
98
|
for container in containers:
|
|
81
99
|
labels = container.get("Config", {}).get("Labels") or {}
|
|
82
100
|
if not labels.get("com.docker.compose.project.working_dir", "").startswith("/opt/gowalk-backends/"):
|
|
83
101
|
continue
|
|
84
102
|
amount = container["HostConfig"].get("MemoryReservation") or 64 * MIB
|
|
85
103
|
if labels.get("com.docker.compose.project") == app:
|
|
104
|
+
amount = container.get("Applied", amount) # a native unit holds its MemoryLow, not the declaration deployed
|
|
86
105
|
current += amount
|
|
106
|
+
held += declared(container, amount)
|
|
87
107
|
else:
|
|
88
108
|
reserved += amount
|
|
89
|
-
|
|
109
|
+
# A redeploy is charged only for growth beyond what its project holds: each container at the reservation it
|
|
110
|
+
# started with (the agent's record), else its live one; an agent rise is never credited to new growth.
|
|
111
|
+
growth = required - held
|
|
112
|
+
if growth > 0 and reserved + current + growth > facts["total"] * 0.7:
|
|
90
113
|
raise ValueError("host_reservations_exhausted")
|
|
91
114
|
if facts["available"] - max(0, required - current) < max(512 * MIB, facts["total"] * 0.1):
|
|
92
115
|
raise ValueError("host_memory_exhausted")
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Backend host memory agent
|
|
2
|
+
|
|
3
|
+
Admission counts reservations (64 MiB when unset), not use, within 70% of RAM. Idle Python APIs use less than
|
|
4
|
+
that default and a third of their memory is cold, so a shared host refused stacks with
|
|
5
|
+
`host_reservations_exhausted` while RAM was still free. `backend-action/memory_agent.py` runs as root every five
|
|
6
|
+
minutes on eight threads. For each running container under `/opt/gowalk-backends/` (the scope admission counts)
|
|
7
|
+
that started at least ten minutes ago, it first measures the working set (`memory.current - inactive_file`), then:
|
|
8
|
+
|
|
9
|
+
- sets the reservation to the working set plus 20%, rounded up to 4 MiB, at least 16 MiB and at most the
|
|
10
|
+
container's limit (`docker update --memory-reservation`). App containers rise at once and fall by 8 MiB or more.
|
|
11
|
+
Datastores (PostgreSQL, PostGIS, TimescaleDB, Redis, Valkey, KeyDB, MySQL, MariaDB, MongoDB or Memcached by
|
|
12
|
+
image, service or command, or service `db`/`database`) only rise, and only with memory beyond their file cache;
|
|
13
|
+
- while swap is present, zswap is enabled (`/sys/module/zswap/parameters/enabled` is `Y`) and `memory.pressure`
|
|
14
|
+
`some avg60` is below 0.5, writes a quarter of an app container's anonymous memory to `memory.reclaim`,
|
|
15
|
+
compressing cold pages into zswap. Datastores are never reclaimed.
|
|
16
|
+
|
|
17
|
+
Before its first change to a container, the agent records the reservation admission counted (bytes; 64 MiB when
|
|
18
|
+
unset) in `/var/lib/gowalk-backend-memory/<full container id>` (root-owned, 0644, atomic, fsynced, never
|
|
19
|
+
overwritten) and never resizes without it; each real run removes only the 64-hex-named records of containers that
|
|
20
|
+
no longer exist. Admission (`runtime_host.py`) charges a redeploy only for growth beyond what its project holds:
|
|
21
|
+
each running container at its root-owned record (not group- or other-writable) when one exists, otherwise at its
|
|
22
|
+
live reservation, so an agent rise never pays for new growth; each native unit at its applied `MemoryLow`. An
|
|
23
|
+
unchanged or shrinking redeploy is never refused on reservations; a first deploy, an added service or a raised
|
|
24
|
+
declaration needs room under the cap for what exceeds that. The agent never changes limits, CPU, PIDs, images,
|
|
25
|
+
Compose files, runtime profiles or native units.
|
|
26
|
+
|
|
27
|
+
## Host setup
|
|
28
|
+
|
|
29
|
+
Install `backend-action/` from the integrity-verified published package as the [SSH entry](BACKEND_SSH_GATE.md)
|
|
30
|
+
describes. Before enabling the timer, move both panel keys' forced commands in `/root/.ssh/authorized_keys` to the
|
|
31
|
+
new release directory: the admission credit lives in its `runtime_host.py`. Add these root-owned units to
|
|
32
|
+
`/etc/systemd/system/`, then create a 16 GiB swap file and enable them:
|
|
33
|
+
|
|
34
|
+
```ini
|
|
35
|
+
# gowalk-backend-zswap.service
|
|
36
|
+
[Unit]
|
|
37
|
+
DefaultDependencies=no
|
|
38
|
+
Before=swap.target
|
|
39
|
+
[Service]
|
|
40
|
+
Type=oneshot
|
|
41
|
+
RemainAfterExit=yes
|
|
42
|
+
ExecStart=/bin/sh -c 'modprobe -q zstd; modprobe -q zsmalloc; cd /sys/module/zswap/parameters && \
|
|
43
|
+
echo zsmalloc > zpool && echo zstd > compressor && echo 25 > max_pool_percent && \
|
|
44
|
+
{ [ ! -e exclusive_loads ] || echo Y > exclusive_loads; } && echo Y > enabled'
|
|
45
|
+
[Install]
|
|
46
|
+
WantedBy=swap.target
|
|
47
|
+
# gowalk-backend-memory.service
|
|
48
|
+
[Unit]
|
|
49
|
+
After=docker.service
|
|
50
|
+
[Service]
|
|
51
|
+
Type=oneshot
|
|
52
|
+
Nice=10
|
|
53
|
+
ExecStart=/usr/bin/python3 -B /opt/gowalk-cicd-runtime/<version>/backend-action/memory_agent.py
|
|
54
|
+
# gowalk-backend-memory.timer
|
|
55
|
+
[Timer]
|
|
56
|
+
OnBootSec=10min
|
|
57
|
+
OnUnitActiveSec=5min
|
|
58
|
+
[Install]
|
|
59
|
+
WantedBy=timers.target
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
fallocate -l 16G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon --priority 10 /swapfile
|
|
64
|
+
echo '/swapfile none swap sw,pri=10 0 0' >> /etc/fstab
|
|
65
|
+
systemctl daemon-reload && systemctl enable --now gowalk-backend-zswap.service gowalk-backend-memory.timer
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Verification and rollback
|
|
69
|
+
|
|
70
|
+
`systemctl start gowalk-backend-memory.service` runs the agent at once; the oneshot unit never overlaps runs.
|
|
71
|
+
`memory_agent.py --dry-run` prints every container's plan and the summary and writes nothing (no state directory,
|
|
72
|
+
record, prune, `docker update` or reclaim). A real run prints a `{"name","id","from_mib","to_mib"}` history line
|
|
73
|
+
per changed container and a line per error, then the `gowalk-cicd/backend-memory.v1` summary that
|
|
74
|
+
`journalctl _SYSTEMD_UNIT=gowalk-backend-memory.service -n 1 -o cat` shows: `reserved_mib` is the
|
|
75
|
+
admission-equivalent sum after the run, `headroom_mib` what remains of `cap_mib`, `released_mib` what the run
|
|
76
|
+
returned, `zswap` whether reclaim was allowed, `partial` the reclaims completed only in part and `native_mib` null
|
|
77
|
+
(natives excluded) when native discovery failed. Only unavailable Docker exits 1.
|
|
78
|
+
|
|
79
|
+
Roll back with `systemctl disable --now gowalk-backend-memory.timer && systemctl stop gowalk-backend-memory.service`
|
|
80
|
+
(safe midway: a record precedes each update); Compose restores declared reservations when it recreates containers.
|
|
81
|
+
Restore every recorded value now with this loop, then move both forced commands back to the previous release:
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
for f in /var/lib/gowalk-backend-memory/*; do docker update --memory-reservation "$(cat "$f")" "$(basename "$f")"; done
|
|
85
|
+
```
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -76,6 +76,9 @@ are retained as sparse `workflows.<file>.overrides` entries. An override identif
|
|
|
76
76
|
a YAML path, with `value` or `remove: true`; step paths use stable IDs/names rather
|
|
77
77
|
than numeric positions. These extensions are app-owned configuration, including
|
|
78
78
|
their execution conditions, dependencies and secret references.
|
|
79
|
+
Reusable workflow jobs may retain GitHub's `secrets: inherit` handoff. This literal is accepted
|
|
80
|
+
only at `jobs.<job>.secrets`; named secret mappings and other credential inputs still require
|
|
81
|
+
GitHub secret references. Sparse overrides are checked at their rendered workflow paths.
|
|
79
82
|
|
|
80
83
|
Candidate artifacts are enabled automatically for recognized execution. Custom deployment
|
|
81
84
|
commands that the installer cannot establish as candidate-safe retain the normal main
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gowalk-cicd",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.189",
|
|
4
4
|
"description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"docs/BACKEND_EDGE.md",
|
|
42
42
|
"docs/BACKEND_SSH_GATE.md",
|
|
43
43
|
"docs/BACKEND_NATIVE.md",
|
|
44
|
+
"docs/BACKEND_MEMORY.md",
|
|
44
45
|
"docs/CONFIGURATION.md",
|
|
45
46
|
"docs/CI_PERFORMANCE.md",
|
|
46
47
|
"docs/LOCAL_VALIDATION.md"
|
package/src/config/settings.mjs
CHANGED
|
@@ -59,8 +59,11 @@ export function assertReferences(value, file, path = []) {
|
|
|
59
59
|
return;
|
|
60
60
|
}
|
|
61
61
|
if (typeof value !== 'string' || !value.trim()) return;
|
|
62
|
+
// GitHub's reusable-workflow handoff is syntax, not an inline credential.
|
|
63
|
+
if (value === 'inherit' && path.length === 3 && path[0] === 'jobs' && path[2] === 'secrets') return;
|
|
64
|
+
const secretValue = path[0] === 'jobs' && path[2] === 'secrets';
|
|
62
65
|
const key = path.at(-1) || '';
|
|
63
|
-
if (key === 'runtime-env' && value.includes('\n')) {
|
|
66
|
+
if (key === 'runtime-env' && value.includes('\n') && !secretValue) {
|
|
64
67
|
for (const line of value.split('\n').filter(line => line.trim() && !line.trim().startsWith('#'))) {
|
|
65
68
|
const match = /^([A-Z_][A-Z0-9_]*)=(.*)$/.exec(line);
|
|
66
69
|
if (!match) throw new MigrationRepair('unsupported_runtime_environment', file, path);
|
|
@@ -68,7 +71,7 @@ export function assertReferences(value, file, path = []) {
|
|
|
68
71
|
}
|
|
69
72
|
return;
|
|
70
73
|
}
|
|
71
|
-
const credentialKey = /(?:password|private.key|ssh-key|runtime-env|token|secret|credential)/i.test(key);
|
|
74
|
+
const credentialKey = secretValue || /(?:password|private.key|ssh-key|runtime-env|token|secret|credential)/i.test(key);
|
|
72
75
|
const referencesOnly = /^\s*\$\{\{[^}]+\}\}\s*$/.test(value);
|
|
73
76
|
if ((credentialKey && !referencesOnly) || /-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(value)
|
|
74
77
|
|| /https?:\/\/[^\s/@]+:[^\s/@]+@/.test(value)) {
|
package/src/config/workflows.mjs
CHANGED
|
@@ -108,8 +108,9 @@ export function planWorkflows(root, templateDir, files, version) {
|
|
|
108
108
|
delete state.lock.workflows[file];
|
|
109
109
|
}
|
|
110
110
|
writes.push(...removed.map(file => ({ file: `.github/workflows/${file}`, content: null })));
|
|
111
|
-
|
|
112
|
-
|
|
111
|
+
const { workflows, ...settings } = state.config;
|
|
112
|
+
assertReferences(settings, CONFIG);
|
|
113
|
+
for (const [file, entry] of Object.entries(workflows)) {
|
|
113
114
|
for (const change of entry.overrides) assertReferences(change.value, file, change.path);
|
|
114
115
|
}
|
|
115
116
|
writes.push(...planValidation(root, [...selected, WARM, RECOVERY]));
|