splent-feature-autodeploy 0.1.0__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.
@@ -0,0 +1,265 @@
1
+ Metadata-Version: 2.4
2
+ Name: splent_feature_autodeploy
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.13
5
+ Description-Content-Type: text/markdown
6
+
7
+ # splent_feature_autodeploy
8
+
9
+ Release-gated continuous delivery for a SPLENT product. A product that
10
+ selects this feature runs a small agent next to its web container in
11
+ production. The agent follows a registry image channel, for example
12
+ `git.splent.io/splent-io/<product>:stable`, and when the channel points to a
13
+ new image it redeploys the product. The database is backed up first, the
14
+ new version has to pass its health check, and an automatic rollback of both the container and the database when
15
+ the new version does not come up.
16
+
17
+ The channel only moves when someone runs `splent product:release`, so nothing
18
+ reaches production that nobody released. The agent never builds anything and
19
+ never follows a branch.
20
+
21
+ ## How a check goes
22
+
23
+ Every `AUTODEPLOY_INTERVAL` seconds, and whenever an administrator asks,
24
+ the agent goes through these steps.
25
+
26
+ 1. Pull the channel image. If its id is the one the web container runs,
27
+ there is nothing to do.
28
+ 2. Skip an image that failed before, until the channel points elsewhere.
29
+ Honour the pause flag and the update window (`AUTODEPLOY_WINDOW`). A
30
+ check somebody asks for (Check now) deploys outside the window too.
31
+ 3. Back up the database. `mariadb-dump --single-transaction --routines
32
+ --triggers` runs as root inside the database container, with the
33
+ credentials that container already holds, and the gzipped dump is kept
34
+ only if `mariadb-dump` exited cleanly and the dump ends with its
35
+ `Dump completed` trailer. No complete backup, no deploy.
36
+ 4. Record what the web container runs (`splent version --json`).
37
+ 5. Recreate the web container on the new image with everything it was
38
+ configured with, the way Watchtower does it. That is its name, its
39
+ environment (the container's own values on top of the new image's), its
40
+ labels, mounts and binds, port bindings, restart policy, healthcheck, and
41
+ its networks with their aliases. Cmd, Entrypoint and WorkingDir follow the new image when the old
42
+ container only inherited them, and are kept when it chose them (the
43
+ compose file's `command:`). The old container is renamed
44
+ `<name>-previous` and stopped, not removed.
45
+ 6. Wait for the healthcheck, up to `AUTODEPLOY_HEALTH_TIMEOUT` seconds. The
46
+ entrypoint runs every feature's migrations before gunicorn starts, so this
47
+ is generous.
48
+ 7. Once it is healthy, remove the previous container and write the history
49
+ entry (from and to image, product version, the features whose version
50
+ changed, duration).
51
+ 8. If it turns unhealthy, exits, restarts or runs out of time, keep the new
52
+ container's last 80 log lines, remove it, drop and recreate the database and import the
53
+ backup (so tables the new migrations created do not linger), put the
54
+ previous container back under its name, wait for it to be healthy, and
55
+ refuse that image from then on. The history entry says `rolled_back` and
56
+ carries the log lines.
57
+
58
+ Every step is written to `/data/state.json` before it is taken. An agent
59
+ restarted in the middle of a deploy (a reboot, a redeploy of the agent
60
+ itself) finishes it if the new container turns healthy, and rolls it back
61
+ otherwise.
62
+
63
+ | State | Meaning |
64
+ |----------------------|--------------------------------------------------------------|
65
+ | `idle` | The web container runs the image the channel points to |
66
+ | `waiting_for_window` | A new image is ready and waits for the update window |
67
+ | `deploying` | Backup, recreate or health wait in progress |
68
+ | `rolling_back` | The new image failed and the previous version is coming back |
69
+ | `paused` | An administrator paused automatic deploys |
70
+ | `held` | The only image available failed before; waiting for the next release |
71
+ | `error` | Something needs a person, the detail says what |
72
+ | `not_configured` | A required setting is missing, the detail lists it |
73
+
74
+ ## Enable it in a product
75
+
76
+ 1. Add the feature to the product line as an optional feature, from a
77
+ detached workspace.
78
+
79
+ ```bash
80
+ splent spl:add-feature <spl> splent_feature_autodeploy
81
+ ```
82
+
83
+ In the UVL model it reads as below, with the constraint that keeps the
84
+ admin panel it lives in.
85
+
86
+ ```
87
+ optional
88
+ autodeploy {org 'splent-io', package 'splent_feature_autodeploy'}
89
+ constraints
90
+ autodeploy => admin
91
+ ```
92
+
93
+ 2. Attach it to the product and release the product, so the channel exists.
94
+
95
+ ```bash
96
+ splent product:select <product>
97
+ splent feature:attach splent-io/splent_feature_autodeploy v0.1.0
98
+ splent product:release
99
+ ```
100
+
101
+ A product release pushes `<registry>/<org>/<product>:<version>` and moves
102
+ `:stable` to it.
103
+
104
+ 3. On the server, set `SPLENT_IMAGE` in `docker/.env.deploy` to the channel
105
+ the server follows, then build and deploy. The web service of the
106
+ product's `docker-compose.prod.yml` must take its image from that
107
+ variable, as in `image: ${SPLENT_IMAGE:-<product>:latest}`.
108
+
109
+ ```bash
110
+ SPLENT_IMAGE=git.splent.io/splent-io/<product>:stable
111
+ ```
112
+
113
+ ```bash
114
+ splent product:build --no-image
115
+ splent product:deploy
116
+ ```
117
+
118
+ `product:build` adds the agent's service to `docker-compose.deploy.yml`
119
+ and copies `docker/` to `docker/features/splent_feature_autodeploy/`.
120
+ `product:deploy` offers a random value for `AUTODEPLOY_SECRET`, builds the
121
+ agent's image and starts it.
122
+
123
+ 4. For a private registry set `AUTODEPLOY_REGISTRY_USER` and
124
+ `AUTODEPLOY_REGISTRY_TOKEN` (a token that can read packages).
125
+
126
+ From then on, `splent product:release` is the whole deploy.
127
+
128
+ ## Configuration
129
+
130
+ Declared in `[tool.splent.config]`, so they appear in `.env.deploy.example`
131
+ with the product's names filled in.
132
+
133
+ | Variable | Default | Meaning |
134
+ |------------------------------|-------------------------------------|---------|
135
+ | `AUTODEPLOY_IMAGE` | empty | Image to follow. Empty means `SPLENT_IMAGE` |
136
+ | `AUTODEPLOY_INTERVAL` | `300` | Seconds between checks (at least 30) |
137
+ | `AUTODEPLOY_WINDOW` | empty | UTC span for deploys, like `02:00-05:00` or `22:00-04:00`. Empty means any time |
138
+ | `AUTODEPLOY_HEALTH_TIMEOUT` | `600` | Seconds the new container gets to turn healthy |
139
+ | `AUTODEPLOY_KEEP_BACKUPS` | `5` | Backups kept, newest first |
140
+ | `AUTODEPLOY_WEB_CONTAINER` | `<product>_web_deploy` | The container that is replaced |
141
+ | `AUTODEPLOY_DB_CONTAINER` | `<product>_db_deploy` | The MariaDB container that is backed up |
142
+ | `AUTODEPLOY_AGENT_CONTAINER` | `<product>_autodeploy` | The agent's container name |
143
+ | `AUTODEPLOY_URL` | `http://<product>_autodeploy:8080` | Where the web side finds the agent |
144
+ | `AUTODEPLOY_REGISTRY_USER` | empty | Registry account, for a private channel |
145
+ | `AUTODEPLOY_REGISTRY_TOKEN` | empty | Registry token, for a private channel |
146
+ | `AUTODEPLOY_SECRET` | `<SET>` | Shared by the web container and the agent |
147
+
148
+ ## The Deployments page
149
+
150
+ `/admin/deployments`, in the admin panel's sidebar, for administrators only
151
+ (the admin feature's `ADMIN_ROLES`). It shows the channel, the running image
152
+ and product version, what the agent is doing and why, the last check, the
153
+ update window, whether updates are paused, and the latest deploys with the
154
+ features each one moved. A rolled back deploy shows its log lines in a
155
+ collapsible block, and a banner says so until the next deploy. Check now,
156
+ Pause and Resume are CSRF protected forms.
157
+
158
+ When the agent is missing (development), stopped or refuses the secret the
159
+ page explains what is going on. The site itself is never affected.
160
+
161
+ ## Commands
162
+
163
+ ```bash
164
+ splent feature:autodeploy status [--json]
165
+ splent feature:autodeploy history [--limit N] [--json]
166
+ splent feature:autodeploy check
167
+ splent feature:autodeploy pause
168
+ splent feature:autodeploy resume
169
+ ```
170
+
171
+ They talk to the agent's API, so they need the product network. On a
172
+ production server run them in the web container.
173
+
174
+ ```bash
175
+ docker exec <product>_web_deploy splent feature:autodeploy status
176
+ ```
177
+
178
+ ## The agent's API
179
+
180
+ Port 8080 on the product network, never published on the host. Every request
181
+ carries `Authorization: Bearer <AUTODEPLOY_SECRET>`, compared in constant
182
+ time; anything else is a 401, whatever the path.
183
+
184
+ | Method | Path | Does |
185
+ |--------|--------------------|------|
186
+ | GET | `/status` | State, configuration, running versions, backups |
187
+ | GET | `/history?limit=N` | The newest N deploys, newest first (1 to 200) |
188
+ | POST | `/check` | Look for a new image now |
189
+ | POST | `/pause` | Stop deploying; a deploy in progress finishes |
190
+ | POST | `/resume` | Deploy again, and check right away |
191
+
192
+ There is no rollback endpoint on purpose. A rollback restores the backup taken
193
+ before the deploy, so run later by hand it would throw away everything written
194
+ since.
195
+
196
+ ## Security
197
+
198
+ - The agent mounts `/var/run/docker.sock`. Whoever controls that socket is
199
+ root on the host, so the agent is as sensitive as the host itself. It runs
200
+ only in production, from the image `product:deploy` builds out of this
201
+ feature's `docker/` directory, and does only what this README describes.
202
+ - It publishes no port. Only containers on the product network reach the API,
203
+ and every call needs the shared secret. The secret never appears in the
204
+ status, the history or the logs.
205
+ - The web side calls the agent without any proxy, so an `HTTP_PROXY` in the
206
+ environment never sees the secret.
207
+ - The database credentials stay in the database container. The dump and the
208
+ restore read them from that container's environment and pass them through
209
+ `MYSQL_PWD`, so they are not on any command line and are never printed.
210
+ - Registry credentials are only sent to the registry, with the pull.
211
+
212
+ ## Backups
213
+
214
+ One per deploy, in the agent's `autodeploy_data` volume under
215
+ `/data/backups/`, named `<UTC time>-<short id of the image being replaced>.sql.gz`,
216
+ so each file says which code its schema belongs to. The newest
217
+ `AUTODEPLOY_KEEP_BACKUPS` are kept. They live on the same host as the
218
+ database, so they protect against a bad release, not against losing the
219
+ server. Copy them elsewhere if you need that.
220
+
221
+ ## Restoring a backup by hand
222
+
223
+ Restoring a backup loses everything written after it was taken. Pause the
224
+ agent first, so it does not deploy in the middle.
225
+
226
+ ```bash
227
+ docker exec <product>_web_deploy splent feature:autodeploy pause
228
+ docker cp <product>_autodeploy:/data/backups/<file>.sql.gz .
229
+ docker stop <product>_web_deploy
230
+ docker exec <product>_db_deploy bash -c 'MYSQL_PWD="$MARIADB_ROOT_PASSWORD" mariadb -uroot -e "DROP DATABASE IF EXISTS \`$MARIADB_DATABASE\`; CREATE DATABASE \`$MARIADB_DATABASE\`"'
231
+ gunzip -c <file>.sql.gz | docker exec -i <product>_db_deploy bash -c 'MYSQL_PWD="$MARIADB_ROOT_PASSWORD" mariadb -uroot "$MARIADB_DATABASE"'
232
+ ```
233
+
234
+ Then run a web container on the image named in the file name, so code and
235
+ schema match, and resume the agent.
236
+
237
+ To let the agent try an image it refused, pause it, delete the image id from
238
+ `bad_images` in `/data/state.json` in the agent's volume, and resume.
239
+
240
+ ## What the agent does not do
241
+
242
+ - It replaces the web container only. A release that adds a service (a
243
+ feature that ships its own container), or needs a new setting without a
244
+ default in `.env.deploy`, has to go through `splent product:deploy`; pause
245
+ the agent while you do it. Left to the agent such a release fails its
246
+ health check and is rolled back, which is safe but not what you want.
247
+ - It does not update itself. A new version of this feature reaches the
248
+ server with `splent product:deploy`, which rebuilds the agent's image.
249
+ - It replaces one web container, not a set of replicas.
250
+
251
+ ## Develop
252
+
253
+ The agent is plain Python 3.13 with docker-py, in `docker/autodeploy_agent/`.
254
+ It gets the Docker client handed in, so its tests run against a fake Engine.
255
+
256
+ ```bash
257
+ splent feature:test splent_feature_autodeploy
258
+ ```
259
+
260
+ From a bare checkout, with a Python that has the framework, Flask-Babel,
261
+ Flask-Login and Flask-WTF installed, the same tests run without a product.
262
+
263
+ ```bash
264
+ python -m pytest src/splent_io/splent_feature_autodeploy/tests
265
+ ```
@@ -0,0 +1,17 @@
1
+ splent_io/splent_feature_autodeploy/__init__.py,sha256=-vCEeSoOSg1e5mxfP5RS6fJwFKa1zcT_laiyB-JvvDE,400
2
+ splent_io/splent_feature_autodeploy/commands.py,sha256=z4sSiPRHDJFrZl6Yon5xHVJzx6u16_LcdWdfYVvN6P8,5395
3
+ splent_io/splent_feature_autodeploy/config.py,sha256=VdFAiYX9N0F-FvYGbqTPpMEpwcP5vtBvD6baLoAMy6o,1420
4
+ splent_io/splent_feature_autodeploy/forms.py,sha256=9yxlORGCXq_YsL_RyfTLC3gZKZGwr-IlE0g21bU6VKg,225
5
+ splent_io/splent_feature_autodeploy/hooks.py,sha256=-d0GtGy_rCKqi1Y0z5fynKWt5iCvapk7HbSpSYvvJjU,1069
6
+ splent_io/splent_feature_autodeploy/presenters.py,sha256=udGUF1A8aerGXd-8otIFh09RiFvZ9uus88NIDMkdD4k,6850
7
+ splent_io/splent_feature_autodeploy/routes.py,sha256=V63KTraWrCZBee_e_AKVwLJhs57_fhpInM1jBmW-spo,4342
8
+ splent_io/splent_feature_autodeploy/services.py,sha256=KxlGeVS4Nx6TqJSMyFUrF77PWBhM4P3r440NCIlbAdk,6244
9
+ splent_io/splent_feature_autodeploy/templates/autodeploy/index.html,sha256=Jsu9tYhaj4DIcpx3tg6jnzbby5aUyXj2YIc_FG_e7_Y,12764
10
+ splent_io/splent_feature_autodeploy/templates/hooks/sidebar_deployments.html,sha256=EhAAem2QBgeZJXTfJF3hPu2o02LKzTpwSeJBvDts3Q0,323
11
+ splent_io/splent_feature_autodeploy/translations/messages.pot,sha256=tBXGk7NT7t45xeCBuOwHXv5q3lw700hRvydyfTh5ClA,13537
12
+ splent_io/splent_feature_autodeploy/translations/es/LC_MESSAGES/messages.mo,sha256=Pl9FFXR6dG0LwPmf20Gw4VTp7N_nSiALPaHXL-V5acg,11033
13
+ splent_io/splent_feature_autodeploy/translations/es/LC_MESSAGES/messages.po,sha256=QW4oWL7UBi70FLTGLPHR751PppLO6S2KBProgwuMrmU,18713
14
+ splent_feature_autodeploy-0.1.0.dist-info/METADATA,sha256=Iw7j4-MyAy74m-vyS2N55SLuAmkAeHUbN1j3lXPnHqI,12642
15
+ splent_feature_autodeploy-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
16
+ splent_feature_autodeploy-0.1.0.dist-info/top_level.txt,sha256=nksrfdr_edPhGPUsCqz08kyNRLBkNc2sInZYzmbZ4TA,10
17
+ splent_feature_autodeploy-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ splent_io
@@ -0,0 +1,14 @@
1
+ from splent_framework.blueprints.base_blueprint import create_blueprint
2
+ from splent_framework.services.service_locator import register_service
3
+
4
+ from splent_io.splent_feature_autodeploy.services import AutodeployService
5
+
6
+ autodeploy_bp = create_blueprint(__name__)
7
+
8
+
9
+ def init_feature(app):
10
+ register_service(app, "AutodeployService", AutodeployService)
11
+
12
+
13
+ def inject_context_vars(app):
14
+ return {}
@@ -0,0 +1,163 @@
1
+ """CLI commands contributed by splent_feature_autodeploy.
2
+
3
+ Exposed under the ``feature:autodeploy`` group::
4
+
5
+ splent feature:autodeploy status
6
+ splent feature:autodeploy history --limit 5
7
+ splent feature:autodeploy check
8
+ splent feature:autodeploy pause
9
+ splent feature:autodeploy resume
10
+
11
+ They talk to the agent's API at AUTODEPLOY_URL with AUTODEPLOY_SECRET, so
12
+ they work wherever the product network is reachable. On a production server
13
+ that is the web container itself::
14
+
15
+ docker exec <product>_web_deploy splent feature:autodeploy status
16
+ """
17
+
18
+ import json
19
+
20
+ import click
21
+
22
+ from splent_io.splent_feature_autodeploy.services import (
23
+ DEFAULT_HISTORY_LIMIT,
24
+ AgentError,
25
+ AutodeployService,
26
+ )
27
+
28
+ HINTS = {
29
+ "not_configured": "Set AUTODEPLOY_URL and AUTODEPLOY_SECRET (product:deploy does both in production).",
30
+ "unreachable": "Is the agent container running, and is this command on the product network?",
31
+ "refused": "AUTODEPLOY_SECRET differs between here and the agent.",
32
+ "bad_response": "The agent and this feature may be of different versions.",
33
+ }
34
+
35
+
36
+ def _service() -> AutodeployService:
37
+ return AutodeployService()
38
+
39
+
40
+ def _call(action: str, *args):
41
+ try:
42
+ return getattr(_service(), action)(*args)
43
+ except AgentError as exc:
44
+ hint = HINTS.get(exc.reason, "")
45
+ raise click.ClickException(f"{exc}. {hint}".strip())
46
+
47
+
48
+ def _short(image_id) -> str:
49
+ return str(image_id or "").split(":", 1)[-1][:12] or "-"
50
+
51
+
52
+ def _row(label: str, value) -> None:
53
+ click.echo(f" {label:<18} {value if value not in (None, '') else '-'}")
54
+
55
+
56
+ def _print_status(data: dict) -> None:
57
+ versions = data.get("running_versions") or {}
58
+ last = data.get("last_deploy") or {}
59
+ click.echo()
60
+ _row("state", data.get("status"))
61
+ if data.get("detail"):
62
+ _row("detail", data.get("detail"))
63
+ for problem in data.get("problems") or []:
64
+ _row("missing", problem)
65
+ _row("channel", data.get("image"))
66
+ _row("running image", _short(data.get("running_image")))
67
+ _row("product version", versions.get("product_version"))
68
+ if data.get("pending_image"):
69
+ _row("newest release", _short(data.get("pending_image")))
70
+ _row("last check", data.get("last_check"))
71
+ _row("window", f"{data['window']} UTC" if data.get("window") else "any time")
72
+ _row("paused", "yes" if data.get("paused") else "no")
73
+ if last:
74
+ _row(
75
+ "last deploy",
76
+ f"{last.get('result')} at {last.get('finished_at')} "
77
+ f"({_short(last.get('from_image'))} -> {_short(last.get('to_image'))})",
78
+ )
79
+ _row("refused images", len(data.get("bad_images") or []))
80
+ click.echo()
81
+
82
+
83
+ @click.command("status")
84
+ @click.option(
85
+ "--json", "as_json", is_flag=True, help="Print the agent's answer as JSON."
86
+ )
87
+ def status(as_json):
88
+ """What the deployment agent is doing and what the product runs."""
89
+ data = _call("status")
90
+ if as_json:
91
+ click.echo(json.dumps(data, indent=2, sort_keys=True))
92
+ return
93
+ _print_status(data)
94
+
95
+
96
+ @click.command("history")
97
+ @click.option(
98
+ "--limit",
99
+ default=DEFAULT_HISTORY_LIMIT,
100
+ show_default=True,
101
+ type=click.IntRange(1, 200),
102
+ help="How many deploys to show, newest first.",
103
+ )
104
+ @click.option("--json", "as_json", is_flag=True, help="Print the entries as JSON.")
105
+ def history(limit, as_json):
106
+ """The latest deploys, newest first."""
107
+ entries = _call("history", limit)
108
+ if as_json:
109
+ click.echo(json.dumps(entries, indent=2, sort_keys=True))
110
+ return
111
+ if not entries:
112
+ click.echo(" No deploys yet.")
113
+ return
114
+ click.echo()
115
+ for entry in entries:
116
+ result = entry.get("result") or "?"
117
+ colour = {"success": "green", "rolled_back": "yellow"}.get(result, "red")
118
+ click.echo(
119
+ f" {entry.get('finished_at') or entry.get('started_at')} "
120
+ + click.style(f"{result:<15}", fg=colour)
121
+ + f" {_short(entry.get('from_image'))} -> {_short(entry.get('to_image'))}"
122
+ + f" {entry.get('duration_seconds', '?')}s"
123
+ )
124
+ old, new = entry.get("product_version_from"), entry.get("product_version_to")
125
+ if old or new:
126
+ click.echo(f" product {old or '?'} -> {new or '?'}")
127
+ for change in entry.get("features_changed") or []:
128
+ click.echo(
129
+ f" {change.get('name')} {change.get('from') or '(new)'} -> "
130
+ f"{change.get('to') or '(removed)'}"
131
+ )
132
+ if entry.get("reason"):
133
+ click.echo(f" reason {entry['reason']}")
134
+ for problem in entry.get("problems") or []:
135
+ click.secho(f" problem {problem}", fg="red")
136
+ click.echo()
137
+
138
+
139
+ @click.command("check")
140
+ def check():
141
+ """Ask the agent to look for a new release now."""
142
+ _call("check")
143
+ click.secho(
144
+ " The agent is checking now. It still deploys only inside its update window.",
145
+ fg="green",
146
+ )
147
+
148
+
149
+ @click.command("pause")
150
+ def pause():
151
+ """Stop automatic deploys until resumed. A deploy in progress finishes."""
152
+ _call("pause")
153
+ click.secho(" Automatic deploys are paused.", fg="green")
154
+
155
+
156
+ @click.command("resume")
157
+ def resume():
158
+ """Turn automatic deploys back on, and check right away."""
159
+ _call("resume")
160
+ click.secho(" Automatic deploys are back on.", fg="green")
161
+
162
+
163
+ cli_commands = [status, history, check, pause, resume]
@@ -0,0 +1,33 @@
1
+ """
2
+ autodeploy feature configuration.
3
+
4
+ Injects environment variables into Flask app.config. Only what the web side
5
+ needs: where the agent listens and the secret it expects, plus the image and
6
+ window to show while the agent cannot be reached. The rest of the AUTODEPLOY_*
7
+ settings are read by the agent container itself (docker/autodeploy_agent).
8
+
9
+ Every value has a working default, so the feature loads without a .env entry
10
+ and the Deployments page explains what is missing.
11
+
12
+ To regenerate from source code: splent feature:inject-config splent_feature_autodeploy
13
+ """
14
+
15
+ import os
16
+
17
+
18
+ def inject_config(app):
19
+ app.config.update(
20
+ {
21
+ # Base URL of the agent's API on the product network.
22
+ "AUTODEPLOY_URL": os.getenv("AUTODEPLOY_URL", ""),
23
+ # Shared with the agent; "<SET>" until product:deploy fills it in.
24
+ "AUTODEPLOY_SECRET": os.getenv("AUTODEPLOY_SECRET", ""),
25
+ # The channel the agent follows, shown while it is unreachable.
26
+ # Empty means the agent falls back to SPLENT_IMAGE.
27
+ "AUTODEPLOY_IMAGE": os.getenv("AUTODEPLOY_IMAGE", ""),
28
+ # Daily UTC span in which the agent may deploy, empty for any time.
29
+ "AUTODEPLOY_WINDOW": os.getenv("AUTODEPLOY_WINDOW", ""),
30
+ # Seconds between two checks of the registry.
31
+ "AUTODEPLOY_INTERVAL": os.getenv("AUTODEPLOY_INTERVAL", "300"),
32
+ }
33
+ )
@@ -0,0 +1,6 @@
1
+ from flask_wtf import FlaskForm
2
+
3
+
4
+ class DeploymentActionForm(FlaskForm):
5
+ """Nothing but the CSRF token, so Check now, Pause and Resume cannot be
6
+ triggered from another site. One instance renders all three buttons."""
@@ -0,0 +1,29 @@
1
+ """Template hooks for splent_feature_autodeploy.
2
+
3
+ One entry in the panel's sidebar, for the accounts that may open the page
4
+ (the admin feature's ADMIN_ROLES), so nobody is shown a door that answers
5
+ 403. The sidebar never calls the agent: it renders on every panel page, and
6
+ a slow or missing agent must not slow the panel down.
7
+
8
+ The admin feature offers no dashboard card slot yet, so a rolled back deploy
9
+ is reported on the Deployments page only.
10
+ """
11
+
12
+ from flask import render_template
13
+ from flask_login import current_user
14
+
15
+ from splent_framework.hooks.template_hooks import register_template_hook
16
+ from splent_io.splent_feature_admin.services import ADMIN_ROLES
17
+
18
+
19
+ def deployments_sidebar_item():
20
+ if not getattr(current_user, "is_authenticated", False):
21
+ return ""
22
+ if not getattr(current_user, "active", False):
23
+ return ""
24
+ if getattr(current_user, "role", None) not in ADMIN_ROLES:
25
+ return ""
26
+ return render_template("hooks/sidebar_deployments.html")
27
+
28
+
29
+ register_template_hook("layout.authenticated_sidebar", deployments_sidebar_item)