@mriekki/project-forge 4.8.1 → 4.9.0

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,327 @@
1
+ """Remote Ubuntu script template for shared Cloudflare Tunnel ingress."""
2
+
3
+ REMOTE_SCRIPT = r"""#!/usr/bin/env bash
4
+ set -Eeuo pipefail
5
+
6
+ ACTION="$1"
7
+ SCOPE="$2"
8
+ META="${3:-}"
9
+ SPEC="${4:-}"
10
+ EXPECTED="${5:-}"
11
+
12
+ PLATFORM_ROOT="__PLATFORM_ROOT__"
13
+ NETWORK_NAME="__NETWORK_NAME__"
14
+ CADDY_CONTAINER="__CADDY_CONTAINER__"
15
+ CLOUDFLARED_IMAGE="__CLOUDFLARED_IMAGE__@sha256:c6c7ae1d5dfdcb094ad344ed68ccffc26635c9c6be80965664c33baf064b0515"
16
+ SCHEMA_VERSION="__SCHEMA_VERSION__"
17
+ INGRESS_VERSION="__INGRESS_VERSION__"
18
+ DISTRIBUTION="__DISTRIBUTION__"
19
+ TOKEN_B64="__TOKEN_B64__"
20
+
21
+ valid_slug() { [[ "$1" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]]; }
22
+ valid_repo() { [[ "$1" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; }
23
+ valid_hostname() {
24
+ local host="$1" label
25
+ [[ -n "$host" && ${#host} -le 253 ]] || return 1
26
+ [[ "$host" != *:* && "$host" != *" "* && "$host" != */* && "$host" != *,* ]] || return 1
27
+ IFS='.' read -r -a labels <<< "$host"
28
+ (( ${#labels[@]} >= 2 )) || return 1
29
+ for label in "${labels[@]}"; do
30
+ [[ "$label" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]] || return 1
31
+ done
32
+ }
33
+
34
+ valid_slug "$SCOPE" || { echo "Invalid Cloudflare ingress scope." >&2; exit 20; }
35
+
36
+ ROOT="$PLATFORM_ROOT/ingress/cloudflare/$SCOPE"
37
+ MARKER="$ROOT/ingress.env"
38
+ TOKEN_FILE="$ROOT/tunnel-token"
39
+ COMPOSE_FILE="$ROOT/compose.yaml"
40
+ ROUTE_ROOT="$ROOT/routes"
41
+ CONTAINER="project-forge-cloudflared-$SCOPE"
42
+ LOCK_FILE="/run/lock/project-forge-cloudflare-$SCOPE.lock"
43
+
44
+ marker_value() {
45
+ local key="$1"
46
+ [[ -f "$MARKER" ]] || return 1
47
+ sed -n "s/^${key}=//p" "$MARKER" | head -n1
48
+ }
49
+
50
+ platform_ready() {
51
+ [[ "$(docker inspect --format '{{.State.Status}}' "$CADDY_CONTAINER" 2>/dev/null || true)" == "running" ]] &&
52
+ docker network inspect "$NETWORK_NAME" >/dev/null 2>&1
53
+ }
54
+
55
+ container_running() {
56
+ [[ "$(docker inspect --format '{{.State.Status}}' "$CONTAINER" 2>/dev/null || true)" == "running" ]]
57
+ }
58
+
59
+ container_image() {
60
+ docker inspect --format '{{.Config.Image}}' "$CONTAINER" 2>/dev/null || true
61
+ }
62
+
63
+ network_attached() {
64
+ docker network inspect "$NETWORK_NAME" --format '{{range .Containers}}{{println .Name}}{{end}}' 2>/dev/null |
65
+ grep -Fxq "$CONTAINER"
66
+ }
67
+
68
+ status() {
69
+ local marker=false token=false running=false attached=false platform=false
70
+ [[ -f "$MARKER" ]] && marker=true
71
+ [[ -f "$TOKEN_FILE" ]] && token=true
72
+ container_running && running=true || true
73
+ network_attached && attached=true || true
74
+ platform_ready && platform=true || true
75
+
76
+ local state marker_schema marker_version marker_distribution marker_image runtime_image
77
+ marker_schema="$(marker_value SCHEMA_VERSION 2>/dev/null || true)"
78
+ marker_version="$(marker_value INGRESS_VERSION 2>/dev/null || true)"
79
+ marker_distribution="$(marker_value DISTRIBUTION 2>/dev/null || true)"
80
+ marker_image="$(marker_value CLOUDFLARED_IMAGE 2>/dev/null || true)"
81
+ runtime_image="$(container_image)"
82
+
83
+ if [[ "$platform" != true ]]; then
84
+ state="blocked-platform"
85
+ elif [[ "$marker" == false ]]; then
86
+ state="installable"
87
+ elif [[ "$marker_schema" != "$SCHEMA_VERSION" || "$marker_distribution" != "$DISTRIBUTION" ]]; then
88
+ state="incompatible"
89
+ elif [[ "$marker_version" != "$INGRESS_VERSION" || "$marker_image" != "$CLOUDFLARED_IMAGE" ]]; then
90
+ state="upgrade-required"
91
+ elif [[ "$running" == true && "$runtime_image" != "$CLOUDFLARED_IMAGE" ]]; then
92
+ state="drifted"
93
+ elif [[ "$token" == true && "$running" == true && "$attached" == true && -f "$COMPOSE_FILE" && -d "$ROUTE_ROOT" ]]; then
94
+ state="ready"
95
+ else
96
+ state="drifted"
97
+ fi
98
+
99
+ printf 'state=%s\n' "$state"
100
+ printf 'scope=%s\n' "$SCOPE"
101
+ printf 'markerPresent=%s\n' "$marker"
102
+ printf 'markerSchemaVersion=%s\n' "$marker_schema"
103
+ printf 'markerIngressVersion=%s\n' "$marker_version"
104
+ printf 'markerDistribution=%s\n' "$marker_distribution"
105
+ printf 'platformReady=%s\n' "$platform"
106
+ printf 'tokenPresent=%s\n' "$token"
107
+ printf 'containerRunning=%s\n' "$running"
108
+ printf 'networkAttached=%s\n' "$attached"
109
+ printf 'containerName=%s\n' "$CONTAINER"
110
+ printf 'cloudflaredImage=%s\n' "$CLOUDFLARED_IMAGE"
111
+ printf 'accountId=%s\n' "$(marker_value ACCOUNT_ID 2>/dev/null || true)"
112
+ printf 'zoneId=%s\n' "$(marker_value ZONE_ID 2>/dev/null || true)"
113
+ printf 'zoneName=%s\n' "$(marker_value ZONE_NAME 2>/dev/null || true)"
114
+ printf 'tunnelId=%s\n' "$(marker_value TUNNEL_ID 2>/dev/null || true)"
115
+ printf 'tunnelName=%s\n' "$(marker_value TUNNEL_NAME 2>/dev/null || true)"
116
+ }
117
+
118
+ parse_meta() {
119
+ IFS=$'\t' read -r M_ACCOUNT M_ZONE_ID M_ZONE_NAME M_TUNNEL_ID M_TUNNEL_NAME extra <<< "$META"
120
+ [[ -z "${extra:-}" && -n "$M_ACCOUNT" && -n "$M_ZONE_ID" && -n "$M_ZONE_NAME" && -n "$M_TUNNEL_ID" && -n "$M_TUNNEL_NAME" ]] || {
121
+ echo "Malformed Cloudflare ingress metadata." >&2
122
+ exit 20
123
+ }
124
+ valid_hostname "$M_ZONE_NAME" || { echo "Invalid Cloudflare zone name." >&2; exit 20; }
125
+ }
126
+
127
+ write_marker() {
128
+ local tmp="$MARKER.tmp.$$"
129
+ cat > "$tmp" <<EOF
130
+ SCHEMA_VERSION=$SCHEMA_VERSION
131
+ INGRESS_VERSION=$INGRESS_VERSION
132
+ DISTRIBUTION=$DISTRIBUTION
133
+ ACCOUNT_ID=$M_ACCOUNT
134
+ ZONE_ID=$M_ZONE_ID
135
+ ZONE_NAME=$M_ZONE_NAME
136
+ TUNNEL_ID=$M_TUNNEL_ID
137
+ TUNNEL_NAME=$M_TUNNEL_NAME
138
+ CLOUDFLARED_IMAGE=$CLOUDFLARED_IMAGE
139
+ EOF
140
+ chmod 0600 "$tmp"
141
+ mv -f "$tmp" "$MARKER"
142
+ }
143
+
144
+ write_compose() {
145
+ local tmp="$COMPOSE_FILE.tmp.$$"
146
+ cat > "$tmp" <<EOF
147
+ services:
148
+ cloudflared:
149
+ image: $CLOUDFLARED_IMAGE
150
+ container_name: $CONTAINER
151
+ restart: unless-stopped
152
+ command: tunnel --no-autoupdate run --token-file /run/secrets/tunnel-token
153
+ volumes:
154
+ - $TOKEN_FILE:/run/secrets/tunnel-token:ro
155
+ networks:
156
+ - $NETWORK_NAME
157
+ logging:
158
+ driver: json-file
159
+ options:
160
+ max-size: "10m"
161
+ max-file: "3"
162
+
163
+ networks:
164
+ $NETWORK_NAME:
165
+ external: true
166
+ EOF
167
+ chmod 0600 "$tmp"
168
+ docker compose -f "$tmp" config >/dev/null
169
+ mv -f "$tmp" "$COMPOSE_FILE"
170
+ }
171
+
172
+ write_token() {
173
+ [[ -n "$TOKEN_B64" ]] || { echo "Tunnel token was not supplied." >&2; exit 20; }
174
+ local tmp="$TOKEN_FILE.tmp.$$"
175
+ printf '%s' "$TOKEN_B64" | base64 --decode > "$tmp"
176
+ [[ -s "$tmp" ]] || { rm -f "$tmp"; echo "Tunnel token decoded empty." >&2; exit 20; }
177
+ chmod 0444 "$tmp"
178
+ mv -f "$tmp" "$TOKEN_FILE"
179
+ }
180
+
181
+ install_or_upgrade() {
182
+ platform_ready || { echo "Shared Project Forge Caddy/network platform is not ready." >&2; exit 21; }
183
+ parse_meta
184
+ if [[ "$ACTION" == install && -f "$MARKER" ]]; then
185
+ echo "Cloudflare ingress marker already exists; use upgrade." >&2
186
+ exit 22
187
+ fi
188
+ if [[ "$ACTION" == upgrade && ! -f "$MARKER" ]]; then
189
+ echo "Cloudflare ingress marker is absent; use install." >&2
190
+ exit 22
191
+ fi
192
+ install -d -m 0700 "$ROOT" "$ROUTE_ROOT"
193
+ write_token
194
+ write_compose
195
+ write_marker
196
+ docker compose -f "$COMPOSE_FILE" up -d --force-recreate
197
+ container_running || { echo "cloudflared container did not become running." >&2; exit 23; }
198
+ [[ "$(container_image)" == "$CLOUDFLARED_IMAGE" ]] || { echo "cloudflared container is not running the expected pinned image." >&2; exit 23; }
199
+ network_attached || { echo "cloudflared container is not attached to the shared Caddy network." >&2; exit 23; }
200
+ }
201
+
202
+ parse_spec_value() {
203
+ IFS=$'\t' read -r R_SCHEMA R_OWNER R_ENV R_APP R_ROUTE R_PUBLIC R_ORIGIN extra <<< "$1"
204
+ [[ -z "${extra:-}" ]] || { echo "Malformed public-ingress route spec." >&2; exit 30; }
205
+ [[ "$R_SCHEMA" == "$SCHEMA_VERSION" ]] || { echo "Unsupported public-ingress route schema." >&2; exit 30; }
206
+ valid_repo "$R_OWNER" || { echo "Invalid route owner repo." >&2; exit 30; }
207
+ valid_slug "$R_ENV" || { echo "Invalid route environment." >&2; exit 30; }
208
+ valid_slug "$R_APP" || { echo "Invalid route app slug." >&2; exit 30; }
209
+ [[ "$R_ROUTE" == "$R_APP--$R_ENV" ]] || { echo "Public-ingress route ID mismatch." >&2; exit 30; }
210
+ valid_hostname "$R_PUBLIC" || { echo "Invalid public hostname." >&2; exit 30; }
211
+ [[ "$R_PUBLIC" != *.local ]] || { echo "Public hostname must not use .local." >&2; exit 30; }
212
+ valid_hostname "$R_ORIGIN" || { echo "Invalid origin hostname." >&2; exit 30; }
213
+ [[ "$R_ORIGIN" == *.local ]] || { echo "Origin hostname must be a trusted-LAN .local route." >&2; exit 30; }
214
+ }
215
+
216
+ route_path() { printf '%s/%s.route' "$ROUTE_ROOT" "$1"; }
217
+ route_value() {
218
+ local path="$1" key="$2"
219
+ sed -n "s/^${key}=//p" "$path" | head -n1
220
+ }
221
+ emit_route() {
222
+ local path="$1"
223
+ printf 'ROUTE\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
224
+ "$(route_value "$path" SCHEMA_VERSION)" \
225
+ "$(route_value "$path" OWNER_REPO)" \
226
+ "$(route_value "$path" ENVIRONMENT)" \
227
+ "$(route_value "$path" APP_SLUG)" \
228
+ "$(route_value "$path" ROUTE_ID)" \
229
+ "$(route_value "$path" PUBLIC_HOSTNAME)" \
230
+ "$(route_value "$path" ORIGIN_HOSTNAME)"
231
+ }
232
+ route_inventory() {
233
+ [[ -f "$MARKER" ]] || { echo "Cloudflare ingress is not installed." >&2; exit 31; }
234
+ local count=0 path
235
+ for path in "$ROUTE_ROOT"/*.route; do [[ -f "$path" ]] && count=$((count + 1)); done
236
+ printf 'SUMMARY\tscope\t%s\n' "$SCOPE"
237
+ printf 'SUMMARY\tmanagedRouteCount\t%s\n' "$count"
238
+ for path in "$ROUTE_ROOT"/*.route; do [[ -f "$path" ]] && emit_route "$path"; done
239
+ }
240
+
241
+ metadata_matches() {
242
+ local path="$1" encoded="$2"
243
+ parse_spec_value "$encoded"
244
+ [[ "$(route_value "$path" SCHEMA_VERSION)" == "$R_SCHEMA" &&
245
+ "$(route_value "$path" OWNER_REPO)" == "$R_OWNER" &&
246
+ "$(route_value "$path" ENVIRONMENT)" == "$R_ENV" &&
247
+ "$(route_value "$path" APP_SLUG)" == "$R_APP" &&
248
+ "$(route_value "$path" ROUTE_ID)" == "$R_ROUTE" &&
249
+ "$(route_value "$path" PUBLIC_HOSTNAME)" == "$R_PUBLIC" &&
250
+ "$(route_value "$path" ORIGIN_HOSTNAME)" == "$R_ORIGIN" ]]
251
+ }
252
+
253
+ route_register() {
254
+ [[ "$(status | sed -n 's/^state=//p' | head -n1)" == ready ]] || {
255
+ echo "Cloudflare ingress runtime is not ready." >&2; exit 31;
256
+ }
257
+ parse_spec_value "$SPEC"
258
+ local path other public
259
+ path="$(route_path "$R_ROUTE")"
260
+ if [[ "$EXPECTED" == NONE ]]; then
261
+ [[ ! -e "$path" ]] || { echo "Public-ingress route appeared after planning." >&2; exit 32; }
262
+ else
263
+ [[ -f "$path" && ! -L "$path" ]] || { echo "Expected public-ingress route is missing or unsafe." >&2; exit 32; }
264
+ metadata_matches "$path" "$EXPECTED" || { echo "Public-ingress route changed after planning." >&2; exit 32; }
265
+ parse_spec_value "$SPEC"
266
+ fi
267
+ for other in "$ROUTE_ROOT"/*.route; do
268
+ [[ -f "$other" ]] || continue
269
+ [[ "$other" == "$path" ]] && continue
270
+ public="$(route_value "$other" PUBLIC_HOSTNAME)"
271
+ [[ "$public" != "$R_PUBLIC" ]] || { echo "Public hostname is already owned by another managed route." >&2; exit 33; }
272
+ done
273
+ if [[ -f "$path" ]]; then
274
+ [[ "$(route_value "$path" OWNER_REPO)" == "$R_OWNER" &&
275
+ "$(route_value "$path" ENVIRONMENT)" == "$R_ENV" &&
276
+ "$(route_value "$path" APP_SLUG)" == "$R_APP" ]] || {
277
+ echo "Existing public-ingress route ownership mismatch." >&2; exit 33;
278
+ }
279
+ fi
280
+ local tmp="$path.tmp.$$"
281
+ cat > "$tmp" <<EOF
282
+ SCHEMA_VERSION=$R_SCHEMA
283
+ OWNER_REPO=$R_OWNER
284
+ ENVIRONMENT=$R_ENV
285
+ APP_SLUG=$R_APP
286
+ ROUTE_ID=$R_ROUTE
287
+ PUBLIC_HOSTNAME=$R_PUBLIC
288
+ ORIGIN_HOSTNAME=$R_ORIGIN
289
+ EOF
290
+ chmod 0600 "$tmp"
291
+ mv -f "$tmp" "$path"
292
+ }
293
+
294
+ route_remove() {
295
+ parse_spec_value "$SPEC"
296
+ local path
297
+ path="$(route_path "$R_ROUTE")"
298
+ [[ -f "$path" && ! -L "$path" ]] || { echo "Managed public-ingress route does not exist." >&2; exit 34; }
299
+ metadata_matches "$path" "$SPEC" || { echo "Public-ingress route ownership/state mismatch." >&2; exit 34; }
300
+ rm -f "$path"
301
+ }
302
+
303
+ case "$ACTION" in
304
+ status)
305
+ status
306
+ ;;
307
+ install|upgrade)
308
+ exec 9>>"$LOCK_FILE"
309
+ flock -n 9 || { echo "Another Cloudflare ingress operation holds the lock." >&2; exit 75; }
310
+ install_or_upgrade
311
+ status
312
+ ;;
313
+ route-inventory)
314
+ route_inventory
315
+ ;;
316
+ route-register|route-remove)
317
+ exec 9>>"$LOCK_FILE"
318
+ flock -n 9 || { echo "Another Cloudflare ingress operation holds the lock." >&2; exit 75; }
319
+ if [[ "$ACTION" == route-register ]]; then route_register; else route_remove; fi
320
+ route_inventory
321
+ ;;
322
+ *)
323
+ echo "Unsupported Cloudflare ingress action: $ACTION" >&2
324
+ exit 2
325
+ ;;
326
+ esac
327
+ """
@@ -8,6 +8,7 @@ from .access_cli import main as access_main
8
8
  from .app_cli import main as app_main
