Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 39 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<run>.<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):
Expand All @@ -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
Expand Down
12 changes: 10 additions & 2 deletions backend/apps/admin_api/serializers_platform_updates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 3 additions & 5 deletions backend/apps/admin_api/views_platform_updates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion backend/apps/backup/host_topology_record.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion backend/apps/data_export/classification_platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion backend/apps/tenant_migration/tenant_dump_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down
15 changes: 1 addition & 14 deletions backend/apps/updates/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,19 @@

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):
form = PlatformUpdateSettingsForm
audit_action = "platform.update_settings_updated"
list_display = (
"automatic_updates_enabled",
"channel",
"status",
"current_version",
"available_version",
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion backend/apps/updates/management/commands/update_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
Original file line number Diff line number Diff line change
@@ -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"),
]
5 changes: 0 additions & 5 deletions backend/apps/updates/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,13 @@


class PlatformUpdateSettings(models.Model):
class Channel(models.TextChoices):
STABLE = "stable", "Stable"
BETA = "beta", "Beta"

class Status(models.TextChoices):
IDLE = "idle", "Idle"
QUEUED = "queued", "Queued"
RUNNING = "running", "Running"
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)
Expand Down
17 changes: 8 additions & 9 deletions backend/apps/updates/releases.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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"))
Expand All @@ -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]
22 changes: 5 additions & 17 deletions backend/apps/updates/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
6 changes: 3 additions & 3 deletions docs/MODULES-PLATFORM.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading