From 1ebeb89ae33c3fa36c597f3e4eff67b4021d1d8a Mon Sep 17 00:00:00 2001 From: Shaan-Shoukath Date: Tue, 13 Oct 2026 19:32:24 +0530 Subject: [PATCH] fix(install): work for sudo users; updates come only from GitHub main releases The installer decides sudo for Docker access independently of the install directory, handles curl|sudo bash, docker-group re-entry and root-owned installs, and no longer needs host python3. It now prints a checklist of what you need first, explains menu options 1-4, and confirms a fresh install. The beta release channel is removed: install, manual update, the Platform panel and the weekly cron all move to the newest main release. Co-Authored-By: Shaan-Shoukath Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Codex GPT-6.1 Sol --- README.md | 49 +++- .../admin_api/serializers_platform_updates.py | 12 +- .../apps/admin_api/views_platform_updates.py | 8 +- backend/apps/backup/host_topology_record.py | 3 +- .../data_export/classification_platform.py | 2 +- .../tenant_migration/tenant_dump_catalog.py | 2 +- .../tenant_dump_field_snapshot.py | 2 +- backend/apps/updates/admin.py | 15 +- .../management/commands/update_control.py | 5 +- ...3_remove_platformupdatesettings_channel.py | 9 + backend/apps/updates/models.py | 5 - backend/apps/updates/releases.py | 17 +- backend/apps/updates/services.py | 22 +- docs/MODULES-PLATFORM.md | 6 +- docs/PROJECT-STATUS.md | 18 +- docs/README-HOSTING.md | 7 +- docs/SOURCE-MAP-PLATFORM.md | 20 +- docs/self-hosting.md | 39 ++- docs/setup-for-makerspaces.md | 4 +- frontend/openapi-schema.json | 22 +- .../features/staff/PlatformUpdatePanel.tsx | 15 +- .../src/features/staff/platformUpdateApi.ts | 2 - frontend/src/generated/api.ts | 4 - install.sh | 227 +++++++++++------- scripts/filter-main-releases.py | 49 ++++ scripts/init-host-orchestration.sh | 2 + scripts/install-auto-update.sh | 12 +- scripts/install-owner.sh | 32 +++ scripts/module-selection.sh | 18 +- scripts/restore.sh | 44 ++-- scripts/setup-first-run.sh | 133 ++++++++++ scripts/setup-host-orchestration.sh | 7 +- scripts/spaceworks-compose.sh | 32 ++- scripts/update-lock.sh | 4 +- scripts/update-rollback-host.sh | 35 +++ scripts/update-rollback.sh | 48 +++- scripts/update.sh | 55 +++-- scripts/validate-compose-wrapper.py | 4 + setup.sh | 168 ++----------- 39 files changed, 726 insertions(+), 432 deletions(-) create mode 100644 backend/apps/updates/migrations/0003_remove_platformupdatesettings_channel.py create mode 100644 scripts/filter-main-releases.py create mode 100644 scripts/install-owner.sh create mode 100644 scripts/setup-first-run.sh create mode 100644 scripts/update-rollback-host.sh diff --git a/README.md b/README.md index 9b373ceb7..6220e08f4 100644 --- a/README.md +++ b/README.md @@ -90,26 +90,55 @@ Celery worker/beat, and database migrations** and wires them to the app for you in any addresses; the compose file passes them in). Pick one path: **Path 1 — Guided pinned install (easiest; no Git clone or local build).** One script checks the host, -downloads the newest tagged release, generates all secrets, pulls its published images, and creates your -first admin + makerspace: +downloads the newest published GitHub release built from `main`, generates all secrets, pulls its +published images, and creates your first admin + makerspace: + +**Before you start, you need:** + +- a 64-bit (x86_64 or arm64) Linux server (Debian/Ubuntu, Fedora/RHEL/Alma/Rocky, Arch or openSUSE), + macOS, or Windows Git Bash/WSL; +- on Linux, root or a user with `sudo` — the installer installs Docker, Docker Compose, curl and tar + itself; on macOS/Windows, Docker Desktop installed and running; +- internet access to `github.com` and `ghcr.io`, at least 8 GiB free disk, and TCP ports 80, 9000 and + 9001 free; +- a terminal, plus the answers setup asks for: the web address (host name or IP), your makerspace name, + and the admin username, email and password (or let it generate one). Google sign-in and Stripe keys + are optional. ```bash curl -fsSL https://raw.githubusercontent.com/SpaceWorks-HQ/SpaceWorks/main/install.sh | bash ``` +Run it as your normal user — it asks for your `sudo` password when it needs root, adds you to the +`docker` group if required, and leaves the install owned by you. `curl … | sudo bash` also works and +keeps the install (and its update cron job) owned by root. `bash install.sh --help` prints the +checklist without changing anything. + It installs to `/opt/spaceworks`; set `SPACEWORKS_DIR` on the `bash` side of the pipe to override that (on Windows Git Bash, use `SPACEWORKS_DIR="$HOME/SpaceWorks"`). It detects x86_64/aarch64 and the host -distribution, checks dependencies, Docker, release availability, ports, disk space and existing state -**before changing anything**, then installs missing Linux dependencies through apt, dnf/yum, pacman or -zypper. The downloaded source and backend/frontend images are pinned to the tagged release. +distribution, checks dependencies, Docker, ports, disk space and existing state, then installs missing +Linux dependencies through apt, dnf/yum, pacman or zypper. It verifies release availability before +deploying; the downloaded source and backend/frontend images are pinned to the tagged release. It prints your URL and login when it finishes and offers to install seven-day, backup-first production -update checks. Super Admins can control automatic or manual installation from **Platform settings → +update checks. Installs pin the latest main release; manual updates, the Platform panel and the seven-day +cron move to the newest published, non-draft, non-prerelease main release. Accepted versions are +`X.Y.Z-main..<12-hex sha>`, with no release channel setting. +Super Admins can control automatic or manual installation from **Platform settings → Software updates**. `setup.sh` pulls published images by default; developers with a full source checkout can explicitly opt into a local Django/Vite build with `bash setup.sh --build`. -Run the same curl command later and an existing install offers **update**, **change modules**, **both**, or -**cancel**. It never silently reinstalls or reruns first-instance setup. +Run the same curl command later and an existing install shows a menu. It never silently reinstalls or +reruns first-instance setup: + +1. **Update to the latest release** — backs up the database, moves to the newest main release, runs + migrations and restarts. Modules and data stay as they are. +2. **Change modules only** — turn optional modules (events, bookings, machines, membership, payments…) + on or off without downloading a new version. +3. **Update, then change modules** — option 1 followed by option 2. +4. **Cancel** — exit without changing anything (the default). +An update rerun uses the latest main archive's updater/helpers against the existing install, keeping +its privileged host files in place, so older installers can move onto the main-only update path. **Path 2 — Manual prebuilt-image setup.** Pull the two published images and start the stack — after `cp .env.example .env` (fill in the few values it asks for): @@ -125,8 +154,8 @@ full stack automatically. ### Upgrades and module changes -An interactive update reviews the live module ticks after the new release is healthy. The useful explicit -forms are: +Updates select the newest main release from GitHub. An interactive update reviews the live module ticks +after the new release is healthy. The useful explicit forms are: ```bash bash scripts/update.sh --force --modules # update, then review modules diff --git a/backend/apps/admin_api/serializers_platform_updates.py b/backend/apps/admin_api/serializers_platform_updates.py index 1904126d3..5c2a124c9 100644 --- a/backend/apps/admin_api/serializers_platform_updates.py +++ b/backend/apps/admin_api/serializers_platform_updates.py @@ -15,13 +15,21 @@ class PlatformUpdateSettingsSerializer(serializers.ModelSerializer): class Meta: model = PlatformUpdateSettings fields = ( - "automatic_updates_enabled", "channel", "status", "current_version", + "automatic_updates_enabled", "status", "current_version", "available_version", "target_version", "update_requested_at", "last_checked_at", "last_updated_at", "last_backup_at", "last_backup_name", "last_error", "updated_at", "release_notes", "release_url", "running_since", "is_stuck", "pre_update_version", "pre_update_archive", "rollback_restore", "rollback_available", "rollback_refusal", "rollback_refusal_detail", "rollback_active", ) - read_only_fields = tuple(field for field in fields if field not in {"automatic_updates_enabled", "channel"}) + read_only_fields = tuple(field for field in fields if field != "automatic_updates_enabled") + + def to_internal_value(self, data): + unknown = set(data) - set(self.fields) if isinstance(data, dict) else set() + if unknown: + # to_internal_value must raise a field-keyed dict; a bare string makes DRF's + # ReturnDict crash and turns a 400 into a 500. + raise serializers.ValidationError({key: ["Unknown update setting."] for key in sorted(unknown)}) + return super().to_internal_value(data) def get_is_stuck(self, obj) -> bool: return is_stuck(obj) diff --git a/backend/apps/admin_api/views_platform_updates.py b/backend/apps/admin_api/views_platform_updates.py index ecd84f407..a7c726278 100644 --- a/backend/apps/admin_api/views_platform_updates.py +++ b/backend/apps/admin_api/views_platform_updates.py @@ -40,7 +40,7 @@ def _require_updates_module(): ) @extend_schema( tags=["Platform"], - summary="Retrieve or update production release channel and update settings", + summary="Retrieve or update main release update settings", ) class PlatformUpdateSettingsView(generics.RetrieveUpdateAPIView): serializer_class = PlatformUpdateSettingsSerializer @@ -57,13 +57,11 @@ def update(self, request, *args, **kwargs): try: return super().update(request, *args, **kwargs) except ValidationError as exc: - raise UpdateRefused("invalid_settings", "Select a stable/beta channel and a boolean automatic-update setting.") from exc + raise UpdateRefused("invalid_settings", "Provide a boolean automatic_updates_enabled setting.") from exc @transaction.atomic def perform_update(self, serializer): instance = services._locked_settings() - if "channel" in serializer.validated_data: - services.prepare_channel_change(instance, serializer.validated_data["channel"]) serializer.instance = instance instance = serializer.save() audit.record( @@ -76,7 +74,7 @@ def perform_update(self, serializer): @extend_schema( tags=["Platform"], - summary="Queue the latest production release for installation", + summary="Queue the newest published main release for installation", request=None, responses={ status.HTTP_202_ACCEPTED: PlatformUpdateSettingsSerializer, diff --git a/backend/apps/backup/host_topology_record.py b/backend/apps/backup/host_topology_record.py index 7c9cbc81d..7aabd20fe 100644 --- a/backend/apps/backup/host_topology_record.py +++ b/backend/apps/backup/host_topology_record.py @@ -100,6 +100,7 @@ def validate_compose_wrapper( topology_record, compose_files, require_root_owned=True, + static_owner_uid=None, ): read_pointer(pointer_file, require_root_owned=require_root_owned) paths = [Path(static_env), Path(topology_record), *(Path(item) for item in compose_files)] @@ -108,7 +109,7 @@ def validate_compose_wrapper( static_stat = paths[0].stat(follow_symlinks=False) record_stat = paths[1].stat(follow_symlinks=False) if ( - static_stat.st_uid not in {0, __import__("os").geteuid()} + static_stat.st_uid not in {0, os.geteuid() if static_owner_uid is None else static_owner_uid} or static_stat.st_mode & 0o077 or record_stat.st_uid != 0 or record_stat.st_mode & 0o027 diff --git a/backend/apps/data_export/classification_platform.py b/backend/apps/data_export/classification_platform.py index d74ffceca..72b05738d 100644 --- a/backend/apps/data_export/classification_platform.py +++ b/backend/apps/data_export/classification_platform.py @@ -155,7 +155,7 @@ "payments.ProcessedStripeEvent": "Provider idempotency telemetry.", "payments.StripeConnectOAuthState": "Transient OAuth state.", "roadmap.RoadmapItem": "Platform-global product roadmap.", - "updates.PlatformUpdateSettings": "Platform update state.", + "updates.PlatformUpdateSettings": "Deployment-local main release update and recovery state.", "data_export.DataExportJob": "Source-deployment export lifecycle and bearer state.", # Phase 5A. Every one of these is deployment-scoped operational state, not tenant data, # even where a row names a makerspace: BackupArchive.makerspace records which tenant an diff --git a/backend/apps/tenant_migration/tenant_dump_catalog.py b/backend/apps/tenant_migration/tenant_dump_catalog.py index 7fc457999..3b20eea6c 100644 --- a/backend/apps/tenant_migration/tenant_dump_catalog.py +++ b/backend/apps/tenant_migration/tenant_dump_catalog.py @@ -81,7 +81,7 @@ class TenantDumpCatalogError(AssertionError): # lane/w2-machines); the coordinator recomputes the merged event/booking catalog. # Merge lane/w2-notify with integration/2026-10 (including lane/w2-events, # lane/w2-machines and lane/w2-loans); the coordinator recomputes the merged digest. -CATALOG_SCHEMA_SHA256 = "26aa4548b1f27b54c24599bc79c33cf715705eec71b90b93fb39a66cccec656c" +CATALOG_SCHEMA_SHA256 = "1826e70f78fcdc78f75a4539e4f85beded256f5c8347ee772537adb7deb87f04" def catalog_models(apps_registry=apps): diff --git a/backend/apps/tenant_migration/tenant_dump_field_snapshot.py b/backend/apps/tenant_migration/tenant_dump_field_snapshot.py index f9ce33523..c19fee92f 100644 --- a/backend/apps/tenant_migration/tenant_dump_field_snapshot.py +++ b/backend/apps/tenant_migration/tenant_dump_field_snapshot.py @@ -151,7 +151,7 @@ 'procurement.ToBuyItem': frozenset('source_product actual_unit_cost created_at created_by estimated_unit_cost id kind link machine_type makerspace moved_to_inventory_at name ordered_at purchaser quantity received_at resulting_machine resulting_pool resulting_product source_pool status updated_at vendor_name'.split()), 'procurement.ToBuyReceipt': frozenset('created_at id object_key to_buy_item uploaded_by'.split()), 'notifications.Notification': frozenset('body created_at event id level makerspace read_at title url_path'.split()), - 'updates.PlatformUpdateSettings': frozenset('automatic_updates_enabled available_version channel current_version id last_backup_at last_backup_name last_checked_at last_error last_updated_at pre_update_archive pre_update_version release_notes release_url rollback_restore running_since status target_version update_requested_at updated_at'.split()), + 'updates.PlatformUpdateSettings': frozenset('automatic_updates_enabled available_version current_version id last_backup_at last_backup_name last_checked_at last_error last_updated_at pre_update_archive pre_update_version release_notes release_url rollback_restore running_since status target_version update_requested_at updated_at'.split()), 'backup.PlatformBackupSettings': frozenset('automatic_backups_enabled id last_error last_scheduled_at last_success_at retention_days updated_at'.split()), 'backup.BackupArchive': frozenset('age_encrypted archive_sha256 backup_run build_holder completed_at created_at download_token_consumed_at download_token_digest download_token_expires_at expires_at failure_detail id legacy_pre_decision_snapshot makerspace manifest object_key requested_by scope size_bytes staging_object_key started_at status superadmin_access_at_decision updated_at'.split()), 'backup.MakerspaceArchiveRecipient': frozenset('added_at added_by challenge_issued_at challenge_nonce_digest compromised_at fingerprint id label makerspace public_recipient revoked_at verified_at'.split()), diff --git a/backend/apps/updates/admin.py b/backend/apps/updates/admin.py index 82b16bae8..9218ece4c 100644 --- a/backend/apps/updates/admin.py +++ b/backend/apps/updates/admin.py @@ -8,22 +8,12 @@ from apps.updates.models import PlatformUpdateSettings from apps.updates import services -from apps.updates.errors import UpdateRefused class PlatformUpdateSettingsForm(forms.ModelForm): class Meta: model = PlatformUpdateSettings - fields = ("automatic_updates_enabled", "channel") - - def clean_channel(self): - channel = self.cleaned_data["channel"] - if self.instance.pk: - try: - services.prepare_channel_change(PlatformUpdateSettings.load(), channel) - except UpdateRefused as exc: - raise forms.ValidationError(str(exc.detail["detail"])) from exc - return channel + fields = ("automatic_updates_enabled",) class PlatformUpdateSettingsAdmin(AuditedControlChangeMixin, SuperuserOnlyModelAdmin, admin.ModelAdmin): @@ -31,7 +21,6 @@ class PlatformUpdateSettingsAdmin(AuditedControlChangeMixin, SuperuserOnlyModelA audit_action = "platform.update_settings_updated" list_display = ( "automatic_updates_enabled", - "channel", "status", "current_version", "available_version", @@ -67,8 +56,6 @@ def has_delete_permission(self, request, obj=None): def save_model(self, request, obj, form, change): # Re-read under a row lock: the host may have claimed an update after form validation. row = services._locked_settings() - services.prepare_channel_change(row, form.cleaned_data["channel"]) - row.channel = form.cleaned_data["channel"] row.automatic_updates_enabled = form.cleaned_data["automatic_updates_enabled"] super().save_model(request, row, form, change) # Django continues logging/redirecting with the original form object. diff --git a/backend/apps/updates/management/commands/update_control.py b/backend/apps/updates/management/commands/update_control.py index 79b5f6cd7..7ca316999 100644 --- a/backend/apps/updates/management/commands/update_control.py +++ b/backend/apps/updates/management/commands/update_control.py @@ -82,7 +82,10 @@ def handle(self, *args, **options): self.stdout.write("recorded") return if action == "complete": - services.complete_update(options["version"].strip()) + try: + services.complete_update(options["version"].strip()) + except ValueError as exc: + raise CommandError(str(exc)) from exc self.stdout.write("complete") return if action == "fail": diff --git a/backend/apps/updates/migrations/0003_remove_platformupdatesettings_channel.py b/backend/apps/updates/migrations/0003_remove_platformupdatesettings_channel.py new file mode 100644 index 000000000..b840b5567 --- /dev/null +++ b/backend/apps/updates/migrations/0003_remove_platformupdatesettings_channel.py @@ -0,0 +1,9 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [("updates", "0002_update_recovery")] + + operations = [ + migrations.RemoveField(model_name="platformupdatesettings", name="channel"), + ] diff --git a/backend/apps/updates/models.py b/backend/apps/updates/models.py index 6d5736f63..98ee494e9 100644 --- a/backend/apps/updates/models.py +++ b/backend/apps/updates/models.py @@ -2,10 +2,6 @@ class PlatformUpdateSettings(models.Model): - class Channel(models.TextChoices): - STABLE = "stable", "Stable" - BETA = "beta", "Beta" - class Status(models.TextChoices): IDLE = "idle", "Idle" QUEUED = "queued", "Queued" @@ -13,7 +9,6 @@ class Status(models.TextChoices): FAILED = "failed", "Failed" automatic_updates_enabled = models.BooleanField(default=False) - channel = models.CharField(max_length=8, choices=Channel.choices, default=Channel.STABLE) release_notes = models.TextField(blank=True) release_url = models.URLField(blank=True) running_since = models.DateTimeField(null=True, blank=True) diff --git a/backend/apps/updates/releases.py b/backend/apps/updates/releases.py index d70937da3..94c6d4cf9 100644 --- a/backend/apps/updates/releases.py +++ b/backend/apps/updates/releases.py @@ -3,17 +3,16 @@ import re from datetime import datetime -VERSION_PATTERN = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+-(main|beta)\.[0-9]+\.[0-9a-f]{12}") +VERSION_PATTERN = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+-main\.[0-9]+\.[0-9a-f]{12}") -def release_allowed(version, channel, *, prerelease=False): - match = VERSION_PATTERN.fullmatch(version) - return bool(match and (channel == "beta" or (match[1] == "main" and not prerelease))) +def release_allowed(version, *, prerelease=False): + return bool(isinstance(version, str) and VERSION_PATTERN.fullmatch(version) and not prerelease) -def select_release(releases, channel): - if not isinstance(releases, list) or channel not in {"stable", "beta"}: - raise ValueError("Expected a GitHub release list and a stable/beta channel.") +def select_release(releases): + if not isinstance(releases, list): + raise ValueError("Expected a GitHub release list.") candidates = [] for release in releases[:100]: if not isinstance(release, dict) or release.get("draft") is not False: @@ -22,7 +21,7 @@ def select_release(releases, channel): prerelease = release.get("prerelease") if not isinstance(tag, str) or not tag.startswith("v") or type(prerelease) is not bool: continue - if not release_allowed(tag[1:], channel, prerelease=prerelease): + if not release_allowed(tag[1:], prerelease=prerelease): continue try: published = datetime.fromisoformat(release["published_at"].replace("Z", "+00:00")) @@ -42,5 +41,5 @@ def select_release(releases, channel): "url": url[:200], })) if not candidates: - raise ValueError(f"No completed release is available on the {channel} channel.") + raise ValueError("No published, non-prerelease main release is available.") return max(candidates, key=lambda row: row[0])[1] diff --git a/backend/apps/updates/services.py b/backend/apps/updates/services.py index 843dd27a7..d42b0f4c2 100644 --- a/backend/apps/updates/services.py +++ b/backend/apps/updates/services.py @@ -36,7 +36,7 @@ def claim_update(*, current_version, available_version, force=False): @transaction.atomic def claim_release(*, current_version, releases, force=False): settings = _locked_settings() - release = select_release(releases, settings.channel) + release = select_release(releases) settings.release_notes = release["notes"] settings.release_url = release["url"] claimed = _claim_locked(settings, current_version, release["version"], force) @@ -45,8 +45,8 @@ def claim_release(*, current_version, releases, force=False): def _claim_locked(settings, current_version, available_version, force): _require_no_rollback(settings) - if not release_allowed(available_version, settings.channel): - raise ValueError("The release does not belong to the configured update channel.") + if not release_allowed(available_version): + raise ValueError("The release must be an immutable main release version.") if settings.status == PlatformUpdateSettings.Status.RUNNING: # The host only claims while holding the update lock, so a RUNNING row seen here # belongs to a run that died before its cleanup trap fired (host reboot, OOM kill). @@ -98,6 +98,8 @@ def record_backup(name): @transaction.atomic def complete_update(version): + if not release_allowed(version): + raise ValueError("The release must be an immutable main release version.") settings = _locked_settings() settings.status = PlatformUpdateSettings.Status.IDLE settings.current_version = version @@ -134,17 +136,3 @@ def _require_no_rollback(settings): if active_rollback(settings): refuse("rollback_active", "A rollback restore is already active.") - - -def prepare_channel_change(settings, channel): - from apps.updates.rollback import refuse - - if channel == settings.channel: - return - _require_no_rollback(settings) - if settings.status in {PlatformUpdateSettings.Status.RUNNING, PlatformUpdateSettings.Status.QUEUED}: - refuse("update_busy", "The channel cannot change during an update or rollback.") - settings.available_version = "" - settings.release_notes = "" - settings.release_url = "" - settings.last_checked_at = None diff --git a/docs/MODULES-PLATFORM.md b/docs/MODULES-PLATFORM.md index a8613ccc8..9f453b891 100644 --- a/docs/MODULES-PLATFORM.md +++ b/docs/MODULES-PLATFORM.md @@ -214,9 +214,9 @@ Opt-in, no dependencies; installed only by the `full`/`everything` profiles. - **What it is** — in-app release control: check for a new version, and apply it from the console. - **What it adds** — the superadmin update surface and `PlatformUpdateSettings` (current / available / target - version). -- **Without it** — the box is updated by whatever tooling you already use (`git pull` + `docker compose - up -d --build`, Ansible, your own pipeline). This is a **deployment-level** key like + version), selecting the newest published, non-draft, non-prerelease GitHub release built from `main`. +- **Without it** — use `bash scripts/update.sh --force` on the host to install the newest main release; + the in-app controls are absent. This is a **deployment-level** key like `member_accounts`, read as "does any live makerspace run it", because the update console is a single-box superadmin surface with no tenant to scope by. diff --git a/docs/PROJECT-STATUS.md b/docs/PROJECT-STATUS.md index 05e0a4ad3..fe6a500ef 100644 --- a/docs/PROJECT-STATUS.md +++ b/docs/PROJECT-STATUS.md @@ -66,6 +66,11 @@ walks every registered admin and forces an explicit scoped/global decision (via **Non-technical install:** the root `install.sh` curl flow on supported Linux families or Windows Git Bash (preflight → tagged source archive → published-image pull → `setup.sh` secrets/instance/live module ticks → version marker), with `SPACEWORKS_DIR` override and an existing-install update/module menu. +Fresh installs pin the newest published, non-draft, non-prerelease GitHub release built from `main`. +Reruns stage that release's updater/helpers against the existing install, keeping installed privileged +host files in place. A normal user's install and cron remain user-owned; a sudo/root install uses root +for setup, updates, its version marker and crontab. Host Python is optional: orchestration configuration +uses the backend image, and Compose validation uses that image if host Python 3.10+ is unavailable. `setup.sh --build` is the explicit source-build path via `docker/compose.build.yml`. Native Windows covers install/run/update, using the crash-recoverable PID/timestamp update lock when `flock` is unavailable; restore and compound host recovery stay WSL2-only because they require AF_UNIX sockets and @@ -75,19 +80,24 @@ root-owned-file trust semantics. See cleared by `/auth/change-password`). **Releases are titled `SpaceWorks `** (owner convention, 2026-08-15), where the version is the -git-tag form `v-..` — e.g. `SpaceWorks v0.5.1-main.12.a9cd82c0dd89`. **The +git-tag form `vX.Y.Z-main..<12-hex sha>` — e.g. `SpaceWorks v0.5.1-main.12.a9cd82c0dd89`. **The `SpaceWorks ` prefix is a display title, not part of the version value:** `updates.PlatformUpdateSettings` stores the bare string in `current_version`/`available_version`/`target_version`, and writing the prefix into those columns (or comparing against it) would break every version equality check the update flow makes. Compose `f"SpaceWorks {version}"` at the display layer. -**Platform updates** use a deployment-wide `stable` channel by default; `beta` includes -published prereleases. Release notes appear as text in the superadmin update panel. A running update +**Platform updates** move to the newest published, non-draft, non-prerelease GitHub release built from +`main`, whose version is `X.Y.Z-main..<12-hex sha>`. Manual host updates, the superadmin Platform +panel and the seven-day cron use this same release selection; there is no channel setting. +The host filters the GitHub release list before an older installed backend claims the update. +Release notes appear as text in the superadmin update panel. A running update older than two hours can be recovered only after proving the shared host operation lock is free; recovery writes one audit row and enables retry. Host claims also recover interrupted runs under that lock. Every new update records a verified deployment archive and its previous immutable version. Confirmed rollback records intent through the existing restore service and retains the all-table -diff decision. The legacy supervisor supports full legacy archives only: compound envelopes +diff decision. User-owned installs hand private rollback intent and the restore supervisor to a root +helper through sudo while the owner-run updater retains its portable lock. +The legacy supervisor supports full legacy archives only: compound envelopes (including those with no sovereign slices) and single-box restore are refused. Wiring the compound host coordinator remains necessary before newly built archives can offer product rollback. diff --git a/docs/README-HOSTING.md b/docs/README-HOSTING.md index f7443998c..7ea02b479 100644 --- a/docs/README-HOSTING.md +++ b/docs/README-HOSTING.md @@ -77,8 +77,10 @@ scripts/spaceworks-compose.sh bundled run --rm --no-deps backend --role manageme ``` With no arguments it creates **`superadmin` with a generated password**, prints it once for you to save, and forces a password change on first login. Re-running setup keeps the existing password and does not print it. Supply `--password` or `SETUP_SUPERADMIN_PASSWORD` to choose your own credential. -Guided installs can receive each successful `main` release automatically with a backup and readiness -check. If deployment fails, the application containers return to the previous retained release. Run +Guided installs pin the latest published GitHub release built from `main`. Manual updates, the Platform +panel and the seven-day cron select the newest published, non-draft, non-prerelease main release +(`X.Y.Z-main..<12-hex sha>`), with a backup and readiness check. If deployment fails, the +application containers return to the previous retained release. Run `bash scripts/update.sh --force` on Linux or Windows Git Bash for an immediate update; see **[docs/self-hosting.md](self-hosting.md)** for scheduling, pinning, TLS, and recovery. @@ -146,4 +148,3 @@ Three things are deliberate, because this moves real accountability records: - **The source is archived, not deleted** — and archives are outside the purge guarantee. Both sides are driven from the staff console; nothing here needs a shell. - diff --git a/docs/SOURCE-MAP-PLATFORM.md b/docs/SOURCE-MAP-PLATFORM.md index 512d9736a..c0f460c30 100644 --- a/docs/SOURCE-MAP-PLATFORM.md +++ b/docs/SOURCE-MAP-PLATFORM.md @@ -1,14 +1,26 @@ # Platform update and backup source map -- `backend/apps/updates/` — singleton deployment update state. `releases.py` selects published stable/beta - releases and caches plain-text changelogs; `recovery.py` checks the running timeout and shared host - lock before audited recovery; `rollback.py` binds snapshots and confirmed intent to the existing +- `backend/apps/updates/` — singleton deployment update state. `releases.py` selects the newest published, + non-draft, non-prerelease GitHub release with version `X.Y.Z-main..<12-hex sha>` and caches + plain-text changelogs. Fresh installs pin the latest main release; manual host updates, the Platform + panel and seven-day cron all move to the newest main release. `recovery.py` checks the running timeout + and shared host lock before audited recovery; `rollback.py` binds snapshots and confirmed intent to the existing restore service. `services.py` owns host claims and check/backup/result state. `admin_api/serializers_platform_updates.py`, `views_platform_updates.py` and `views_update_recovery.py` expose superadmin-only settings, recovery and rollback. `update_control` drives the privileged host scheduler; the web never receives a Docker socket. + `scripts/install-owner.sh` keeps setup, orchestration initialization, cron and updater re-entry under + the install owner's identity. `scripts/setup-first-run.sh` holds the guided first-run settings and + private `.env` creation sourced by `setup.sh`. + `scripts/filter-main-releases.py` filters GitHub JSON before an older installed backend claims an + update, preserving publication dates and release notes while admitting only published main releases. + Installer reruns stage the latest main archive's updater/helpers against the existing install without + replacing its privileged host files. `scripts/spaceworks-compose.sh` validates with host Python 3.10+ + when available, otherwise in the backend image under the caller's UID and supplementary groups. `scripts/update-rollback.sh` is sourced by `update.sh` to hand supported legacy snapshots to - `restore.sh` with previous immutable images. Compound envelopes are refused, including an envelope + `restore.sh` with previous immutable images. For a user-owned install, `scripts/update-rollback-host.sh` + runs the private intent and restore handoff as root through sudo while the parent retains its portable + update lock. Compound envelopes are refused, including an envelope with no sovereign slices: the existing legacy supervisor cannot restore their object ledger. The staff `PlatformUpdatePanel`, `UpdateRecoveryControls` and hand-written `platformUpdateApi` show release notes, confirmations and the existing restore-diff decision. diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 54b28828f..b7af85e46 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -12,14 +12,28 @@ curl -fsSL https://raw.githubusercontent.com/SpaceWorks-HQ/SpaceWorks/main/insta The installer defaults to `/opt/spaceworks`; override it on the `bash` process with, for example, `curl -fsSL https://raw.githubusercontent.com/SpaceWorks-HQ/SpaceWorks/main/install.sh | SPACEWORKS_DIR="$HOME/SpaceWorks" bash`. -It resolves the latest tagged GitHub release, +It resolves the latest published, non-draft, non-prerelease GitHub release built from `main`, downloads that tag's archive, and pins the published backend/frontend images to its immutable version. +The accepted version format is `X.Y.Z-main..<12-hex sha>`; there is no release channel choice. Before mutating the host it checks x86_64/aarch64 support, distribution/dependency handling, Docker, ports 80/9000/9001, 8 GiB of free disk, release availability, and existing state. Linux dependency installation supports apt, dnf/yum, pacman and zypper families identified from `/etc/os-release`. +Running the installer as a normal sudo-capable user keeps the install owned by that user, including +under `/opt/spaceworks`; sudo handles privileged host setup and directory creation where needed. +Running `curl ... | sudo bash` or `sudo bash install.sh` creates a root-owned install and uses root for +setup, updates and the version marker. Automatic updates use the install owner's crontab: root's for a +root-owned install, the user's otherwise. Rerunning the installer or updater as a normal sudo user +against a root-owned install re-enters as root; it does not transfer ownership. +Host Python is optional. Setup runs database URL configuration in the pulled backend image; the Compose +wrapper also validates inside that image when host Python is missing or older than 3.10, using the +caller's UID and supplementary groups to preserve the same ownership checks. + If `.spaceworks-version` already exists, pasting the command again offers update, module changes, both, or cancel; it never reruns first-instance setup. A non-empty install root without the marker is refused. +An update rerun downloads the latest main archive and runs its updater/helpers against the existing +installation, keeping the installed privileged host files in place. This also repairs the update path +when the installed updater predates main-only release selection. ## Production Compose @@ -120,9 +134,13 @@ The command is idempotent. It only sends reminders for issued or partially retur ## Automatic and manual upgrades Every successful push to `main` publishes matching backend/frontend images and a GitHub Release. The -release is marked latest only after both images are available. The self-host updater uses that release -as its gate, creates a PostgreSQL backup, deploys the exact immutable tag, runs migrations through the -Compose migration service, and records the version only after the readiness check passes. +release is marked latest only after both images are available. Manual host updates, the Platform panel +and the seven-day cron all select the newest published, non-draft, non-prerelease main release. +The host filters GitHub's release JSON before passing it to an older installed backend's claim logic, +so an obsolete saved release preference cannot select a different release. +The self-host updater uses that release as its gate, creates a PostgreSQL backup, deploys the exact +immutable tag, runs migrations through the Compose migration service, and records the version only +after the readiness check passes. Guided setup offers automatic checks every seven days by default. A Super Admin can then open **Staff console -> Platform settings -> Software updates** to turn automatic installation on or off, @@ -192,6 +210,12 @@ and start timestamp. A dead owner is detected and cleared on the next run. A liv never silently discarded: after verifying no update is active, pass `--override-lock`. This fallback is for install/run/update only; run `scripts/restore.sh` and other compound recovery supervisors under WSL2. +Confirmed snapshot rollback keeps the existing verified-archive and restore-diff checks. On a user-owned +Linux install, the updater uses sudo for a root helper that reads/writes private rollback intent and +runs the restore supervisor. The owner-run parent keeps its portable update lock during that handoff; +the helper takes the shared host operation lock. If sudo cannot authenticate during a scheduled run, +run `bash scripts/update.sh --no-module-changes` interactively as the install owner to resume rollback. + For a manual deployment, set `MAKERSPACE_IMAGE_TAG` to a release such as `0.5.1-main.42.a1b2c3d4e5f6`, then run: @@ -206,9 +230,10 @@ service and readiness gate. Manual dependency audit: `pip install pip-audit && p ## Publishing new images (maintainers) Every push to `main` runs `release.yml`, publishes matching backend and frontend images, and creates a -GitHub Release titled with the version from `VERSION` (for example, `v0.5.1`). Its internal tag still -identifies the exact build used by the updater. When both images succeed for the current branch head, the workflow promotes -them to the rolling `:X.Y`, `:main`, and `:latest` tags, then removes older Releases and GHCR versions. +GitHub Release titled `SpaceWorks vX.Y.Z-main..<12-hex sha>` using the version series from `VERSION`. +Its tag identifies the exact build used by the updater. When both images succeed for the current branch +head, the workflow promotes them to the rolling `:X.Y`, `:main`, and `:latest` tags, then prunes older GHCR +image versions. GitHub releases and their tags remain permanently available. The current and immediately previous builds remain available so a failed deployment can roll its application containers back automatically. diff --git a/docs/setup-for-makerspaces.md b/docs/setup-for-makerspaces.md index 8b0633f07..5b5a4ff72 100644 --- a/docs/setup-for-makerspaces.md +++ b/docs/setup-for-makerspaces.md @@ -163,7 +163,9 @@ You have two good options before giving up: `scripts/spaceworks-compose.sh bundled up -d`. - **Stop it:** `scripts/spaceworks-compose.sh bundled down` (your data is safe — it's kept in a database volume). -- **Update to a newer version:** guided installs check every seven days by default. Use +- **Update to a newer version:** guided installs pin the latest published GitHub release built from `main`. + Manual updates, the Platform panel and the seven-day cron move to the newest published, non-draft, + non-prerelease main release (`X.Y.Z-main..<12-hex sha>`), with no channel setting. Use **Staff console -> Platform settings -> Software updates** to control automatic installation or select **Update now**. Run `bash scripts/update.sh --force` on Linux, macOS or Windows Git Bash to update immediately; the interactive command also opens the current module tick list. The updater backs up diff --git a/frontend/openapi-schema.json b/frontend/openapi-schema.json index 4fb5b10b3..d5644b2e2 100644 --- a/frontend/openapi-schema.json +++ b/frontend/openapi-schema.json @@ -44451,7 +44451,7 @@ "/api/v1/admin/platform/update-settings": { "get": { "operationId": "api_v1_admin_platform_update_settings_retrieve", - "summary": "Retrieve or update production release channel and update settings", + "summary": "Retrieve or update main release update settings", "tags": [ "Platform" ], @@ -44491,7 +44491,7 @@ }, "patch": { "operationId": "api_v1_admin_platform_update_settings_partial_update", - "summary": "Retrieve or update production release channel and update settings", + "summary": "Retrieve or update main release update settings", "tags": [ "Platform" ], @@ -44658,7 +44658,7 @@ "/api/v1/admin/platform/update-settings/update-now": { "post": { "operationId": "api_v1_admin_platform_update_settings_update_now_create", - "summary": "Queue the latest production release for installation", + "summary": "Queue the newest published main release for installation", "tags": [ "Platform" ], @@ -93315,9 +93315,6 @@ "automatic_updates_enabled": { "type": "boolean" }, - "channel": { - "$ref": "#/components/schemas/PlatformUpdateSettingsChannelEnum" - }, "status": { "allOf": [ { @@ -94968,9 +94965,6 @@ "automatic_updates_enabled": { "type": "boolean" }, - "channel": { - "$ref": "#/components/schemas/PlatformUpdateSettingsChannelEnum" - }, "status": { "allOf": [ { @@ -95105,14 +95099,6 @@ "updated_at" ] }, - "PlatformUpdateSettingsChannelEnum": { - "enum": [ - "stable", - "beta" - ], - "type": "string", - "description": "* `stable` - Stable\n* `beta` - Beta" - }, "PlatformUpdateSettingsStatusEnum": { "enum": [ "idle", @@ -106527,4 +106513,4 @@ "description": "Staff requests for a superadmin to issue an API client." } ] -} \ No newline at end of file +} diff --git a/frontend/src/features/staff/PlatformUpdatePanel.tsx b/frontend/src/features/staff/PlatformUpdatePanel.tsx index 8ddb12290..4e24cf285 100644 --- a/frontend/src/features/staff/PlatformUpdatePanel.tsx +++ b/frontend/src/features/staff/PlatformUpdatePanel.tsx @@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { staffRequest } from "../../lib/api"; import { Panel } from "./StaffPanels"; import { UpdateRecoveryControls } from "./UpdateRecoveryControls"; -import { UPDATE_QUERY_KEY as QUERY_KEY, type PlatformUpdateSettings, type UpdateChannel, type UpdateStatus } from "./platformUpdateApi"; +import { UPDATE_QUERY_KEY as QUERY_KEY, type PlatformUpdateSettings, type UpdateStatus } from "./platformUpdateApi"; const STATUS_COPY: Record = { idle: "Ready", @@ -23,7 +23,7 @@ export function PlatformUpdatePanel() { : 60_000, }); const toggle = useMutation({ - mutationFn: (change: { automatic_updates_enabled?: boolean; channel?: UpdateChannel }) => + mutationFn: (change: { automatic_updates_enabled: boolean }) => staffRequest("/admin/platform/update-settings", { method: "PATCH", body: JSON.stringify(change), @@ -63,7 +63,7 @@ export function PlatformUpdatePanel() { {STATUS_COPY[data.status]}

- When enabled, the host checks every seven days and installs the newest completed release on your channel. + When enabled, the host checks every seven days and installs the newest published GitHub release built from main. Turn it off to stop automatic installation; scheduled checks and manual updates still work.

@@ -94,13 +94,6 @@ export function PlatformUpdatePanel() { - -
@@ -110,7 +103,7 @@ export function PlatformUpdatePanel() {

{data.available_version ? `SpaceWorks ${data.available_version}: what changed` : "Release changelog"}

-

{data.release_notes || "Release notes will appear after the host checks this channel."}

+

{data.release_notes || "Release notes will appear after the host checks for the newest main release."}

{data.release_url ? View GitHub release : null}
diff --git a/frontend/src/features/staff/platformUpdateApi.ts b/frontend/src/features/staff/platformUpdateApi.ts index 6b00281c2..907e8332a 100644 --- a/frontend/src/features/staff/platformUpdateApi.ts +++ b/frontend/src/features/staff/platformUpdateApi.ts @@ -1,10 +1,8 @@ import { staffRequest } from "../../lib/api"; export type UpdateStatus = "idle" | "queued" | "running" | "failed"; -export type UpdateChannel = "stable" | "beta"; export type PlatformUpdateSettings = { automatic_updates_enabled: boolean; - channel: UpdateChannel; status: UpdateStatus; current_version: string; available_version: string; diff --git a/frontend/src/generated/api.ts b/frontend/src/generated/api.ts index de33da222..96b37335e 100644 --- a/frontend/src/generated/api.ts +++ b/frontend/src/generated/api.ts @@ -6832,7 +6832,6 @@ export type PatchedPlatformStripeConnectSettings = { export type PatchedPlatformUpdateSettings = { "automatic_updates_enabled"?: boolean; - "channel"?: PlatformUpdateSettingsChannelEnum; "status"?: PlatformUpdateSettingsStatusEnum; "current_version"?: string; "available_version"?: string; @@ -7244,7 +7243,6 @@ export type PlatformTenantUsage = { export type PlatformUpdateSettings = { "automatic_updates_enabled"?: boolean; - "channel"?: PlatformUpdateSettingsChannelEnum; "status": PlatformUpdateSettingsStatusEnum; "current_version": string; "available_version": string; @@ -7269,8 +7267,6 @@ export type PlatformUpdateSettings = { "rollback_active": boolean; }; -export type PlatformUpdateSettingsChannelEnum = "stable" | "beta"; - export type PlatformUpdateSettingsStatusEnum = "idle" | "queued" | "running" | "failed"; export type PlatformUpdatesHealth = { diff --git a/install.sh b/install.sh index 0488666d1..f526784e5 100755 --- a/install.sh +++ b/install.sh @@ -1,12 +1,44 @@ #!/usr/bin/env bash -# Pinned, no-git SpaceWorks installer. +# Pinned, no-git SpaceWorks installer. It is fetched alone (curl | bash), so it is one +# self-contained file by necessity and may exceed the ~300-line ceiling for that reason. set -euo pipefail +show_guide() { + cat <<'EOF' +SpaceWorks installer - installs the latest SpaceWorks release built from GitHub main. + +What you need first + - A 64-bit machine (x86_64 or arm64) running Linux (Debian/Ubuntu, Fedora/RHEL/Alma/ + Rocky, Arch or openSUSE), macOS, or Windows Git Bash/WSL. + - Linux: root, or a user with sudo (you may be asked for your password). The installer + installs Docker, Docker Compose, curl and tar for you if they are missing. + - macOS/Windows: Docker Desktop installed and running before you start. + - Internet access to github.com and ghcr.io. + - At least 8 GiB free disk and TCP ports 80, 9000 and 9001 free. + - A terminal: setup asks questions. Have ready the web address (host name or IP), + your makerspace name, and the admin username, email and password (or let it + generate one). Google sign-in and Stripe keys are optional and can be skipped. + +What it does + 1. Checks everything above. Nothing on the machine changes until those checks pass. + 2. Installs missing packages (Linux) and downloads the release into /opt/spaceworks + (set SPACEWORKS_DIR=/your/path to use another folder). + 3. Runs guided setup: your details, which modules to turn on, then starts SpaceWorks in + Docker and offers a weekly automatic update from the main release. + Run it again later to update or change modules; it detects the existing install. +EOF +} +case "${1:-}" in + -h|--help) show_guide; exit 0 ;; + "") ;; + *) printf 'Usage: install.sh [--help]\n' >&2; exit 64 ;; +esac REPOSITORY="SpaceWorks-HQ/SpaceWorks" RELEASE_API="https://api.github.com/repos/$REPOSITORY/releases/latest" INSTALL_DIR="${SPACEWORKS_DIR:-/opt/spaceworks}" MIN_FREE_KB=$((8 * 1024 * 1024)) RUN_ROOT=() +SUDO=() USE_DOCKER_GROUP=0 RUN_INSTALLED_AS_ROOT=0 ACTION=fresh @@ -15,7 +47,6 @@ VERSION="" STAGE_DIR="" ARCHIVE="" CREATED_PARENT=0 - say() { printf '[SpaceWorks installer] %s\n' "$*"; } die() { printf '[SpaceWorks installer] ERROR: %s\n' "$*" >&2; exit 1; } prompt() { @@ -27,29 +58,29 @@ prompt() { } cleanup() { if [[ -n "$STAGE_DIR" && "$STAGE_DIR" == */.spaceworks-stage.* ]]; then - "${RUN_ROOT[@]}" rm -rf -- "$STAGE_DIR" 2>/dev/null || true + ${RUN_ROOT[@]+"${RUN_ROOT[@]}"} rm -rf -- "$STAGE_DIR" 2>/dev/null || true fi [[ -z "$ARCHIVE" ]] || rm -f -- "$ARCHIVE" if [[ "$CREATED_PARENT" == 1 ]]; then - "${RUN_ROOT[@]}" rmdir -- "$(dirname "$INSTALL_DIR")" 2>/dev/null || true + ${RUN_ROOT[@]+"${RUN_ROOT[@]}"} rmdir -- "$(dirname "$INSTALL_DIR")" 2>/dev/null || true fi } trap cleanup EXIT - case "$INSTALL_DIR" in - ""|/|*"'"*) die "SPACEWORKS_DIR must be a specific path and cannot contain a single quote." ;; + ""|/|*"'"*|*$'\n'*) die "SPACEWORKS_DIR must be a specific path without single quotes or newlines." ;; esac - +while [[ "$INSTALL_DIR" == */ ]]; do INSTALL_DIR="${INSTALL_DIR%/}"; done +[[ -n "$INSTALL_DIR" ]] || die "SPACEWORKS_DIR cannot be the filesystem root." +[[ "$INSTALL_DIR" == /* ]] || INSTALL_DIR="$PWD/$INSTALL_DIR" +show_guide say "Preflight only—nothing will be installed until all checks pass." say "Checking architecture, operating system, dependencies, Docker, release availability, ports, disk space, and existing state." - machine="$(uname -m)" case "$machine" in x86_64|amd64) ARCH=x86_64 ;; aarch64|arm64) ARCH=aarch64 ;; *) die "Unsupported architecture '$machine'. SpaceWorks release images support only x86_64 and aarch64." ;; esac - kernel="$(uname -s)" PACKAGE_FAMILY="" case "$kernel" in @@ -75,7 +106,19 @@ case "$kernel" in *) die "Unsupported host '$kernel'. Use Linux, Windows Git Bash, or macOS with Docker Desktop." ;; esac say "Architecture: $ARCH; host: $HOST_KIND${PACKAGE_FAMILY:+/$PACKAGE_FAMILY}." - +# Host privilege and install-directory privilege are independent decisions. +if [[ "$HOST_KIND" == linux && "$(id -u)" != 0 ]]; then + command -v sudo >/dev/null 2>&1 && SUDO=(sudo) + # Releases stage beside the install, including on a user-owned /opt rerun. + write_path="$(dirname "$INSTALL_DIR")" + while [[ ! -e "$write_path" ]]; do write_path="$(dirname "$write_path")"; done + if [[ ! -w "$write_path" || ( -d "$INSTALL_DIR" && ! -x "$INSTALL_DIR" ) ]]; then + ((${#SUDO[@]} > 0)) || die "Writing $INSTALL_DIR requires sudo. Install sudo or run this installer as root." + RUN_ROOT=(sudo) + fi +elif [[ "$(id -u)" == 0 ]]; then + RUN_INSTALLED_AS_ROOT=1 +fi missing=() MISSING_DOCKER=0 MISSING_COMPOSE=0 @@ -94,16 +137,11 @@ if ((${#missing[@]} > 0)); then die "Missing: ${missing[*]}. Install Docker Desktop (with Compose V2), curl, and tar, then rerun." fi if [[ "$(id -u)" != 0 ]]; then - command -v sudo >/dev/null 2>&1 || die "Missing ${missing[*]} and sudo is unavailable. Install them as root, then rerun." - RUN_ROOT=(sudo) + ((${#SUDO[@]} > 0)) || die "Missing ${missing[*]} and sudo is unavailable. Install them as root, then rerun." fi say "After preflight, the $PACKAGE_FAMILY family installer will install/ensure: curl, tar, Docker Engine, and Docker Compose V2." else say "Dependencies are already present." - if [[ "$HOST_KIND" == linux && "$(id -u)" != 0 && ! -w "$(dirname "$INSTALL_DIR")" ]]; then - command -v sudo >/dev/null 2>&1 || die "Writing $INSTALL_DIR requires root and sudo is unavailable." - RUN_ROOT=(sudo) - fi fi if command -v docker >/dev/null 2>&1 && ! docker info >/dev/null 2>&1; then if [[ "$HOST_KIND" == linux ]]; then @@ -112,9 +150,17 @@ if command -v docker >/dev/null 2>&1 && ! docker info >/dev/null 2>&1; then die "Docker Desktop is installed but not running. Start it and wait until it is ready, then rerun; no state was changed." fi fi - -if [[ -f "$INSTALL_DIR/.spaceworks-version" ]]; then - ACTION="$(prompt $'Existing SpaceWorks install detected.\n 1) Update to latest release\n 2) Change modules\n 3) Update and change modules\n 4) Cancel\nChoose [4]: ')" +if ${RUN_ROOT[@]+"${RUN_ROOT[@]}"} test -f "$INSTALL_DIR/.spaceworks-version"; then + ACTION="$(prompt " +SpaceWorks is already installed in $INSTALL_DIR (version $(${RUN_ROOT[@]+"${RUN_ROOT[@]}"} cat "$INSTALL_DIR/.spaceworks-version" 2>/dev/null || echo unknown)). +What do you want to do? + 1) Update to the latest release - backs up the database, moves to the newest main + release, runs migrations and restarts. Your modules and data stay as they are. + 2) Change modules only - turn optional modules (events, bookings, machines, membership, + payments...) on or off. No new version is downloaded. + 3) Update, then change modules - option 1 followed by option 2. + 4) Cancel - exit without changing anything. +Choose 1-4 [4]: ")" ACTION="${ACTION:-4}" case "$ACTION" in 1) ACTION=update ;; @@ -123,14 +169,15 @@ if [[ -f "$INSTALL_DIR/.spaceworks-version" ]]; then 4) say "Cancelled; existing deployment was not changed."; exit 0 ;; *) die "Invalid menu choice; existing deployment was not changed." ;; esac - [[ -f "$INSTALL_DIR/scripts/update.sh" ]] || die "The version marker exists but scripts/update.sh is missing. Repair the install manually." - if [[ "$(id -u)" != 0 && ! -w "$INSTALL_DIR" ]]; then + ${RUN_ROOT[@]+"${RUN_ROOT[@]}"} test -f "$INSTALL_DIR/scripts/update.sh" || die "The version marker exists but scripts/update.sh is missing. Repair the install manually." + if [[ "$(id -u)" != 0 ]] && { [[ ! -w "$INSTALL_DIR" ]] \ + || { [[ "$HOST_KIND" == linux ]] && [[ "$(stat -c %u "$INSTALL_DIR")" == 0 ]]; }; }; then command -v sudo >/dev/null 2>&1 || die "The existing install is not writable and sudo is unavailable." RUN_ROOT=(sudo) RUN_INSTALLED_AS_ROOT=1 fi -elif [[ -e "$INSTALL_DIR" ]]; then - if [[ -d "$INSTALL_DIR" && -z "$(find "$INSTALL_DIR" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]]; then +elif ${RUN_ROOT[@]+"${RUN_ROOT[@]}"} test -e "$INSTALL_DIR"; then + if ${RUN_ROOT[@]+"${RUN_ROOT[@]}"} test -d "$INSTALL_DIR" && [[ -z "$(${RUN_ROOT[@]+"${RUN_ROOT[@]}"} find "$INSTALL_DIR" -mindepth 1 -maxdepth 1 -print -quit)" ]]; then EMPTY_INSTALL_DIR=1 else die "$INSTALL_DIR already contains state but has no .spaceworks-version marker. Refusing to reuse or overwrite it." @@ -138,7 +185,6 @@ elif [[ -e "$INSTALL_DIR" ]]; then else EMPTY_INSTALL_DIR=0 fi - port_in_use() { local port="$1" hex file if [[ "$HOST_KIND" == linux ]]; then @@ -158,66 +204,67 @@ if [[ "$ACTION" == fresh ]]; then done say "Required TCP ports 80, 9000, and 9001 are free." fi - disk_path="$INSTALL_DIR" while [[ ! -e "$disk_path" && "$disk_path" != / ]]; do disk_path="$(dirname "$disk_path")"; done available_kb="$(df -Pk "$disk_path" | awk 'NR == 2 {print $4}')" [[ "$available_kb" =~ ^[0-9]+$ ]] || die "Could not determine free disk space for $INSTALL_DIR." ((available_kb >= MIN_FREE_KB)) || die "At least 8 GiB free is required; $disk_path has $((available_kb / 1024)) MiB." say "Disk-space check passed." - -if [[ "$ACTION" != modules ]]; then - release_json="$(curl --fail --silent --show-error --location \ - --header 'Accept: application/vnd.github+json' \ - --header 'User-Agent: spaceworks-curl-installer' "$RELEASE_API")" \ - || die "Could not read the latest tagged GitHub release." - RELEASE_TAG="$(printf '%s' "$release_json" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)" - VERSION="${RELEASE_TAG#v}" - [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-main\.[0-9]+\.[0-9a-f]{12}$ ]] \ - || die "GitHub returned an unexpected latest release tag: ${RELEASE_TAG:-}" - say "Pinned release: $RELEASE_TAG." +say "Host preflight passed; preparing dependencies and checking the release." +if [[ "$ACTION" == fresh ]]; then + plan="Install SpaceWorks into $INSTALL_DIR" + ((${#missing[@]} == 0)) || plan="Install ${missing[*]}, then install SpaceWorks into $INSTALL_DIR" + reply="$(prompt " +$plan and start guided setup? [Y/n]: ")" + [[ ! "$reply" =~ ^[Nn] ]] || { say "Cancelled; nothing was changed."; exit 0; } fi -say "All preflight checks passed; state-changing work starts now." - install_linux_dependencies() { case "$PACKAGE_FAMILY" in apt) - "${RUN_ROOT[@]}" apt-get update - "${RUN_ROOT[@]}" env DEBIAN_FRONTEND=noninteractive apt-get install -y curl tar + ${SUDO[@]+"${SUDO[@]}"} apt-get update + ${SUDO[@]+"${SUDO[@]}"} env DEBIAN_FRONTEND=noninteractive apt-get install -y curl tar [[ "$MISSING_DOCKER" == 0 ]] \ - || "${RUN_ROOT[@]}" env DEBIAN_FRONTEND=noninteractive apt-get install -y docker.io + || ${SUDO[@]+"${SUDO[@]}"} env DEBIAN_FRONTEND=noninteractive apt-get install -y docker.io if [[ "$MISSING_COMPOSE" == 1 ]]; then - "${RUN_ROOT[@]}" env DEBIAN_FRONTEND=noninteractive apt-get install -y docker-compose-v2 \ - || "${RUN_ROOT[@]}" env DEBIAN_FRONTEND=noninteractive apt-get install -y docker-compose-plugin \ + ${SUDO[@]+"${SUDO[@]}"} env DEBIAN_FRONTEND=noninteractive apt-get install -y docker-compose-v2 \ + || ${SUDO[@]+"${SUDO[@]}"} env DEBIAN_FRONTEND=noninteractive apt-get install -y docker-compose-plugin \ || true fi ;; dnf) manager=dnf; command -v dnf >/dev/null 2>&1 || manager=yum - "${RUN_ROOT[@]}" "$manager" install -y curl tar + ${SUDO[@]+"${SUDO[@]}"} "$manager" install -y curl tar if [[ "$MISSING_DOCKER" == 1 ]]; then repo_os=centos [[ "${ID:-}" == fedora ]] && repo_os=fedora [[ "${ID:-}" == rhel ]] && repo_os=rhel if [[ "$manager" == dnf ]]; then - "${RUN_ROOT[@]}" dnf install -y dnf-plugins-core - "${RUN_ROOT[@]}" dnf config-manager --add-repo "https://download.docker.com/linux/$repo_os/docker-ce.repo" \ - || "${RUN_ROOT[@]}" dnf config-manager addrepo --from-repofile="https://download.docker.com/linux/$repo_os/docker-ce.repo" + ${SUDO[@]+"${SUDO[@]}"} dnf install -y dnf-plugins-core + ${SUDO[@]+"${SUDO[@]}"} dnf config-manager --add-repo "https://download.docker.com/linux/$repo_os/docker-ce.repo" \ + || ${SUDO[@]+"${SUDO[@]}"} dnf config-manager addrepo --from-repofile="https://download.docker.com/linux/$repo_os/docker-ce.repo" else - "${RUN_ROOT[@]}" yum install -y yum-utils - "${RUN_ROOT[@]}" yum-config-manager --add-repo "https://download.docker.com/linux/$repo_os/docker-ce.repo" + ${SUDO[@]+"${SUDO[@]}"} yum install -y yum-utils + ${SUDO[@]+"${SUDO[@]}"} yum-config-manager --add-repo "https://download.docker.com/linux/$repo_os/docker-ce.repo" fi - "${RUN_ROOT[@]}" "$manager" install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + ${SUDO[@]+"${SUDO[@]}"} "$manager" install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin else - "${RUN_ROOT[@]}" "$manager" install -y docker-compose-plugin || true + ${SUDO[@]+"${SUDO[@]}"} "$manager" install -y docker-compose-plugin || true fi ;; - pacman) "${RUN_ROOT[@]}" pacman -Sy --noconfirm --needed curl tar docker docker-compose ;; - zypper) "${RUN_ROOT[@]}" zypper --non-interactive install curl tar docker docker-compose ;; + pacman) ${SUDO[@]+"${SUDO[@]}"} pacman -Sy --noconfirm --needed curl tar docker docker-compose ;; + zypper) ${SUDO[@]+"${SUDO[@]}"} zypper --non-interactive install curl tar docker docker-compose ;; esac } if ((${#missing[@]} > 0)); then install_linux_dependencies; fi - +release_json="$(curl --fail --silent --show-error --location \ + --header 'Accept: application/vnd.github+json' \ + --header 'User-Agent: spaceworks-curl-installer' "$RELEASE_API")" \ + || die "Could not read the latest tagged GitHub release." +RELEASE_TAG="$(printf '%s' "$release_json" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)" +VERSION="${RELEASE_TAG#v}" +[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-main\.[0-9]+\.[0-9a-f]{12}$ ]] \ + || die "GitHub returned an unexpected latest release tag: ${RELEASE_TAG:-}" +say "Pinned release: $RELEASE_TAG." if ! docker compose version >/dev/null 2>&1; then compose_json="$(curl --fail --silent --show-error --location \ --header 'Accept: application/vnd.github+json' \ @@ -225,66 +272,74 @@ if ! docker compose version >/dev/null 2>&1; then https://api.github.com/repos/docker/compose/releases/latest)" compose_tag="$(printf '%s' "$compose_json" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)" [[ "$compose_tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "Could not resolve a Docker Compose V2 plugin release." - "${RUN_ROOT[@]}" install -m 0755 -d /usr/local/lib/docker/cli-plugins - "${RUN_ROOT[@]}" curl --fail --silent --show-error --location \ + ${SUDO[@]+"${SUDO[@]}"} install -m 0755 -d /usr/local/lib/docker/cli-plugins + ${SUDO[@]+"${SUDO[@]}"} curl --fail --silent --show-error --location \ "https://github.com/docker/compose/releases/download/$compose_tag/docker-compose-linux-$ARCH" \ -o /usr/local/lib/docker/cli-plugins/docker-compose - "${RUN_ROOT[@]}" chmod 755 /usr/local/lib/docker/cli-plugins/docker-compose + ${SUDO[@]+"${SUDO[@]}"} chmod 755 /usr/local/lib/docker/cli-plugins/docker-compose fi - if ! docker info >/dev/null 2>&1; then if [[ "$HOST_KIND" == linux ]] && command -v systemctl >/dev/null 2>&1; then - "${RUN_ROOT[@]}" systemctl enable --now docker >/dev/null 2>&1 || true + ${SUDO[@]+"${SUDO[@]}"} systemctl enable --now docker >/dev/null 2>&1 || true fi fi if ! docker info >/dev/null 2>&1; then - if [[ "$HOST_KIND" == linux && "$(id -u)" != 0 ]] && "${RUN_ROOT[@]}" docker info >/dev/null 2>&1; then - operator="$(id -un)" - "${RUN_ROOT[@]}" usermod -aG docker "$operator" - command -v sg >/dev/null 2>&1 || die "Docker was installed, but group membership needs a new login. Sign out, sign in, and rerun this installer." - USE_DOCKER_GROUP=1 - say "Added $operator to the docker group; setup will use that group for this run." + if [[ "$HOST_KIND" == linux && "$(id -u)" != 0 && ${#SUDO[@]} -gt 0 ]] && ${SUDO[@]+"${SUDO[@]}"} docker info >/dev/null 2>&1; then + if [[ "$RUN_INSTALLED_AS_ROOT" == 0 ]]; then + operator="$(id -un)" + ${SUDO[@]+"${SUDO[@]}"} usermod -aG docker "$operator" \ + || die "Run sudo usermod -aG docker '$operator' (if the group is missing, run sudo groupadd docker first), sign out and back in, then rerun." + command -v sg >/dev/null 2>&1 || die "Docker access needs refreshed group membership. Sign out, sign in, and rerun this installer." + sg docker -c 'docker info >/dev/null 2>&1' || die "Docker is still inaccessible inside the docker group. Sign out and back in, test docker info, and rerun; if needed, configure Docker's socket group before retrying." + USE_DOCKER_GROUP=1 + say "Added $operator to the docker group; setup will use that group for this run." + fi else - die "Docker is installed but not running or not accessible. Start Docker, then rerun." + die "Docker is inaccessible. Run sudo systemctl start docker; then test sudo docker info. For user access, run sudo usermod -aG docker \$(id -un), sign out and back in, then rerun." fi fi docker compose version >/dev/null 2>&1 || die "Docker Compose V2 is still unavailable after dependency setup." - run_installed() { local command="$1" if [[ "$RUN_INSTALLED_AS_ROOT" == 1 ]]; then - "${RUN_ROOT[@]}" bash -c "cd '$INSTALL_DIR' && $command" < /dev/tty + ${RUN_ROOT[@]+"${RUN_ROOT[@]}"} bash -c "cd '$INSTALL_DIR' && $command" < /dev/tty elif [[ "$USE_DOCKER_GROUP" == 1 ]]; then sg docker -c "cd '$INSTALL_DIR' && $command" < /dev/tty else (cd "$INSTALL_DIR" && bash -c "$command" < /dev/tty) fi } -case "$ACTION" in - update) run_installed "bash scripts/update.sh --force --no-module-changes"; exit 0 ;; - modules) run_installed "bash scripts/update.sh --modules-only"; exit 0 ;; - both) run_installed "bash scripts/update.sh --force --modules"; exit 0 ;; -esac - ARCHIVE="$(mktemp)" curl --fail --silent --show-error --location \ "https://github.com/$REPOSITORY/archive/refs/tags/$RELEASE_TAG.tar.gz" -o "$ARCHIVE" parent="$(dirname "$INSTALL_DIR")" -if [[ ! -d "$parent" ]]; then "${RUN_ROOT[@]}" mkdir -p -- "$parent"; CREATED_PARENT=1; fi -STAGE_DIR="$("${RUN_ROOT[@]}" mktemp -d "$parent/.spaceworks-stage.XXXXXX")" -"${RUN_ROOT[@]}" tar -xzf "$ARCHIVE" -C "$STAGE_DIR" --strip-components=1 +if [[ ! -d "$parent" ]]; then ${RUN_ROOT[@]+"${RUN_ROOT[@]}"} mkdir -p -- "$parent"; CREATED_PARENT=1; fi +STAGE_DIR="$(${RUN_ROOT[@]+"${RUN_ROOT[@]}"} mktemp -d "$parent/.spaceworks-stage.XXXXXX")" +${RUN_ROOT[@]+"${RUN_ROOT[@]}"} tar -xzf "$ARCHIVE" -C "$STAGE_DIR" --strip-components=1 --no-same-owner +if [[ "$(id -u)" != 0 && ${#RUN_ROOT[@]} -gt 0 ]]; then + ${RUN_ROOT[@]+"${RUN_ROOT[@]}"} chown -R "$(id -u):$(id -g)" "$STAGE_DIR" +fi [[ -f "$STAGE_DIR/setup.sh" && -f "$STAGE_DIR/docker-compose.prod.yml" \ && -f "$STAGE_DIR/scripts/update.sh" && -f "$STAGE_DIR/scripts/spaceworks-compose.sh" \ - && -f "$STAGE_DIR/scripts/module-selection.sh" && -f "$STAGE_DIR/scripts/update-lock.sh" ]] \ + && -f "$STAGE_DIR/scripts/module-selection.sh" && -f "$STAGE_DIR/scripts/update-lock.sh" \ + && -f "$STAGE_DIR/scripts/install-owner.sh" && -f "$STAGE_DIR/scripts/setup-first-run.sh" \ + && -f "$STAGE_DIR/scripts/filter-main-releases.py" && -f "$STAGE_DIR/scripts/update-rollback-host.sh" \ + && -f "$STAGE_DIR/scripts/validate-compose-wrapper.py" && -f "$STAGE_DIR/scripts/restore.sh" ]] \ || die "The pinned release archive is missing required installer files." -"${RUN_ROOT[@]}" chmod 755 "$STAGE_DIR/install.sh" "$STAGE_DIR/scripts/spaceworks-compose.sh" -if [[ "${EMPTY_INSTALL_DIR:-0}" == 1 ]]; then "${RUN_ROOT[@]}" rmdir -- "$INSTALL_DIR"; fi -"${RUN_ROOT[@]}" mv -- "$STAGE_DIR" "$INSTALL_DIR" -STAGE_DIR="" -if [[ "$(id -u)" != 0 && ${#RUN_ROOT[@]} -gt 0 ]]; then - "${RUN_ROOT[@]}" chown -R "$(id -u):$(id -g)" "$INSTALL_DIR" +${RUN_ROOT[@]+"${RUN_ROOT[@]}"} chmod 755 "$STAGE_DIR/install.sh" "$STAGE_DIR/scripts/spaceworks-compose.sh" +# Re-entry uses the pinned release's updater/helpers, never an older installed updater. +case "$ACTION" in + update) update_options="--force --no-module-changes" ;; + modules) update_options="--modules-only" ;; + both) update_options="--force --modules" ;; +esac +if [[ "$ACTION" != fresh ]]; then + run_installed "SPACEWORKS_UPDATE_INSTALL_DIR='$INSTALL_DIR' bash '$STAGE_DIR/scripts/update.sh' $update_options" + exit 0 fi - +if [[ "${EMPTY_INSTALL_DIR:-0}" == 1 ]]; then ${RUN_ROOT[@]+"${RUN_ROOT[@]}"} rmdir -- "$INSTALL_DIR"; fi +${RUN_ROOT[@]+"${RUN_ROOT[@]}"} mv -- "$STAGE_DIR" "$INSTALL_DIR" +STAGE_DIR="" run_installed "MAKERSPACE_IMAGE_TAG='$VERSION' bash setup.sh" -printf '%s\n' "$VERSION" > "$INSTALL_DIR/.spaceworks-version" +run_installed "printf '%s\\n' '$VERSION' > .spaceworks-version" say "Installed SpaceWorks $VERSION in $INSTALL_DIR." diff --git a/scripts/filter-main-releases.py b/scripts/filter-main-releases.py new file mode 100644 index 000000000..72a569f2e --- /dev/null +++ b/scripts/filter-main-releases.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Constrain old installed backends to published main releases before claiming.""" + +import json +import re +import sys + + +TAG_PATTERN = re.compile(r"v[0-9]+\.[0-9]+\.[0-9]+-main\.[0-9]+\.[0-9a-f]{12}") +MAX_RESPONSE_SIZE = 2_000_000 + + +def filter_releases(raw): + if len(raw) > MAX_RESPONSE_SIZE: + raise ValueError("The GitHub release response exceeds its size limit.") + try: + releases = json.loads(raw) + except (ValueError, RecursionError) as exc: + raise ValueError("GitHub returned malformed release JSON.") from exc + if not isinstance(releases, list): + raise ValueError("GitHub did not return a release list.") + candidates = [ + release for release in releases[:100] + if isinstance(release, dict) + and release.get("draft") is False + and release.get("prerelease") is False + and isinstance(release.get("tag_name"), str) + and TAG_PATTERN.fullmatch(release["tag_name"]) + ] + if not candidates: + raise ValueError("No published, non-prerelease main release is available.") + return candidates + + +def main(): + try: + releases = filter_releases(sys.stdin.read(MAX_RESPONSE_SIZE + 1)) + except ValueError as exc: + print(f"Release check failed: {exc}", file=sys.stderr) + return 1 + # Keep publication dates and release notes for the backend's existing claim + # logic. Even a backend with a persisted beta preference now only sees main. + json.dump(releases, sys.stdout) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/init-host-orchestration.sh b/scripts/init-host-orchestration.sh index 6ca43e9a0..bed82eaba 100644 --- a/scripts/init-host-orchestration.sh +++ b/scripts/init-host-orchestration.sh @@ -4,6 +4,8 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" +source "$ROOT/scripts/install-owner.sh" +require_install_owner "$ROOT/scripts/init-host-orchestration.sh" "$@" [[ -f .env ]] || { echo "Copy .env.example to .env and fill every required secret first." >&2 exit 64 diff --git a/scripts/install-auto-update.sh b/scripts/install-auto-update.sh index 387d0b884..c4f6abe3f 100644 --- a/scripts/install-auto-update.sh +++ b/scripts/install-auto-update.sh @@ -2,19 +2,20 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "$ROOT/scripts/install-owner.sh" +require_install_owner "$ROOT/scripts/install-auto-update.sh" "$@" case "$ROOT" in - *"'"*) printf 'ERROR: the install path cannot contain a single quote.\n' >&2; exit 1 ;; + *"'"*|*$'\n'*) printf 'ERROR: the install path cannot contain single quotes or newlines.\n' >&2; exit 1 ;; esac command -v crontab >/dev/null 2>&1 || { - printf 'ERROR: crontab is unavailable; schedule scripts/update.sh every seven days with your service manager.\n' >&2 + printf 'ERROR: crontab is unavailable. Install your distribution cron package and enable its service, then rerun bash %q. Alternatively schedule bash %q every seven days as %s.\n' "$0" "$ROOT/scripts/update.sh" "$(id -un)" >&2 exit 1 } COMPOSE=("$ROOT/scripts/spaceworks-compose.sh" bundled) -"${COMPOSE[@]}" run --rm --no-deps -T backend --role management python manage.py update_control set-auto on >/dev/null MARKER="# Space Works automatic production update" -JOB="0 3 * * 0 cd '$ROOT' && bash '$ROOT/scripts/update.sh' >> '$ROOT/backups/auto-update.log' 2>&1" +JOB="0 3 * * 0 PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; cd '$ROOT' && bash '$ROOT/scripts/update.sh' >> '$ROOT/backups/auto-update.log' 2>&1" existing="$(crontab -l 2>/dev/null || true)" filtered="$(printf '%s\n' "$existing" | grep -v -F "$MARKER" | grep -v -F "$ROOT/scripts/update.sh" || true)" @@ -24,5 +25,6 @@ mkdir -p "$ROOT/backups" printf '%s\n' "$MARKER" printf '%s\n' "$JOB" } | sed '/^[[:space:]]*$/N;/^\n$/D' | crontab - +${COMPOSE[@]+"${COMPOSE[@]}"} run --rm --no-deps -T backend --role management python manage.py update_control set-auto on >/dev/null -printf 'Automatic Space Works updates are enabled and checked every seven days.\n' +printf 'Automatic Space Works updates check the newest main release every seven days in %s\x27s crontab.\n' "$(id -un)" diff --git a/scripts/install-owner.sh b/scripts/install-owner.sh new file mode 100644 index 000000000..e595681e3 --- /dev/null +++ b/scripts/install-owner.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Sourced by host entry points. The directory owner also owns setup and cron. +require_install_owner() { + local entrypoint="$1"; shift + local owner current name key + local forwarded=() + [[ "$(uname -s)" == Linux ]] || return 0 + owner="$(stat -c %u "$ROOT")" + current="$(id -u)" + [[ "$owner" != "$current" ]] || return 0 + if [[ "$owner" == 0 ]]; then + command -v sudo >/dev/null 2>&1 || { + printf 'ERROR: %s is root-owned. Run sudo bash %q' "$ROOT" "$entrypoint" >&2 + printf ' %q' "$@" >&2; printf '\n' >&2 + exit 1 + } + # Preserve only the host configuration knobs, never the ambient DB pointer. + for key in SPACEWORKS_OPS_HOST_DIR SPACEWORKS_HOST_STATE_DIR \ + SPACEWORKS_COMPOSE_LAYER SPACEWORKS_COMPOSE_BUILD_LAYER \ + SPACEWORKS_STATIC_ENV_FILE SPACEWORKS_UPDATE_INSTALL_DIR \ + MAKERSPACE_IMAGE_TAG MAKERSPACE_BACKEND_IMAGE; do + if [[ -n "${!key:-}" ]]; then forwarded+=("$key=${!key}"); fi + done + printf 'Root-owned install: continuing as root using sudo.\n' + exec sudo env ${forwarded[@]+"${forwarded[@]}"} bash "$entrypoint" "$@" + fi + name="$(stat -c %U "$ROOT")" + printf 'ERROR: %s belongs to %s (UID %s). Sign in as that user and run bash %q' \ + "$ROOT" "$name" "$owner" "$entrypoint" >&2 + printf ' %q' "$@" >&2; printf '\n' >&2 + exit 1 +} diff --git a/scripts/module-selection.sh b/scripts/module-selection.sh index 41f96dac3..fddafe70a 100644 --- a/scripts/module-selection.sh +++ b/scripts/module-selection.sh @@ -6,8 +6,8 @@ module_warn() { warn "$@"; } module_list_output() { local slug="${1:-}" args=() [[ -n "$slug" ]] && args=(--makerspace "$slug") - MODULE_OUTPUT="$("${COMPOSE[@]}" run --rm --no-deps -T backend --role management \ - python manage.py list_modules "${args[@]}" 2>&1)" && return 0 + MODULE_OUTPUT="$(${COMPOSE[@]+"${COMPOSE[@]}"} run --rm --no-deps -T backend --role management \ + python manage.py list_modules ${args[@]+"${args[@]}"} 2>&1)" && return 0 case "$MODULE_OUTPUT" in *"More than one makerspace exists; pass --makerspace ."*) return 2 ;; *"No makerspaces exist yet."*) @@ -30,7 +30,7 @@ module_targets() { local output slug MODULE_TARGETS=() if [[ "${MODULE_ALL_MAKERSPACES:-0}" == 1 ]]; then - if ! output="$("${COMPOSE[@]}" run --rm --no-deps -T backend --role management \ + if ! output="$(${COMPOSE[@]+"${COMPOSE[@]}"} run --rm --no-deps -T backend --role management \ python manage.py shell -c \ 'from apps.makerspaces.models import Makerspace; [print(f"SPACEWORKS_MAKERSPACE:{slug}") for slug in Makerspace.objects.order_by("id").values_list("slug", flat=True)]' 2>&1)"; then module_warn "The backend is unreachable, so makerspaces could not be listed." @@ -108,14 +108,14 @@ module_change_one() { say "No optional modules are available for $slug; core modules remain on." return 0 } - orig=("${state[@]}") + orig=(${state[@]+"${state[@]}"}) if [[ "${MODULE_MODE:-interactive}" == all ]]; then for ((i=0; i<${#state[@]}; i++)); do state[i]=x; done fi if [[ -n "${MODULE_WITHOUT:-}" ]]; then IFS=, read -r -a without <<< "$MODULE_WITHOUT" - for without_key in "${without[@]}"; do + for without_key in ${without[@]+"${without[@]}"}; do [[ -n "$without_key" ]] || continue found=0 for ((i=0; i<${#keys[@]}; i++)); do @@ -136,7 +136,7 @@ module_change_one() { [[ -n "$list" ]] || return 0 local forced=() IFS=, read -r -a forced <<< "$list" - for key in "${forced[@]}"; do + for key in ${forced[@]+"${forced[@]}"}; do [[ -n "$key" ]] || continue for ((j=0; j<${#keys[@]}; j++)); do if [[ "${keys[j]}" == "$key" ]]; then state[j]="$want"; break; fi @@ -179,7 +179,7 @@ module_change_one() { for ((i=0; i<${#keys[@]}; i++)); do [[ "${state[$i]}" != "${orig[$i]}" ]] || continue if [[ "${state[$i]}" == x ]]; then - if result="$("${COMPOSE[@]}" run --rm --no-deps -T backend --role management \ + if result="$(${COMPOSE[@]+"${COMPOSE[@]}"} run --rm --no-deps -T backend --role management \ python manage.py install_module "${keys[$i]}" --makerspace "$slug" 2>&1)"; then say "Installed ${keys[i]} for $slug."; orig[i]=x; changed=1 elif [[ "$pass" == 2 ]]; then @@ -187,7 +187,7 @@ module_change_one() { apply_failed=1 fi else - if result="$("${COMPOSE[@]}" run --rm --no-deps -T backend --role management \ + if result="$(${COMPOSE[@]+"${COMPOSE[@]}"} run --rm --no-deps -T backend --role management \ python manage.py uninstall_module "${keys[$i]}" --makerspace "$slug" 2>&1)"; then say "Disabled ${keys[i]} for $slug; its data is retained."; orig[i]=" "; changed=1 elif [[ "$pass" == 2 ]]; then @@ -205,7 +205,7 @@ module_change_one() { change_modules() { local slug failed=0 module_targets || return 1 - for slug in "${MODULE_TARGETS[@]}"; do + for slug in ${MODULE_TARGETS[@]+"${MODULE_TARGETS[@]}"}; do module_change_one "$slug" || failed=1 done return "$failed" diff --git a/scripts/restore.sh b/scripts/restore.sh index 671ad202a..374082243 100755 --- a/scripts/restore.sh +++ b/scripts/restore.sh @@ -2,16 +2,18 @@ # Privileged host supervisor for an app-recorded Phase 5A restore intent. set -euo pipefail -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +RESTORE_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="${SPACEWORKS_UPDATE_INSTALL_DIR:-$(cd "$RESTORE_SCRIPT_DIR/.." && pwd)}" cd "$ROOT" +export SPACEWORKS_UPDATE_INSTALL_DIR="$ROOT" -COMPOSE=("$ROOT/scripts/spaceworks-compose.sh" bundled) +COMPOSE=("$RESTORE_SCRIPT_DIR/spaceworks-compose.sh" bundled) OPS_DIR="${SPACEWORKS_OPS_HOST_DIR:-/var/lib/spaceworks/ops}" RESTORE_ID="${1:-}" RESTORE_IMAGE_TAG="" if [[ "${2:-}" == --update-image=* ]]; then RESTORE_IMAGE_TAG="${2#*=}" - [[ "$RESTORE_IMAGE_TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+-(main|beta)\.[0-9]+\.[0-9a-f]{12}$ ]] || exit 64 + [[ "$RESTORE_IMAGE_TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+-main\.[0-9]+\.[0-9a-f]{12}$ ]] || exit 64 fi OPS_WORK_DIR="$OPS_DIR/work" IDENTITY_FILE="${BACKUP_AGE_IDENTITY_FILE:-$OPS_WORK_DIR/age-identity.txt}" @@ -24,7 +26,7 @@ control() { local control_tag="$RESTORE_CONTROL_TAG" # Only compatibility preflight uses the archived build before database replacement. [[ "$1" != preflight || -z "$RESTORE_IMAGE_TAG" ]] || control_tag="$RESTORE_IMAGE_TAG" - MAKERSPACE_IMAGE_TAG="$control_tag" "${COMPOSE[@]}" run --rm --no-deps -T backend \ + MAKERSPACE_IMAGE_TAG="$control_tag" ${COMPOSE[@]+"${COMPOSE[@]}"} run --rm --no-deps -T backend \ --role management python manage.py backup_control "$@" } reprovision_grants() { @@ -33,7 +35,7 @@ reprovision_grants() { if [[ -n "$database_name" ]]; then command+=(-e "SPACEWORKS_GRANT_DATABASE_NAME=$database_name") fi - "${COMPOSE[@]}" "${command[@]}" orchestration-ready >/dev/null + ${COMPOSE[@]+"${COMPOSE[@]}"} ${command[@]+"${command[@]}"} orchestration-ready >/dev/null } [[ "$RESTORE_ID" =~ ^[0-9a-fA-F-]{36}$ ]] || die "Usage: scripts/restore.sh " @@ -67,14 +69,14 @@ metadata="" restart_workers() { if [[ "$workers_stopped" == 1 && "$restart_allowed" == 1 ]]; then local no_deps=(); [[ -z "$RESTORE_IMAGE_TAG" ]] || no_deps=(--no-deps) - "${COMPOSE[@]}" up -d "${no_deps[@]}" worker beat >/dev/null 2>&1 + ${COMPOSE[@]+"${COMPOSE[@]}"} up -d ${no_deps[@]+"${no_deps[@]}"} worker beat >/dev/null 2>&1 workers_stopped=0 fi } restart_backend() { if [[ "$backend_stopped" == 1 && "$restart_allowed" == 1 ]]; then local no_deps=(); [[ -z "$RESTORE_IMAGE_TAG" ]] || no_deps=(--no-deps) - "${COMPOSE[@]}" up -d "${no_deps[@]}" backend >/dev/null 2>&1 + ${COMPOSE[@]+"${COMPOSE[@]}"} up -d ${no_deps[@]+"${no_deps[@]}"} backend >/dev/null 2>&1 backend_stopped=0 fi } @@ -88,7 +90,7 @@ cleanup() { control stage "$RESTORE_ID" rolling_back >/dev/null 2>&1 || true control rollback-objects "$RESTORE_ID" >/dev/null 2>&1 || true rollback_grants_ready=0 - if "${COMPOSE[@]}" exec -T db sh -c \ + if ${COMPOSE[@]+"${COMPOSE[@]}"} exec -T db sh -c \ 'pg_restore --clean --if-exists --no-owner --no-acl -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \ < "$PRE_DB" >/dev/null 2>&1 && reprovision_grants >/dev/null 2>&1; then rollback_grants_ready=1 @@ -128,10 +130,10 @@ if [[ -f "$DESTRUCTIVE_MARKER" ]]; then [[ -s "$PRE_DB" ]] \ || die "Interrupted destructive restore lacks its pre-restore database dump." say "Interrupted destructive restore detected; restoring the pre-restore database and objects." - "${COMPOSE[@]}" stop backend worker beat >/dev/null + ${COMPOSE[@]+"${COMPOSE[@]}"} stop backend worker beat >/dev/null backend_stopped=1 workers_stopped=1 - "${COMPOSE[@]}" exec -T db sh -c \ + ${COMPOSE[@]+"${COMPOSE[@]}"} exec -T db sh -c \ 'pg_restore --clean --if-exists --no-owner --no-acl -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \ < "$PRE_DB" if ! reprovision_grants; then @@ -157,24 +159,24 @@ kind="$(printf '%s' "$metadata" | sed -n 's/.*"kind": "\([a-z_]*\)".*/\1/p')" requested_by="$(printf '%s' "$metadata" | sed -n 's/.*"requested_by": \([0-9]*\).*/\1/p')" [[ -n "$archive_id" && -n "$kind" && -n "$requested_by" ]] || die "Could not read restore metadata." -backend_container="$("${COMPOSE[@]}" ps -q backend)" +backend_container="$(${COMPOSE[@]+"${COMPOSE[@]}"} ps -q backend)" image_id="$(docker inspect --format '{{.Image}}' "$backend_container")" oci_digest="$(docker image inspect --format '{{index .RepoDigests 0}}' "$image_id" 2>/dev/null || true)" if [[ -n "$RESTORE_IMAGE_TAG" ]]; then # Preflight runs in the retained source image, whose build identity must still # match the authenticated archive. Never waive the build/OCI compatibility gates. - restore_image="$(MAKERSPACE_IMAGE_TAG="$RESTORE_IMAGE_TAG" "${COMPOSE[@]}" config --images \ + restore_image="$(MAKERSPACE_IMAGE_TAG="$RESTORE_IMAGE_TAG" ${COMPOSE[@]+"${COMPOSE[@]}"} config --images \ | sed -n "/\/spaceworks-backend:${RESTORE_IMAGE_TAG}$/p" | head -n 1)" [[ -n "$restore_image" ]] || die "Could not resolve the previous backend image." docker pull "$restore_image" >/dev/null oci_digest="$(docker image inspect --format '{{index .RepoDigests 0}}' "$restore_image")" fi control export-archive "$RESTORE_ID" --output "/var/lib/spaceworks/ops/work/restore-$RESTORE_ID/archive.tar.age" >/dev/null -"${COMPOSE[@]}" exec -T backend age -d \ +${COMPOSE[@]+"${COMPOSE[@]}"} exec -T backend age -d \ -i "${IDENTITY_FILE/$OPS_DIR/\/var\/lib\/spaceworks\/ops}" \ -o "/var/lib/spaceworks/ops/work/restore-$RESTORE_ID/archive.tar" \ "/var/lib/spaceworks/ops/work/restore-$RESTORE_ID/archive.tar.age" -"${COMPOSE[@]}" exec -T backend python - \ +${COMPOSE[@]+"${COMPOSE[@]}"} exec -T backend python - \ "/var/lib/spaceworks/ops/work/restore-$RESTORE_ID/archive.tar" \ "/var/lib/spaceworks/ops/work/restore-$RESTORE_ID/bundle" <<'PY' import pathlib @@ -211,13 +213,13 @@ claimed=1 drain_seconds="$(control quiesce "$RESTORE_ID" | tail -n 1)" [[ "$drain_seconds" =~ ^[0-9]+$ ]] || die "The app returned an invalid presign drain window." -"${COMPOSE[@]}" stop worker beat >/dev/null +${COMPOSE[@]+"${COMPOSE[@]}"} stop worker beat >/dev/null workers_stopped=1 say "Writers are excluded; draining presigned uploads for ${drain_seconds}s." sleep "$drain_seconds" say "Taking the mandatory pre-restore database snapshot." -"${COMPOSE[@]}" exec -T db sh -c \ +${COMPOSE[@]+"${COMPOSE[@]}"} exec -T db sh -c \ 'pg_dump --format=custom --no-owner --no-acl -U "$POSTGRES_USER" "$POSTGRES_DB"' \ > "$PRE_DB" [[ -s "$PRE_DB" ]] || die "The pre-restore database snapshot is empty." @@ -225,15 +227,15 @@ say "Taking the mandatory pre-restore database snapshot." if [[ "$kind" == "rollback_in_place" ]]; then temp_db="spaceworks_restore_${RESTORE_ID//-/}" temp_db="${temp_db:0:60}" - "${COMPOSE[@]}" exec -T db sh -c \ + ${COMPOSE[@]+"${COMPOSE[@]}"} exec -T db sh -c \ 'dropdb --if-exists -U "$POSTGRES_USER" '"$temp_db"' && createdb -U "$POSTGRES_USER" '"$temp_db" - "${COMPOSE[@]}" exec -T db sh -c \ + ${COMPOSE[@]+"${COMPOSE[@]}"} exec -T db sh -c \ 'pg_restore --no-owner --no-acl -U "$POSTGRES_USER" -d '"$temp_db" \ < "$BUNDLE/database.dump" reprovision_grants "$temp_db" say "Computing every-table diff in one live snapshot; choose before the console countdown expires." decision="$(control diff-wait "$RESTORE_ID" --archive-database-name "$temp_db" | tail -n 1)" - "${COMPOSE[@]}" exec -T db sh -c \ + ${COMPOSE[@]+"${COMPOSE[@]}"} exec -T db sh -c \ 'dropdb --if-exists -U "$POSTGRES_USER" '"$temp_db" >/dev/null 2>&1 || true [[ "$decision" != "abort" ]] || { finished=1; say "Restore aborted without destructive effect."; exit 0; } else @@ -247,11 +249,11 @@ control export-control "$RESTORE_ID" \ printf '%s\n' "$RESTORE_ID" > "$DESTRUCTIVE_MARKER" sync -f "$WORK" control stage "$RESTORE_ID" db_restoring >/dev/null -"${COMPOSE[@]}" stop backend >/dev/null +${COMPOSE[@]+"${COMPOSE[@]}"} stop backend >/dev/null backend_stopped=1 destructive=1 say "Replacing the database." -"${COMPOSE[@]}" exec -T db sh -c \ +${COMPOSE[@]+"${COMPOSE[@]}"} exec -T db sh -c \ 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname=current_database() AND pid<>pg_backend_pid()" >/dev/null && pg_restore --clean --if-exists --no-owner --no-acl -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \ < "$BUNDLE/database.dump" reprovision_grants diff --git a/scripts/setup-first-run.sh b/scripts/setup-first-run.sh new file mode 100644 index 000000000..d20c4268a --- /dev/null +++ b/scripts/setup-first-run.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# Sourced by setup.sh; guided initial settings and private .env creation. +FIRST_RUN=0 +if [ -f .env ]; then + say "Found an existing .env — keeping your settings and secrets." +else + FIRST_RUN=1 + say "Welcome! Let's set up Space Works. Press Enter to accept the [default]." + read -r -p "Web address (host name or IP, no http://) [localhost]: " WEBADDR; WEBADDR="${WEBADDR:-localhost}" + # Normalize: strip any scheme, path, and port so ALLOWED_HOSTS/CORS are valid. + WEBHOST="${WEBADDR#*://}"; WEBHOST="${WEBHOST%%/*}"; WEBHOST="${WEBHOST%%:*}" + [ -n "$WEBHOST" ] || WEBHOST="localhost" + read -r -p "Name of your makerspace [My Makerspace]: " MSNAME; MSNAME="${MSNAME:-My Makerspace}" + # Edition: what this box is FOR. The loan spine is core and always present; an edition + # only decides which surfaces are shown and which public routes answer (docs/MODULES.md, + # "Editions"). Stored in .env as SPACEWORKS_EDITION and changeable later. + echo + echo "What is this installation for?" + echo " 1) A makerspace / tool library - lending, machines, events, bookings (default)" + echo " 2) Events only - an events programme with sign-ups and check-in" + echo " 3) Bookings only - rooms and resources people reserve" + echo " 4) An organization - the same as 1, labelled as an organization" + read -r -p "Choice [1]: " EDITION_CHOICE + case "${EDITION_CHOICE:-1}" in + 2) SPACEWORKS_EDITION="events" ;; + 3) SPACEWORKS_EDITION="bookings" ;; + 4) SPACEWORKS_EDITION="organization" ;; + *) SPACEWORKS_EDITION="makerspace" ;; + esac + # Install shape: several containers (default) or one application container beside the + # database and object storage. Same image contents, same rules; fewer things to run. + echo + echo "How should it run?" + echo " 1) Standard - separate containers for the API, worker, scheduler and web server" + echo " 2) Single box - one application container (plus the database and storage)" + read -r -p "Choice [1]: " SHAPE_CHOICE + if [ "${SHAPE_CHOICE:-1}" = 2 ]; then + printf 'single\n' > "$ROOT/.spaceworks-layer" + export SPACEWORKS_COMPOSE_LAYER=single + else + rm -f "$ROOT/.spaceworks-layer" + fi + # Modules are NOT asked here. The question moved to the end of setup, where the app is + # running and the real registry can be read, so the operator ticks actual module names + # instead of memorising profile words. `recommended` is only the starting point the tick + # list opens with; nothing is final until that step. + MSPROFILE="recommended" + # Asked here with the other identity questions, applied at the very END of setup: the + # answer implies the `membership` module, the module list is chosen after the app is + # running, and the real state can only be read back from the database once both have + # been applied. See apply_request_access. + echo + echo "Who can submit borrow requests?" + echo " 1) Members only - people you have enrolled as members of this makerspace" + echo " 2) Anyone with an account - any signed-in user; staff still accept every request" + echo " 3) Anyone, no account - a stranger leaves their name and contact details" + echo "Option 3 is an unauthenticated write surface: it is rate limited, contact details are" + echo "marked unverified, and no email is sent to them until you verify. You can change this" + echo "later with 'manage.py set_request_access'." + read -r -p "Choice [2]: " REQUEST_ACCESS_CHOICE + case "${REQUEST_ACCESS_CHOICE:-2}" in + 1) REQUEST_ACCESS=members ;; + 3) REQUEST_ACCESS=anyone ;; + 2) REQUEST_ACCESS=accounts ;; + *) warn "Unrecognised choice; requiring an account."; REQUEST_ACCESS=accounts ;; + esac + read -r -p "Admin login username [admin]: " ADMINUSER; ADMINUSER="${ADMINUSER:-admin}" + read -r -p "Admin email [admin@example.com]: " ADMINEMAIL; ADMINEMAIL="${ADMINEMAIL:-admin@example.com}" + read -r -s -p "Admin password (leave blank to auto-generate): " ADMINPASS; echo + GEN_PASS=0 + if [ -z "$ADMINPASS" ]; then ADMINPASS="$(rand_key 16)"; GEN_PASS=1; fi + # Google sign-in is GUIDED, never automatic. Client IDs are issued by Google against + # a specific origin, so no credential can ship in the box and "works out of the box" + # is impossible here -- guided is the ceiling. Skipping leaves username/password + # login fully working, which is the only login a fresh install has either way. + echo + echo "Optional: Google sign-in. To enable it you need a Google OAuth client ID:" + echo " 1. Open https://console.cloud.google.com/apis/credentials" + echo " 2. Create Credentials -> OAuth client ID -> Web application" + echo " 3. Add this to 'Authorised JavaScript origins': http://${WEBHOST}" + echo " 4. Copy the Client ID (it ends in .apps.googleusercontent.com)" + echo "Leave blank to skip — you can add it later in Platform settings." + read -r -p "Google Web client ID (optional): " GOOGLE_WEB_CLIENT_ID + GOOGLE_WEB_CLIENT_ID="$(printf '%s' "$GOOGLE_WEB_CLIENT_ID" | tr -d '[:space:]')" + if [ -n "$GOOGLE_WEB_CLIENT_ID" ] && [[ "$GOOGLE_WEB_CLIENT_ID" != *.apps.googleusercontent.com ]]; then + # Caught here rather than at first login: a wrong value makes the Google button + # appear and then fail token verification for every user, with nothing on screen + # explaining why. + warn "That does not look like a Google client ID; skipping Google sign-in." + GOOGLE_WEB_CLIENT_ID="" + fi + + read -r -p "Stripe secret key (optional; leave blank to skip): " STRIPE_SECRET_KEY + read -r -s -p "Stripe webhook secret (optional; leave blank to skip): " STRIPE_WEBHOOK_SECRET; echo + read -r -p "Stripe default currency [usd]: " STRIPE_DEFAULT_CURRENCY; STRIPE_DEFAULT_CURRENCY="${STRIPE_DEFAULT_CURRENCY:-usd}" + # Braces are load-bearing: && and || have EQUAL precedence in bash and associate left, + # so the unbraced form parsed as ((A && B) || C) && D and silently accepted a secret key + # with no webhook secret -- writing a half-configured Stripe account with no warning. + if { [ -n "$STRIPE_SECRET_KEY" ] && [ -z "$STRIPE_WEBHOOK_SECRET" ]; } \ + || { [ -z "$STRIPE_SECRET_KEY" ] && [ -n "$STRIPE_WEBHOOK_SECRET" ]; }; then + warn "Stripe needs both secrets; leaving payments unconfigured." + STRIPE_SECRET_KEY=""; STRIPE_WEBHOOK_SECRET="" + fi + + say "Writing .env (secrets generated automatically)..." + cat > .env </dev/null else @@ -44,7 +45,9 @@ prepare_compose_wrapper() { "mkdir -p /ops/work && chown 10001:10001 /ops/work && chmod 700 /ops/work && touch /ops/operation.lock && chown 0:0 /ops/operation.lock && chmod 666 /ops/operation.lock && chown 0:$host_gid /ops && chmod 750 /ops" if ! docker run --rm --user 0:0 --entrypoint test \ -v "$OPS_HOST_DIR:/ops" "$HOST_CONFIG_IMAGE" -f /ops/database-pointer.env; then - python3 "$ROOT/scripts/bundled-database-url.py" "$ROOT/.env" | \ + docker run --rm --user 0:0 --entrypoint python \ + -v "$ROOT:/repo:ro" "$HOST_CONFIG_IMAGE" \ + /repo/scripts/bundled-database-url.py /repo/.env | \ docker run --rm -i --user 0:0 --entrypoint python \ -v "$ROOT:/repo:ro" -v "$OPS_HOST_DIR:/ops" "$HOST_CONFIG_IMAGE" \ /repo/scripts/configure-compose-wrapper.py initialize-pointer \ @@ -76,7 +79,7 @@ configure_setup_stripe() { SETUP_STRIPE_WEBHOOK_SECRET="$STRIPE_WEBHOOK_SECRET" \ SETUP_STRIPE_DEFAULT_CURRENCY="$STRIPE_DEFAULT_CURRENCY" \ SETUP_MAKERSPACE_NAME="$MSNAME" \ - "${COMPOSE[@]}" run --rm --no-deps -T \ + ${COMPOSE[@]+"${COMPOSE[@]}"} run --rm --no-deps -T \ -e SETUP_STRIPE_SECRET_KEY -e SETUP_STRIPE_WEBHOOK_SECRET \ -e SETUP_STRIPE_DEFAULT_CURRENCY -e SETUP_MAKERSPACE_NAME \ backend --role management python manage.py shell -c ' diff --git a/scripts/spaceworks-compose.sh b/scripts/spaceworks-compose.sh index d24680e37..47c4d7ec0 100755 --- a/scripts/spaceworks-compose.sh +++ b/scripts/spaceworks-compose.sh @@ -2,7 +2,9 @@ # Load-bearing Compose launcher: static config first, atomic DB pointer last. set -euo pipefail -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WRAPPER_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="${SPACEWORKS_UPDATE_INSTALL_DIR:-$(cd "$WRAPPER_SCRIPT_DIR/.." && pwd)}" +export SPACEWORKS_UPDATE_INSTALL_DIR="$ROOT" TOPOLOGY="${1:-}" [[ "$TOPOLOGY" == "bundled" || "$TOPOLOGY" == "cloud" ]] || { echo "usage: scripts/spaceworks-compose.sh [compose arguments...]" >&2 @@ -83,18 +85,38 @@ if [[ "$LAYER" != "none" ]]; then RECORD="$OPS_DIR/compose-topology-$LAYER.json" fi -VALIDATE=(python3 "$ROOT/scripts/validate-compose-wrapper.py" +VALIDATE=(python3 "$WRAPPER_SCRIPT_DIR/validate-compose-wrapper.py" --topology "$TOPOLOGY" --static-env "$STATIC_ENV" --pointer "$POINTER" --record "$RECORD") COMPOSE_ARGS=(--env-file "$STATIC_ENV" --env-file "$POINTER") -for file in "${COMPOSE_FILES[@]}"; do +for file in ${COMPOSE_FILES[@]+"${COMPOSE_FILES[@]}"}; do VALIDATE+=(--compose-file "$file") COMPOSE_ARGS+=(-f "$file") done -"${VALIDATE[@]}" +if command -v python3 >/dev/null 2>&1 \ + && python3 -c 'import sys; raise SystemExit(sys.version_info < (3, 10))' >/dev/null 2>&1; then + ${VALIDATE[@]+"${VALIDATE[@]}"} +else + # Minimal Linux hosts need no host Python. Validate as the host UID so the + # same static-env owner check applies; retain supplementary read groups. + image="${HOST_CONFIG_IMAGE:-${MAKERSPACE_BACKEND_IMAGE:-ghcr.io/spaceworks-hq/spaceworks-backend}:${MAKERSPACE_IMAGE_TAG:-latest}}" + groups=() + for gid in $(id -G); do groups+=(--group-add "$gid"); done + container_args=(--topology "$TOPOLOGY" --static-env /static.env + --pointer /ops/database-pointer.env --record "/ops/$(basename "$RECORD")") + for file in ${COMPOSE_FILES[@]+"${COMPOSE_FILES[@]}"}; do + container_args+=(--compose-file "/repo/${file#"$ROOT/"}") + done + MSYS_NO_PATHCONV=1 docker run --rm --network none --read-only \ + --user "$(id -u):$(id -g)" ${groups[@]+"${groups[@]}"} --entrypoint python \ + -e SPACEWORKS_UPDATE_INSTALL_DIR=/repo \ + -v "$ROOT:/repo:ro" -v "$STATIC_ENV:/static.env:ro" -v "$OPS_DIR:/ops:ro" \ + -v "$WRAPPER_SCRIPT_DIR/..:/validation-code:ro" \ + "$image" /validation-code/scripts/validate-compose-wrapper.py ${container_args[@]+"${container_args[@]}"} +fi # Shell values outrank every --env-file during interpolation. Remove both pointer # names before Docker Compose starts so only the final pointer file can define them. unset DATABASE_URL unset SPACEWORKS_DB_POINTER_GENERATION -exec docker compose "${COMPOSE_ARGS[@]}" "$@" +exec docker compose ${COMPOSE_ARGS[@]+"${COMPOSE_ARGS[@]}"} "$@" diff --git a/scripts/update-lock.sh b/scripts/update-lock.sh index e5d6a0bdd..bde72625d 100644 --- a/scripts/update-lock.sh +++ b/scripts/update-lock.sh @@ -33,8 +33,10 @@ acquire_update_lock() { printf '%s %s\n' "$$" "$now" > "$LOCK_DIR/owner" UPDATE_LOCK_HELD=1 - mkdir -p "$OPS_DIR" + mkdir -p "$OPS_DIR" || die "Cannot access $OPS_DIR. Run bash scripts/init-host-orchestration.sh as the install owner, then rerun the update." if command -v flock >/dev/null 2>&1; then + [[ ! -e "$OPS_DIR/operation.lock" || -w "$OPS_DIR/operation.lock" ]] \ + || die "The host operation lock is not writable. Run bash scripts/init-host-orchestration.sh as the install owner, then retry." exec 8>"$OPS_DIR/operation.lock" flock -n 8 || die "Another backup, restore, verification, or update is running." else diff --git a/scripts/update-rollback-host.sh b/scripts/update-rollback-host.sh new file mode 100644 index 000000000..3f34934bd --- /dev/null +++ b/scripts/update-rollback-host.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Root child of the owner-run updater; its parent retains the portable update lock. +set -euo pipefail +UPDATE_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="${SPACEWORKS_UPDATE_INSTALL_DIR:-$(cd "$UPDATE_SCRIPT_DIR/.." && pwd)}" +cd "$ROOT" +export SPACEWORKS_UPDATE_INSTALL_DIR="$ROOT" +[[ "$(id -u)" == 0 ]] || { echo "Run this rollback helper through scripts/update.sh (sudo is required)." >&2; exit 1; } +OPS_DIR="${SPACEWORKS_OPS_HOST_DIR:-/var/lib/spaceworks/ops}" +VERSION_FILE="$ROOT/.spaceworks-version" +COMPOSE=("$UPDATE_SCRIPT_DIR/spaceworks-compose.sh" bundled) +APP_SERVICE=backend +HOST_ROLLBACK_PLAN="${1:-resume}" +say() { printf '[Space Works rollback] %s\n' "$*"; } +die() { printf '[Space Works rollback] ERROR: %s\n' "$*" >&2; exit 1; } +source "$UPDATE_SCRIPT_DIR/update-rollback.sh" +layer="${SPACEWORKS_COMPOSE_LAYER:-}" +if [[ -z "$layer" && -f "$ROOT/.spaceworks-layer" ]]; then + layer="$(tr -d '[:space:]' < "$ROOT/.spaceworks-layer")" +fi +[[ "$layer" != single ]] || die "The existing restore supervisor requires the bundled topology." +wait_for_backend() { + local attempt + for attempt in $(seq 1 60); do + if ${COMPOSE[@]+"${COMPOSE[@]}"} exec -T backend python -c \ + "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/v1/health/readiness/', timeout=3).read()" \ + >/dev/null 2>&1; then return 0; fi + sleep 3 + done + return 1 +} +command -v flock >/dev/null 2>&1 || die "Rollback requires flock." +exec 8>"$OPS_DIR/operation.lock" +flock -n 8 || die "Another host operation started before the rollback handoff." +handle_update_rollback diff --git a/scripts/update-rollback.sh b/scripts/update-rollback.sh index 7b6c46aee..012ff2d05 100755 --- a/scripts/update-rollback.sh +++ b/scripts/update-rollback.sh @@ -1,23 +1,53 @@ #!/usr/bin/env bash # Sourced by update.sh; the web only records restore intent. Never execute a new SQL path. +privileged_update_rollback() { + command -v sudo >/dev/null 2>&1 \ + || die "Rollback needs root access to private host state. Install sudo and rerun this update as the install owner." + command -v flock >/dev/null 2>&1 || die "Rollback requires the Linux restore supervisor and flock." + # Retain the parent's portable lock; the root child takes operation.lock itself. + flock -u 8 + exec 8>&- + sudo env SPACEWORKS_OPS_HOST_DIR="$OPS_DIR" \ + SPACEWORKS_HOST_STATE_DIR="${SPACEWORKS_HOST_STATE_DIR:-/var/lib/spaceworks/host}" \ + SPACEWORKS_UPDATE_INSTALL_DIR="$ROOT" \ + SPACEWORKS_COMPOSE_LAYER="${SPACEWORKS_COMPOSE_LAYER:-}" \ + SPACEWORKS_COMPOSE_BUILD_LAYER="${SPACEWORKS_COMPOSE_BUILD_LAYER:-0}" \ + SPACEWORKS_STATIC_ENV_FILE="${SPACEWORKS_STATIC_ENV_FILE:-$ROOT/.env}" \ + MAKERSPACE_BACKEND_IMAGE="${MAKERSPACE_BACKEND_IMAGE:-ghcr.io/spaceworks-hq/spaceworks-backend}" \ + BACKUP_AGE_IDENTITY_FILE="${BACKUP_AGE_IDENTITY_FILE:-$OPS_DIR/work/age-identity.txt}" \ + MAKERSPACE_IMAGE_TAG="${MAKERSPACE_IMAGE_TAG:-}" \ + bash "${UPDATE_SCRIPT_DIR:-$ROOT/scripts}/update-rollback-host.sh" "$1" \ + || die "The privileged rollback refused or paused. Inspect the restore stage in Platform settings, then rerun bash scripts/update.sh --no-module-changes." + exit 0 +} + handle_update_rollback() { local plan rollback_version restore_id metadata stage temporary_intent local intent="$OPS_DIR/update-rollback.intent" + if [[ "$(uname -s)" == Linux && "$(id -u)" != 0 && -e "$intent" ]]; then + [[ "$APP_SERVICE" == backend ]] || die "The existing restore supervisor requires the bundled topology; rollback remains queued." + privileged_update_rollback resume + fi if [[ -e "$intent" ]]; then - [[ -f "$intent" && ! -L "$intent" && "$(stat -c %u "$intent")" == "$EUID" \ + [[ -f "$intent" && ! -L "$intent" && "$(stat -c %u "$intent")" == "$(id -u)" \ && "$(stat -c %a "$intent")" == 600 ]] || die "The rollback intent is not a private host-owned file." plan="$(cat "$intent")" + elif [[ -n "${HOST_ROLLBACK_PLAN:-}" && "$HOST_ROLLBACK_PLAN" != resume ]]; then + plan="$HOST_ROLLBACK_PLAN" else - plan="$("${COMPOSE[@]}" run --rm --no-deps -T backend --role management \ + plan="$(${COMPOSE[@]+"${COMPOSE[@]}"} run --rm --no-deps -T backend --role management \ python manage.py update_control rollback-plan | tr -d '\r' | tail -n 1)" \ || die "Could not check pending update rollback." fi [[ "$plan" != none ]] || return 0 read -r rollback_version restore_id <<< "$plan" - [[ "$rollback_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-(main|beta)\.[0-9]+\.[0-9a-f]{12}$ \ + [[ "$rollback_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-main\.[0-9]+\.[0-9a-f]{12}$ \ && "$restore_id" =~ ^[0-9a-f-]{36}$ ]] || die "Invalid update rollback plan." [[ "$APP_SERVICE" == backend ]] \ || die "The existing restore supervisor requires the bundled topology; rollback remains queued." + if [[ "$(uname -s)" == Linux && "$(id -u)" != 0 ]]; then + privileged_update_rollback "$plan" + fi command -v flock >/dev/null 2>&1 \ || die "Rollback requires the Linux/WSL2 restore supervisor." if [[ ! -e "$intent" ]]; then @@ -30,7 +60,7 @@ handle_update_rollback() { mv -f -- "$temporary_intent" "$intent" sync -f "$OPS_DIR" fi - metadata="$(MAKERSPACE_IMAGE_TAG="$rollback_version" "${COMPOSE[@]}" run --rm --no-deps -T backend --role management \ + metadata="$(MAKERSPACE_IMAGE_TAG="$rollback_version" ${COMPOSE[@]+"${COMPOSE[@]}"} run --rm --no-deps -T backend --role management \ python manage.py backup_control describe "$restore_id" | tail -n 1 || true)" stage="$(printf '%s' "$metadata" | sed -n 's/.*"stage": "\([a-z_]*\)".*/\1/p')" say "Restoring the pre-update snapshot and release $rollback_version. Review the restore diff in the console." @@ -39,13 +69,13 @@ handle_update_rollback() { flock -u 8 exec 8>&- if [[ "$stage" != completed && "$stage" != restored_quarantined && "$stage" != aborted && "$stage" != failed ]]; then - if ! bash "$ROOT/scripts/restore.sh" "$restore_id" "--update-image=$rollback_version"; then + if ! bash "${UPDATE_SCRIPT_DIR:-$ROOT/scripts}/restore.sh" "$restore_id" "--update-image=$rollback_version"; then die "Rollback supervisor refused or paused the restore; inspect its persisted stage before retrying." fi fi exec 8>"$OPS_DIR/operation.lock" flock -n 8 || die "Another host operation started after restore; retain the current version marker." - metadata="$(MAKERSPACE_IMAGE_TAG="$rollback_version" "${COMPOSE[@]}" run --rm --no-deps -T backend --role management \ + metadata="$(MAKERSPACE_IMAGE_TAG="$rollback_version" ${COMPOSE[@]+"${COMPOSE[@]}"} run --rm --no-deps -T backend --role management \ python manage.py backup_control describe "$restore_id" | tail -n 1)" stage="$(printf '%s' "$metadata" | sed -n 's/.*"stage": "\([a-z_]*\)".*/\1/p')" if [[ "$stage" == completed || "$stage" == restored_quarantined ]]; then @@ -53,11 +83,11 @@ handle_update_rollback() { # invoking migrate (which would immediately re-apply the upgrade's migrations). export MAKERSPACE_IMAGE_TAG="$rollback_version" printf '%s\n' "$rollback_version" > "$VERSION_FILE" - "${COMPOSE[@]}" up -d --no-deps backend frontend + ${COMPOSE[@]+"${COMPOSE[@]}"} up -d --no-deps backend frontend if [[ "$stage" == completed ]]; then - "${COMPOSE[@]}" up -d --no-deps worker beat + ${COMPOSE[@]+"${COMPOSE[@]}"} up -d --no-deps worker beat wait_for_backend || die "The restored release is not ready." - "${COMPOSE[@]}" run --rm --no-deps -T backend --role management \ + ${COMPOSE[@]+"${COMPOSE[@]}"} run --rm --no-deps -T backend --role management \ python manage.py update_control complete --version "$rollback_version" >/dev/null fi say "Rollback reached $stage at $rollback_version." diff --git a/scripts/update.sh b/scripts/update.sh index 6bede6834..df0b32374 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -2,11 +2,15 @@ # Apply the newest fully-published Space Works release to a production Compose stack. set -euo pipefail -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +UPDATE_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="${SPACEWORKS_UPDATE_INSTALL_DIR:-$(cd "$UPDATE_SCRIPT_DIR/.." && pwd)}" cd "$ROOT" +export SPACEWORKS_UPDATE_INSTALL_DIR="$ROOT" +source "$UPDATE_SCRIPT_DIR/install-owner.sh" +require_install_owner "$UPDATE_SCRIPT_DIR/update.sh" "$@" OPS_DIR="${SPACEWORKS_OPS_HOST_DIR:-/var/lib/spaceworks/ops}" -COMPOSE=("$ROOT/scripts/spaceworks-compose.sh" bundled) +COMPOSE=("$UPDATE_SCRIPT_DIR/spaceworks-compose.sh" bundled) APP_SERVICE=backend IMAGE_SERVICES=(migrate backend worker beat frontend) layer="${SPACEWORKS_COMPOSE_LAYER:-}" @@ -42,9 +46,9 @@ NO_MODULE_CHANGES=0 say() { printf '[Space Works updater] %s\n' "$*"; } warn() { printf '[Space Works updater] WARNING: %s\n' "$*" >&2; } die() { printf '[Space Works updater] ERROR: %s\n' "$*" >&2; exit 1; } -source "$ROOT/scripts/update-lock.sh" -source "$ROOT/scripts/module-selection.sh" -source "$ROOT/scripts/update-rollback.sh" +source "$UPDATE_SCRIPT_DIR/update-lock.sh" +source "$UPDATE_SCRIPT_DIR/module-selection.sh" +source "$UPDATE_SCRIPT_DIR/update-rollback.sh" force_arg=() while (($# > 0)); do @@ -101,7 +105,7 @@ fi wait_for_backend() { local _ for _ in $(seq 1 60); do - if "${COMPOSE[@]}" exec -T "$APP_SERVICE" python -c \ + if ${COMPOSE[@]+"${COMPOSE[@]}"} exec -T "$APP_SERVICE" python -c \ "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/v1/health/readiness/', timeout=3).read()" \ >/dev/null 2>&1; then return 0 @@ -120,7 +124,7 @@ cleanup() { if [[ "$update_claimed" == 1 && "$update_complete" == 0 ]]; then failure_message="Host update failed. Check backups/auto-update.log." if [[ "$deployment_started" == 1 && \ - "$previous_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-(main|beta)\.[0-9]+\.[0-9a-f]{12}$ ]]; then + "$previous_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-main\.[0-9]+\.[0-9a-f]{12}$ ]]; then say "Update failed; rolling application containers back to $previous_version." export MAKERSPACE_IMAGE_TAG="$previous_version" # The marker pins every later wrapper call (scripts/spaceworks-compose.sh), so it must @@ -128,9 +132,9 @@ cleanup() { # plain `up -d` would start the release that just failed, outside this script's gates. printf '%s\n' "$previous_version" > "$VERSION_FILE" || \ say "WARNING: could not reset $VERSION_FILE to $previous_version." - "${COMPOSE[@]}" pull "${IMAGE_SERVICES[@]}" || \ + ${COMPOSE[@]+"${COMPOSE[@]}"} pull ${IMAGE_SERVICES[@]+"${IMAGE_SERVICES[@]}"} || \ say "Could not refresh previous images; trying the host's cached copies." - if "${COMPOSE[@]}" up -d && wait_for_backend; then + if ${COMPOSE[@]+"${COMPOSE[@]}"} up -d && wait_for_backend; then say "Rollback complete: $previous_version is healthy." failure_message="Update failed; application containers rolled back to $previous_version. The database backup was retained." else @@ -138,7 +142,7 @@ cleanup() { failure_message="Update and automatic rollback failed. Restore the database backup and previous image tag manually." fi fi - "${COMPOSE[@]}" run --rm --no-deps -T backend --role management python manage.py update_control fail \ + ${COMPOSE[@]+"${COMPOSE[@]}"} run --rm --no-deps -T backend --role management python manage.py update_control fail \ --message "$failure_message" >/dev/null 2>&1 || true fi release_update_lock @@ -169,19 +173,26 @@ release_json="$(curl --fail --silent --show-error --location \ --header 'User-Agent: spaceworks-self-host-updater' \ "$RELEASE_API")" +# The installed backend may still support beta until this update migrates it. +# Restrict its claim input first, using its Python without requiring host Python. +release_json="$(printf '%s' "$release_json" | ${COMPOSE[@]+"${COMPOSE[@]}"} exec -T "$APP_SERVICE" \ + python -c "$(cat "$UPDATE_SCRIPT_DIR/filter-main-releases.py")")" \ + || die "Could not validate an eligible published main release; no update was claimed. Read the error above. If the app is stopped, run bash scripts/spaceworks-compose.sh bundled up -d, then retry." + current="" if [[ -f "$VERSION_FILE" ]]; then current="$(tr -d '[:space:]' < "$VERSION_FILE")" fi previous_version="$current" -decision="$(printf '%s' "$release_json" | "${COMPOSE[@]}" run --rm --no-deps -T backend --role management python manage.py update_control claim \ - --current="$current" --releases-stdin "${force_arg[@]}")" \ +decision="$(printf '%s' "$release_json" | ${COMPOSE[@]+"${COMPOSE[@]}"} run --rm --no-deps -T backend --role management python manage.py update_control claim \ + --current="$current" --releases-stdin ${force_arg[@]+"${force_arg[@]}"})" \ || die "The running Space Works backend could not accept the update check." decision="$(printf '%s\n' "$decision" | tr -d '\r' | tail -n 1)" read -r decision version <<< "$decision" -[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-(main|beta)\.[0-9]+\.[0-9a-f]{12}$ ]] \ - || die "The selected channel returned an invalid immutable release version." +[[ "$decision" != run ]] || update_claimed=1 +[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-main\.[0-9]+\.[0-9a-f]{12}$ ]] \ + || die "The updater requires an immutable published main release version." if [[ "$decision" != "run" ]]; then if [[ "$current" == "$version" ]]; then say "$version is already installed." @@ -199,32 +210,32 @@ update_claimed=1 say "Updating ${current:-untracked installation} to $version." mkdir -p "$ROOT/backups" -"${COMPOSE[@]}" up -d --wait db +${COMPOSE[@]+"${COMPOSE[@]}"} up -d --wait db backup_name="pre-update-$(date -u +%Y%m%dT%H%M%SZ).sql.gz" say "Creating database backup backups/$backup_name." -"${COMPOSE[@]}" exec -T db sh -c \ +${COMPOSE[@]+"${COMPOSE[@]}"} exec -T db sh -c \ 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" | gzip -c > "/backups/'"$backup_name"'"' -"${COMPOSE[@]}" exec -T db test -s "/backups/$backup_name" \ +${COMPOSE[@]+"${COMPOSE[@]}"} exec -T db test -s "/backups/$backup_name" \ || die "Database backup was not created; update cancelled." -"${COMPOSE[@]}" run --rm --no-deps -T backend --role management python manage.py update_control record-backup \ +${COMPOSE[@]+"${COMPOSE[@]}"} run --rm --no-deps -T backend --role management python manage.py update_control record-backup \ --name "$backup_name" >/dev/null say "Creating a verified deployment restore point before changing images." -"${COMPOSE[@]}" run --rm --no-deps -T backend --role management python manage.py update_control snapshot >/dev/null \ +${COMPOSE[@]+"${COMPOSE[@]}"} run --rm --no-deps -T backend --role management python manage.py update_control snapshot >/dev/null \ || die "The verified restore point could not be created; update cancelled." export MAKERSPACE_IMAGE_TAG="$version" say "Pulling immutable release images." -"${COMPOSE[@]}" pull "${IMAGE_SERVICES[@]}" +${COMPOSE[@]+"${COMPOSE[@]}"} pull ${IMAGE_SERVICES[@]+"${IMAGE_SERVICES[@]}"} say "Running migrations and replacing application containers." deployment_started=1 -"${COMPOSE[@]}" up -d +${COMPOSE[@]+"${COMPOSE[@]}"} up -d wait_for_backend || die "Release $version did not become ready. The backup is backups/$backup_name." printf '%s\n' "$version" > "$VERSION_FILE" -"${COMPOSE[@]}" run --rm --no-deps -T backend --role management python manage.py update_control complete \ +${COMPOSE[@]+"${COMPOSE[@]}"} run --rm --no-deps -T backend --role management python manage.py update_control complete \ --version "$version" >/dev/null update_complete=1 find "$ROOT/backups" -maxdepth 1 -type f -name 'pre-update-*.sql.gz' -mtime +14 -delete diff --git a/scripts/validate-compose-wrapper.py b/scripts/validate-compose-wrapper.py index a75c29103..a14bf44b2 100644 --- a/scripts/validate-compose-wrapper.py +++ b/scripts/validate-compose-wrapper.py @@ -2,6 +2,7 @@ """Validate all trusted wrapper inputs before Docker Compose sees them.""" import argparse +import os from pathlib import Path import sys @@ -28,6 +29,9 @@ def main(argv=None): pointer_file=args.pointer, topology_record=args.record, compose_files=args.compose_file, + # A privileged restore may operate a user-owned install. Trust the + # repository owner's private .env, while pointer/record stay root-owned. + static_owner_uid=Path(os.environ.get("SPACEWORKS_UPDATE_INSTALL_DIR", ROOT)).stat().st_uid, ) diff --git a/setup.sh b/setup.sh index fcedfef3a..792e9f511 100644 --- a/setup.sh +++ b/setup.sh @@ -5,6 +5,8 @@ set -euo pipefail cd "$(dirname "$0")" ROOT="$PWD" +source "$ROOT/scripts/install-owner.sh" +require_install_owner "$ROOT/setup.sh" "$@" COMPOSE=("$ROOT/scripts/spaceworks-compose.sh" bundled) BUILD_FROM_SOURCE=0 case "${1:-}" in @@ -32,8 +34,8 @@ source "$ROOT/scripts/setup-host-orchestration.sh" source "$ROOT/scripts/module-selection.sh" # 1. Docker must be installed and running. -command -v docker >/dev/null 2>&1 || die "Docker is not installed. Install Docker Desktop first: https://www.docker.com/products/docker-desktop/" -docker info >/dev/null 2>&1 || die "Docker is installed but not running. Start Docker Desktop, then run this again." +command -v docker >/dev/null 2>&1 || die "Docker is missing. On Linux, run install.sh to install it; on macOS/Windows, install Docker Desktop." +docker info >/dev/null 2>&1 || die "Docker is inaccessible. On Linux, run sudo systemctl start docker and sudo usermod -aG docker \$(id -un), then sign out and back in. On macOS/Windows, start Docker Desktop." docker compose version >/dev/null 2>&1 || die "The 'docker compose' plugin is missing. Update Docker Desktop." # Subshell disables pipefail locally: `head` closing the pipe makes `tr` exit via SIGPIPE, @@ -68,7 +70,7 @@ choose_modules() { apply_request_access() { local slug="$1" mode="${REQUEST_ACCESS:-accounts}" if [[ "$mode" == anyone ]]; then - if ! "${COMPOSE[@]}" run --rm --no-deps -T backend --role management \ + if ! ${COMPOSE[@]+"${COMPOSE[@]}"} run --rm --no-deps -T backend --role management \ python manage.py set_request_access --makerspace "$slug" --mode anyone; then warn "Account-less borrow requests were NOT enabled (the membership module is on)." warn "Borrow requests will require an account. Turn membership off and re-run:" @@ -78,142 +80,12 @@ apply_request_access() { return 0 fi fi - "${COMPOSE[@]}" run --rm --no-deps -T backend --role management \ + ${COMPOSE[@]+"${COMPOSE[@]}"} run --rm --no-deps -T backend --role management \ python manage.py set_request_access --makerspace "$slug" --mode "$mode" \ || warn "Could not set who may submit borrow requests; the default (account required) stands." } -FIRST_RUN=0 -if [ -f .env ]; then - say "Found an existing .env — keeping your settings and secrets." -else - FIRST_RUN=1 - say "Welcome! Let's set up Space Works. Press Enter to accept the [default]." - read -r -p "Web address (host name or IP, no http://) [localhost]: " WEBADDR; WEBADDR="${WEBADDR:-localhost}" - # Normalize: strip any scheme, path, and port so ALLOWED_HOSTS/CORS are valid. - WEBHOST="${WEBADDR#*://}"; WEBHOST="${WEBHOST%%/*}"; WEBHOST="${WEBHOST%%:*}" - [ -n "$WEBHOST" ] || WEBHOST="localhost" - read -r -p "Name of your makerspace [My Makerspace]: " MSNAME; MSNAME="${MSNAME:-My Makerspace}" - # Edition: what this box is FOR. The loan spine is core and always present; an edition - # only decides which surfaces are shown and which public routes answer (docs/MODULES.md, - # "Editions"). Stored in .env as SPACEWORKS_EDITION and changeable later. - echo - echo "What is this installation for?" - echo " 1) A makerspace / tool library - lending, machines, events, bookings (default)" - echo " 2) Events only - an events programme with sign-ups and check-in" - echo " 3) Bookings only - rooms and resources people reserve" - echo " 4) An organization - the same as 1, labelled as an organization" - read -r -p "Choice [1]: " EDITION_CHOICE - case "${EDITION_CHOICE:-1}" in - 2) SPACEWORKS_EDITION="events" ;; - 3) SPACEWORKS_EDITION="bookings" ;; - 4) SPACEWORKS_EDITION="organization" ;; - *) SPACEWORKS_EDITION="makerspace" ;; - esac - # Install shape: several containers (default) or one application container beside the - # database and object storage. Same image contents, same rules; fewer things to run. - echo - echo "How should it run?" - echo " 1) Standard - separate containers for the API, worker, scheduler and web server" - echo " 2) Single box - one application container (plus the database and storage)" - read -r -p "Choice [1]: " SHAPE_CHOICE - if [ "${SHAPE_CHOICE:-1}" = 2 ]; then - printf 'single\n' > "$ROOT/.spaceworks-layer" - export SPACEWORKS_COMPOSE_LAYER=single - else - rm -f "$ROOT/.spaceworks-layer" - fi - # Modules are NOT asked here. The question moved to the end of setup, where the app is - # running and the real registry can be read, so the operator ticks actual module names - # instead of memorising profile words. `recommended` is only the starting point the tick - # list opens with; nothing is final until that step. - MSPROFILE="recommended" - # Asked here with the other identity questions, applied at the very END of setup: the - # answer implies the `membership` module, the module list is chosen after the app is - # running, and the real state can only be read back from the database once both have - # been applied. See apply_request_access. - echo - echo "Who can submit borrow requests?" - echo " 1) Members only - people you have enrolled as members of this makerspace" - echo " 2) Anyone with an account - any signed-in user; staff still accept every request" - echo " 3) Anyone, no account - a stranger leaves their name and contact details" - echo "Option 3 is an unauthenticated write surface: it is rate limited, contact details are" - echo "marked unverified, and no email is sent to them until you verify. You can change this" - echo "later with 'manage.py set_request_access'." - read -r -p "Choice [2]: " REQUEST_ACCESS_CHOICE - case "${REQUEST_ACCESS_CHOICE:-2}" in - 1) REQUEST_ACCESS=members ;; - 3) REQUEST_ACCESS=anyone ;; - 2) REQUEST_ACCESS=accounts ;; - *) warn "Unrecognised choice; requiring an account."; REQUEST_ACCESS=accounts ;; - esac - read -r -p "Admin login username [admin]: " ADMINUSER; ADMINUSER="${ADMINUSER:-admin}" - read -r -p "Admin email [admin@example.com]: " ADMINEMAIL; ADMINEMAIL="${ADMINEMAIL:-admin@example.com}" - read -r -s -p "Admin password (leave blank to auto-generate): " ADMINPASS; echo - GEN_PASS=0 - if [ -z "$ADMINPASS" ]; then ADMINPASS="$(rand_key 16)"; GEN_PASS=1; fi - # Google sign-in is GUIDED, never automatic. Client IDs are issued by Google against - # a specific origin, so no credential can ship in the box and "works out of the box" - # is impossible here -- guided is the ceiling. Skipping leaves username/password - # login fully working, which is the only login a fresh install has either way. - echo - echo "Optional: Google sign-in. To enable it you need a Google OAuth client ID:" - echo " 1. Open https://console.cloud.google.com/apis/credentials" - echo " 2. Create Credentials -> OAuth client ID -> Web application" - echo " 3. Add this to 'Authorised JavaScript origins': http://${WEBHOST}" - echo " 4. Copy the Client ID (it ends in .apps.googleusercontent.com)" - echo "Leave blank to skip — you can add it later in Platform settings." - read -r -p "Google Web client ID (optional): " GOOGLE_WEB_CLIENT_ID - GOOGLE_WEB_CLIENT_ID="$(printf '%s' "$GOOGLE_WEB_CLIENT_ID" | tr -d '[:space:]')" - if [ -n "$GOOGLE_WEB_CLIENT_ID" ] && [[ "$GOOGLE_WEB_CLIENT_ID" != *.apps.googleusercontent.com ]]; then - # Caught here rather than at first login: a wrong value makes the Google button - # appear and then fail token verification for every user, with nothing on screen - # explaining why. - warn "That does not look like a Google client ID; skipping Google sign-in." - GOOGLE_WEB_CLIENT_ID="" - fi - - read -r -p "Stripe secret key (optional; leave blank to skip): " STRIPE_SECRET_KEY - read -r -s -p "Stripe webhook secret (optional; leave blank to skip): " STRIPE_WEBHOOK_SECRET; echo - read -r -p "Stripe default currency [usd]: " STRIPE_DEFAULT_CURRENCY; STRIPE_DEFAULT_CURRENCY="${STRIPE_DEFAULT_CURRENCY:-usd}" - # Braces are load-bearing: && and || have EQUAL precedence in bash and associate left, - # so the unbraced form parsed as ((A && B) || C) && D and silently accepted a secret key - # with no webhook secret -- writing a half-configured Stripe account with no warning. - if { [ -n "$STRIPE_SECRET_KEY" ] && [ -z "$STRIPE_WEBHOOK_SECRET" ]; } \ - || { [ -z "$STRIPE_SECRET_KEY" ] && [ -n "$STRIPE_WEBHOOK_SECRET" ]; }; then - warn "Stripe needs both secrets; leaving payments unconfigured." - STRIPE_SECRET_KEY=""; STRIPE_WEBHOOK_SECRET="" - fi - - say "Writing .env (secrets generated automatically)..." - cat > .env <> .env fi @@ -229,37 +101,37 @@ chmod 600 .env prepare_compose_wrapper if [[ "$BUILD_FROM_SOURCE" == 1 ]]; then say "Building and starting the app from source (first run can take a few minutes)..." - "${COMPOSE[@]}" up -d --build + ${COMPOSE[@]+"${COMPOSE[@]}"} up -d --build else say "Pulling release images (first run can take a few minutes)..." - "${COMPOSE[@]}" pull - "${COMPOSE[@]}" up -d + ${COMPOSE[@]+"${COMPOSE[@]}"} pull + ${COMPOSE[@]+"${COMPOSE[@]}"} up -d fi # The deployment operation lock must be the same inode for backend, worker, beat and # the privileged host scripts. Docker performs the ownership step so setup does not # require host sudo merely to create uid 10001's bind mount. if ! grep -q '^BACKUP_AGE_RECIPIENT=' .env; then - "${COMPOSE[@]}" exec -T backend age-keygen \ + ${COMPOSE[@]+"${COMPOSE[@]}"} exec -T backend age-keygen \ -o /var/lib/spaceworks/ops/work/age-identity.txt >/dev/null - BACKUP_RECIPIENT="$("${COMPOSE[@]}" exec -T backend age-keygen \ + BACKUP_RECIPIENT="$(${COMPOSE[@]+"${COMPOSE[@]}"} exec -T backend age-keygen \ -y /var/lib/spaceworks/ops/work/age-identity.txt | tr -d '\r\n')" printf '\nBACKUP_AGE_RECIPIENT=%s\n' "$BACKUP_RECIPIENT" >> .env refresh_compose_config - "${COMPOSE[@]}" up -d + ${COMPOSE[@]+"${COMPOSE[@]}"} up -d fi if ! grep -q '^BACKUP_ARCHIVE_SIGNING_PRIVATE_KEY=' .env; then - SIGNING_PAIR="$("${COMPOSE[@]}" exec -T backend python -c 'from apps.ed25519 import encode_key,generate_keypair; p,q=generate_keypair(); print(encode_key(p)); print(encode_key(q))' | tr -d '\r')" + SIGNING_PAIR="$(${COMPOSE[@]+"${COMPOSE[@]}"} exec -T backend python -c 'from apps.ed25519 import encode_key,generate_keypair; p,q=generate_keypair(); print(encode_key(p)); print(encode_key(q))' | tr -d '\r')" printf '\nBACKUP_ARCHIVE_SIGNING_PRIVATE_KEY=%s\nBACKUP_ARCHIVE_VERIFY_PUBLIC_KEY=%s\n' "$(printf '%s\n' "$SIGNING_PAIR" | sed -n '1p')" "$(printf '%s\n' "$SIGNING_PAIR" | sed -n '2p')" >> .env refresh_compose_config - "${COMPOSE[@]}" up -d + ${COMPOSE[@]+"${COMPOSE[@]}"} up -d fi install_producer_capability say "Waiting for the app to be ready..." ready=0 for _ in $(seq 1 60); do - if "${COMPOSE[@]}" exec -T backend python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/v1/health/readiness/', timeout=3)" >/dev/null 2>&1; then + if ${COMPOSE[@]+"${COMPOSE[@]}"} exec -T backend python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/v1/health/readiness/', timeout=3)" >/dev/null 2>&1; then ready=1; break fi sleep 3 @@ -268,7 +140,7 @@ done if [ "$FIRST_RUN" = 1 ]; then say "Creating your admin account and makerspace..." - "${COMPOSE[@]}" run --rm --no-deps -T backend --role management python manage.py setup_instance \ + ${COMPOSE[@]+"${COMPOSE[@]}"} run --rm --no-deps -T backend --role management python manage.py setup_instance \ --username "$ADMINUSER" --email "$ADMINEMAIL" --password "$ADMINPASS" \ --makerspace-name "$MSNAME" --profile "$MSPROFILE" @@ -276,7 +148,7 @@ if [ "$FIRST_RUN" = 1 ]; then warn "Module selection was not applied. Re-run scripts/update.sh --modules-only after fixing the message above." fi - MS_SLUG="$("${COMPOSE[@]}" run --rm --no-deps -T backend --role management \ + MS_SLUG="$(${COMPOSE[@]+"${COMPOSE[@]}"} run --rm --no-deps -T backend --role management \ python manage.py list_modules --json 2>/dev/null \ | sed -n 's/.*"makerspace": *"\([^"]*\)".*/\1/p' | head -n 1)" if [ -n "$MS_SLUG" ]; then @@ -288,7 +160,7 @@ if [ "$FIRST_RUN" = 1 ]; then if [ -n "$GOOGLE_WEB_CLIENT_ID" ]; then say "Enabling Google sign-in..." - if ! "${COMPOSE[@]}" run --rm --no-deps -T backend --role management python manage.py configure_social_auth \ + if ! ${COMPOSE[@]+"${COMPOSE[@]}"} run --rm --no-deps -T backend --role management python manage.py configure_social_auth \ --google-web-client-id "$GOOGLE_WEB_CLIENT_ID"; then warn "Could not save the Google client ID. Add it later in Platform settings; password login is unaffected." fi @@ -308,7 +180,7 @@ if [ "$FIRST_RUN" = 1 ]; then fi else if bash scripts/install-auto-update.sh; then - "${COMPOSE[@]}" run --rm --no-deps -T backend --role management python manage.py update_control set-auto off >/dev/null + ${COMPOSE[@]+"${COMPOSE[@]}"} run --rm --no-deps -T backend --role management python manage.py update_control set-auto off >/dev/null warn "Automatic installation is off. The host will still check for releases so Update now works from Platform settings." else warn "Could not install the seven-day update checker. Run bash scripts/install-auto-update.sh later, then turn automatic updates off in Platform settings."