9
9
  from .cli import main as core_main
10
10
  from .environment_cli import main as environment_main
11
+ from .ingress_cli import main as ingress_main
11
12
  from .local_cli import main as local_main
12
13
  from .registry_cli import main as registry_main
13
14
  from .sqlite_cli import main as sqlite_main
@@ -23,6 +24,8 @@ def main(argv: list[str] | None = None) -> int:
23
24
  return app_main(args[1:])
24
25
  if args and args[0] == "environment":
25
26
  return environment_main(args[1:])
27
+ if args and args[0] == "ingress":
28
+ return ingress_main(args[1:])
26
29
  if args and args[0] == "local":
27
30
  return local_main(args[1:])
28
31
  if args and args[0] == "registry":
@@ -37,6 +40,7 @@ def main(argv: list[str] | None = None) -> int:
37
40
  print("Command group: access Read-only strict SSH target-access diagnosis")
38
41
  print("Command group: app Remote immutable application runtime inventory/deployment")
39
42
  print("Command group: environment Portable profiles, Home Lab onboarding, and image transfer")
43
+ print("Command group: ingress Optional public ingress such as Cloudflare Tunnel")
40
44
  print("Command group: local Loopback-only workstation application runtime")
41
45
  print("Command group: registry Read-only exact-image registry access verification")
42
46
  print("Command group: sqlite SQLite persistence import/backup/migration/rollback checks")
@@ -0,0 +1,192 @@
1
+ """CLI for optional public-ingress providers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import sys
9
+ from typing import Any
10
+
11
+ from .cloudflare_ingress import (
12
+ CLOUDFLARE_API_TOKEN_ENV,
13
+ HOST_PUBLIC_INGRESS_WRITE_PHRASE,
14
+ PUBLIC_INGRESS_ROUTE_REMOVE_PHRASE,
15
+ PUBLIC_INGRESS_ROUTE_WRITE_PHRASE,
16
+ apply_cloudflare_ingress,
17
+ apply_public_route,
18
+ apply_public_route_removal,
19
+ cloudflare_ingress_doctor,
20
+ cloudflare_ingress_status,
21
+ cloudflare_route_inventory,
22
+ plan_cloudflare_ingress,
23
+ plan_public_route,
24
+ plan_public_route_removal,
25
+ )
26
+ from .errors import DeploymentKitError
27
+
28
+
29
+ def _print(value: Any, *, as_json: bool) -> None:
30
+ if as_json:
31
+ print(json.dumps(value, indent=2, sort_keys=True))
32
+ return
33
+ if isinstance(value, dict):
34
+ for key, item in value.items():
35
+ if isinstance(item, (dict, list)):
36
+ print(f"{key}: {json.dumps(item, sort_keys=True)}")
37
+ else:
38
+ print(f"{key}: {item}")
39
+ return
40
+ print(value)
41
+
42
+
43
+ def _token() -> str:
44
+ value = os.environ.get(CLOUDFLARE_API_TOKEN_ENV, "")
45
+ if not value.strip():
46
+ raise DeploymentKitError(
47
+ f"Cloudflare API authorization is not available. Set {CLOUDFLARE_API_TOKEN_ENV} "
48
+ "in the current workstation process/secret boundary; never put the token in the Project repository or command line."
49
+ )
50
+ return value.strip()
51
+
52
+
53
+ def _confirm(args: argparse.Namespace, phrase: str, action: str) -> None:
54
+ if args.yes:
55
+ return
56
+ entered = input(f"Type {phrase} to {action}: ").strip()
57
+ if entered != phrase:
58
+ raise DeploymentKitError(f"{action.capitalize()} was not authorized.")
59
+
60
+
61
+ def build_parser() -> argparse.ArgumentParser:
62
+ parser = argparse.ArgumentParser(prog="project-forge-deploy ingress")
63
+ providers = parser.add_subparsers(dest="provider", required=True)
64
+ cloudflare = providers.add_parser("cloudflare", help="Cloudflare Tunnel public ingress")
65
+ commands = cloudflare.add_subparsers(dest="command", required=True)
66
+
67
+ for command in ("status", "doctor"):
68
+ item = commands.add_parser(command, help=f"Read Cloudflare ingress {command} without writes")
69
+ item.add_argument("name", help="Logical Project Forge target profile")
70
+ item.add_argument("--scope", required=True, help="Shared ingress scope, for example production")
71
+ item.add_argument("--json", action="store_true")
72
+
73
+ configure = commands.add_parser(
74
+ "configure",
75
+ help="Create/adopt one remotely managed tunnel and install the shared cloudflared runtime",
76
+ )
77
+ configure.add_argument("name")
78
+ configure.add_argument("--scope", required=True)
79
+ configure.add_argument("--zone", required=True, help="Existing active Cloudflare zone, for example example.com")
80
+ configure.add_argument("--tunnel-name", help="Optional explicit tunnel name; default is project-forge-<scope>")
81
+ configure.add_argument("--json", action="store_true")
82
+ configure.add_argument("--yes", action="store_true")
83
+
84
+ route = commands.add_parser("route", help="Project-owned public hostname/DNS routes")
85
+ route_commands = route.add_subparsers(dest="route_command", required=True)
86
+
87
+ inventory = route_commands.add_parser("inventory", help="Read host-side managed public-route ownership")
88
+ inventory.add_argument("name")
89
+ inventory.add_argument("--scope", required=True)
90
+ inventory.add_argument("--json", action="store_true")
91
+
92
+ register = route_commands.add_parser(
93
+ "register",
94
+ help="Register one exact public hostname through the tunnel to an existing trusted-LAN route",
95
+ )
96
+ register.add_argument("name")
97
+ register.add_argument("--scope", required=True)
98
+ register.add_argument("--owner-repo", required=True)
99
+ register.add_argument("--environment", required=True)
100
+ register.add_argument("--app-slug", required=True)
101
+ register.add_argument("--public-hostname", required=True)
102
+ register.add_argument("--origin-hostname", required=True, help="Existing Project Forge .local Caddy hostname")
103
+ register.add_argument("--json", action="store_true")
104
+ register.add_argument("--yes", action="store_true")
105
+
106
+ remove = route_commands.add_parser("remove", help="Remove one exact owned Cloudflare public route")
107
+ remove.add_argument("name")
108
+ remove.add_argument("--scope", required=True)
109
+ remove.add_argument("--owner-repo", required=True)
110
+ remove.add_argument("--environment", required=True)
111
+ remove.add_argument("--app-slug", required=True)
112
+ remove.add_argument("--json", action="store_true")
113
+ remove.add_argument("--yes", action="store_true")
114
+ return parser
115
+
116
+
117
+ def main(argv: list[str] | None = None) -> int:
118
+ args = build_parser().parse_args(argv)
119
+ try:
120
+ if args.provider != "cloudflare":
121
+ raise DeploymentKitError(f"Unsupported public-ingress provider: {args.provider}")
122
+ if args.command == "status":
123
+ _print(cloudflare_ingress_status(args.name, args.scope), as_json=args.json)
124
+ return 0
125
+ if args.command == "doctor":
126
+ _print(cloudflare_ingress_doctor(args.name, args.scope), as_json=args.json)
127
+ return 0
128
+ if args.command == "configure":
129
+ token = _token()
130
+ plan = plan_cloudflare_ingress(
131
+ args.name,
132
+ scope=args.scope,
133
+ zone_name=args.zone,
134
+ api_token=token,
135
+ tunnel_name=args.tunnel_name,
136
+ )
137
+ _print(plan, as_json=False)
138
+ _confirm(
139
+ args,
140
+ HOST_PUBLIC_INGRESS_WRITE_PHRASE,
141
+ f"configure Cloudflare public ingress scope {args.scope} on target {args.name}",
142
+ )
143
+ _print(apply_cloudflare_ingress(plan, api_token=token), as_json=args.json)
144
+ return 0
145
+ if args.command == "route" and args.route_command == "inventory":
146
+ _print(cloudflare_route_inventory(args.name, args.scope), as_json=args.json)
147
+ return 0
148
+ if args.command == "route" and args.route_command == "register":
149
+ token = _token()
150
+ plan = plan_public_route(
151
+ args.name,
152
+ scope=args.scope,
153
+ owner_repo=args.owner_repo,
154
+ environment=args.environment,
155
+ app_slug=args.app_slug,
156
+ public_hostname=args.public_hostname,
157
+ origin_hostname=args.origin_hostname,
158
+ api_token=token,
159
+ )
160
+ _print(plan, as_json=False)
161
+ if plan["action"] == "already-configured":
162
+ _print({"state": "already-configured", "route": plan["route"], "mutated": False}, as_json=args.json)
163
+ return 0
164
+ _confirm(
165
+ args,
166
+ PUBLIC_INGRESS_ROUTE_WRITE_PHRASE,
167
+ f"publish {args.public_hostname} for {args.owner_repo}/{args.environment}/{args.app_slug}",
168
+ )
169
+ _print(apply_public_route(plan, api_token=token), as_json=args.json)
170
+ return 0
171
+ if args.command == "route" and args.route_command == "remove":
172
+ token = _token()
173
+ plan = plan_public_route_removal(
174
+ args.name,
175
+ scope=args.scope,
176
+ owner_repo=args.owner_repo,
177
+ environment=args.environment,
178
+ app_slug=args.app_slug,
179
+ api_token=token,
180
+ )
181
+ _print(plan, as_json=False)
182
+ _confirm(
183
+ args,
184
+ PUBLIC_INGRESS_ROUTE_REMOVE_PHRASE,
185
+ f"remove public route {plan['route']['publicHostname']}",
186
+ )
187
+ _print(apply_public_route_removal(plan, api_token=token), as_json=args.json)
188
+ return 0
189
+ raise DeploymentKitError("Unsupported Cloudflare ingress command.")
190
+ except DeploymentKitError as error:
191
+ print(f"ERROR: {error}", file=sys.stderr)
192
+ return 2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mriekki/project-forge",
3
- "version": "4.8.1",
3
+ "version": "4.9.0",
4
4
  "description": "Public consumer distribution and updater for Project Forge engineering assets.",
5
5
  "type": "module",
6
6
  "bin": {