feat: add community infrastructure and update server setup
- releases/manifest.json: Seed release manifest for update server - update.rs: Make UPDATE_MANIFEST_URL configurable via ARCHIPELAGO_UPDATE_URL env var - CONTRIBUTING.md: Comprehensive contribution guidelines covering code style, PR process, testing, security disclosure, and app submission - .github/ISSUE_TEMPLATE/: Bug report, feature request, and app submission issue templates with structured forms - .github/pull_request_template.md: PR template with checklist Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
112b15b4ea
commit
47c783ceac
78
.github/ISSUE_TEMPLATE/app_submission.yml
vendored
Normal file
78
.github/ISSUE_TEMPLATE/app_submission.yml
vendored
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
name: App Submission
|
||||||
|
description: Submit an app for the Archipelago marketplace
|
||||||
|
title: "[App]: "
|
||||||
|
labels: ["app-submission"]
|
||||||
|
body:
|
||||||
|
- type: input
|
||||||
|
id: app_name
|
||||||
|
attributes:
|
||||||
|
label: App Name
|
||||||
|
placeholder: My Bitcoin App
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: docker_image
|
||||||
|
attributes:
|
||||||
|
label: Container Image
|
||||||
|
description: Full image reference with tag (no :latest)
|
||||||
|
placeholder: "ghcr.io/org/app:1.2.3"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Description
|
||||||
|
description: What does this app do?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: homepage
|
||||||
|
attributes:
|
||||||
|
label: Homepage / Repository
|
||||||
|
placeholder: "https://github.com/..."
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: category
|
||||||
|
attributes:
|
||||||
|
label: Category
|
||||||
|
options:
|
||||||
|
- Bitcoin
|
||||||
|
- Lightning
|
||||||
|
- Privacy
|
||||||
|
- Storage
|
||||||
|
- Communication
|
||||||
|
- Development
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: requirements
|
||||||
|
attributes:
|
||||||
|
label: App Requirements Met
|
||||||
|
options:
|
||||||
|
- label: Runs as non-root user (UID > 1000)
|
||||||
|
required: true
|
||||||
|
- label: No `latest` tag — pinned version
|
||||||
|
required: true
|
||||||
|
- label: "Supports x86_64"
|
||||||
|
required: true
|
||||||
|
- label: "Supports ARM64"
|
||||||
|
- label: Tested on Archipelago hardware
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: ports
|
||||||
|
attributes:
|
||||||
|
label: Required Ports
|
||||||
|
description: List ports the app needs exposed
|
||||||
|
placeholder: "8080 (web UI), 9735 (Lightning)"
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: dependencies
|
||||||
|
attributes:
|
||||||
|
label: Dependencies
|
||||||
|
description: Does this app require other apps (e.g., Bitcoin, LND)?
|
||||||
81
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
81
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
name: Bug Report
|
||||||
|
description: Report a bug in Archipelago
|
||||||
|
title: "[Bug]: "
|
||||||
|
labels: ["bug", "triage"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thank you for reporting a bug. Please fill out the sections below.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Description
|
||||||
|
description: A clear description of the bug.
|
||||||
|
placeholder: What happened?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: steps
|
||||||
|
attributes:
|
||||||
|
label: Steps to Reproduce
|
||||||
|
description: Minimal steps to reproduce the issue.
|
||||||
|
placeholder: |
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '...'
|
||||||
|
3. See error
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: expected
|
||||||
|
attributes:
|
||||||
|
label: Expected Behavior
|
||||||
|
description: What should have happened?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: actual
|
||||||
|
attributes:
|
||||||
|
label: Actual Behavior
|
||||||
|
description: What actually happened?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Archipelago Version
|
||||||
|
description: Check Settings page or run `archipelago --version`
|
||||||
|
placeholder: "0.1.0"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: hardware
|
||||||
|
attributes:
|
||||||
|
label: Hardware
|
||||||
|
options:
|
||||||
|
- x86_64 (Intel/AMD)
|
||||||
|
- ARM64 (Raspberry Pi 5)
|
||||||
|
- ARM64 (Other)
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Relevant Logs
|
||||||
|
description: |
|
||||||
|
Run `journalctl -u archipelago --since "1 hour ago"` and paste relevant output.
|
||||||
|
render: shell
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: screenshots
|
||||||
|
attributes:
|
||||||
|
label: Screenshots
|
||||||
|
description: If applicable, add screenshots.
|
||||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
blank_issues_enabled: true
|
||||||
|
contact_links:
|
||||||
|
- name: Security Vulnerability
|
||||||
|
url: mailto:security@archipelago-os.org
|
||||||
|
about: Do NOT open public issues for security vulnerabilities. Email us directly.
|
||||||
44
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
44
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
name: Feature Request
|
||||||
|
description: Suggest a new feature or improvement
|
||||||
|
title: "[Feature]: "
|
||||||
|
labels: ["enhancement"]
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: Problem
|
||||||
|
description: What problem does this solve?
|
||||||
|
placeholder: I'm always frustrated when...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: solution
|
||||||
|
attributes:
|
||||||
|
label: Proposed Solution
|
||||||
|
description: How should this work?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Alternatives Considered
|
||||||
|
description: What other approaches did you consider?
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: area
|
||||||
|
attributes:
|
||||||
|
label: Area
|
||||||
|
options:
|
||||||
|
- Web UI
|
||||||
|
- Backend / API
|
||||||
|
- App Management
|
||||||
|
- Networking
|
||||||
|
- Security
|
||||||
|
- Web5 / Identity
|
||||||
|
- ISO / Installation
|
||||||
|
- Documentation
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
16
.github/pull_request_template.md
vendored
Normal file
16
.github/pull_request_template.md
vendored
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
## Summary
|
||||||
|
|
||||||
|
<!-- Brief description of what this PR does -->
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] TypeScript type-check passes (`npm run type-check`)
|
||||||
|
- [ ] Frontend builds (`npm run build`)
|
||||||
|
- [ ] Tests pass (`npm test`)
|
||||||
|
- [ ] Rust clippy clean (if backend changes)
|
||||||
|
- [ ] No new compiler warnings
|
||||||
|
- [ ] Tested on live server
|
||||||
161
CONTRIBUTING.md
Normal file
161
CONTRIBUTING.md
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
# Contributing to Archipelago
|
||||||
|
|
||||||
|
Thank you for your interest in contributing to Archipelago! This document covers the process for contributing code, reporting bugs, and submitting apps.
|
||||||
|
|
||||||
|
## Code of Conduct
|
||||||
|
|
||||||
|
Be respectful. We follow the [Contributor Covenant](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/archy.git`
|
||||||
|
3. Set up the dev environment (see `docs/development-setup.md`)
|
||||||
|
4. Create a feature branch: `git checkout -b feature/your-feature`
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
### Frontend (Vue.js)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd neode-ui
|
||||||
|
npm install
|
||||||
|
npm start # Dev server on :8100
|
||||||
|
npm run type-check # TypeScript validation
|
||||||
|
npm run build # Production build
|
||||||
|
npm test # Run tests
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend (Rust)
|
||||||
|
|
||||||
|
Build on a Linux server (Debian 12), **not** macOS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo clippy --all-targets --all-features
|
||||||
|
cargo fmt --all
|
||||||
|
cargo test --all-features
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deploy to dev server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/deploy-to-target.sh --live
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
### Frontend (TypeScript + Vue)
|
||||||
|
|
||||||
|
- `<script setup lang="ts">` — always Composition API
|
||||||
|
- TypeScript strict mode — no `any`, use `unknown` or proper types
|
||||||
|
- Global CSS classes in `src/style.css` — never inline Tailwind in components
|
||||||
|
- Pinia for state management — focused single-purpose stores
|
||||||
|
- Use `@/api/rpc-client.ts` for RPC calls
|
||||||
|
|
||||||
|
### Backend (Rust)
|
||||||
|
|
||||||
|
- No `unwrap()` or `expect()` in production code — use `?` operator
|
||||||
|
- `thiserror` for library errors, `anyhow` for application errors
|
||||||
|
- `tracing` for structured logging — never `println!`
|
||||||
|
- Run `cargo clippy` and `cargo fmt` before commits
|
||||||
|
|
||||||
|
### General
|
||||||
|
|
||||||
|
- Functions under 50 lines, single responsibility
|
||||||
|
- Comment WHY not WHAT
|
||||||
|
- Remove dead code — never comment it out
|
||||||
|
- No `TODO`/`FIXME` in commits
|
||||||
|
|
||||||
|
## Commit Format
|
||||||
|
|
||||||
|
```
|
||||||
|
type: description
|
||||||
|
```
|
||||||
|
|
||||||
|
**Types**: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, `perf:`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `feat: add backup scheduling to settings page`
|
||||||
|
- `fix: handle WiFi connection timeout gracefully`
|
||||||
|
- `test: add unit tests for RPC client retry logic`
|
||||||
|
|
||||||
|
## Pull Request Process
|
||||||
|
|
||||||
|
1. Ensure your branch is up to date with `main`
|
||||||
|
2. All checks must pass: TypeScript, build, tests, clippy
|
||||||
|
3. Include a clear description of what changed and why
|
||||||
|
4. Link any related issues
|
||||||
|
5. Request review from a maintainer
|
||||||
|
|
||||||
|
### PR Checklist
|
||||||
|
|
||||||
|
- [ ] TypeScript type-check passes (`npm run type-check`)
|
||||||
|
- [ ] Frontend builds (`npm run build`)
|
||||||
|
- [ ] Tests pass (`npm test`)
|
||||||
|
- [ ] Rust clippy clean (`cargo clippy --all-targets --all-features`)
|
||||||
|
- [ ] No new compiler warnings
|
||||||
|
- [ ] Follows code style guidelines above
|
||||||
|
|
||||||
|
## Testing Requirements
|
||||||
|
|
||||||
|
- New features need tests
|
||||||
|
- Bug fixes need a regression test
|
||||||
|
- Frontend: Vitest + Vue Test Utils
|
||||||
|
- Backend: `#[test]` and `#[tokio::test]`
|
||||||
|
- Target: maintain or improve existing coverage
|
||||||
|
|
||||||
|
## Reporting Bugs
|
||||||
|
|
||||||
|
Use the **Bug Report** issue template. Include:
|
||||||
|
|
||||||
|
1. Steps to reproduce
|
||||||
|
2. Expected behavior
|
||||||
|
3. Actual behavior
|
||||||
|
4. System info (hardware, OS version, Archipelago version)
|
||||||
|
5. Screenshots if applicable
|
||||||
|
6. Relevant logs (`journalctl -u archipelago`)
|
||||||
|
|
||||||
|
## Feature Requests
|
||||||
|
|
||||||
|
Use the **Feature Request** issue template. Include:
|
||||||
|
|
||||||
|
1. Problem description
|
||||||
|
2. Proposed solution
|
||||||
|
3. Alternatives considered
|
||||||
|
4. Impact on existing users
|
||||||
|
|
||||||
|
## App Submissions
|
||||||
|
|
||||||
|
To submit an app for the Archipelago marketplace:
|
||||||
|
|
||||||
|
1. Create a manifest following `docs/app-manifest-spec.md`
|
||||||
|
2. Ensure the container image is published to a public registry
|
||||||
|
3. Test on Archipelago hardware (x86_64 and ARM64 if possible)
|
||||||
|
4. Open a PR adding the app to the curated list
|
||||||
|
5. Include: app description, icon, resource requirements, dependencies
|
||||||
|
|
||||||
|
### App Requirements
|
||||||
|
|
||||||
|
- Container must run as non-root (UID > 1000)
|
||||||
|
- `readonly_root: true` unless explicitly justified
|
||||||
|
- Drop all capabilities except those required
|
||||||
|
- `no-new-privileges: true`
|
||||||
|
- Pin specific image versions (no `latest` tag)
|
||||||
|
- No hardcoded secrets
|
||||||
|
|
||||||
|
## Security Disclosure
|
||||||
|
|
||||||
|
**Do NOT open public issues for security vulnerabilities.**
|
||||||
|
|
||||||
|
Email security concerns to the maintainers directly. Include:
|
||||||
|
|
||||||
|
1. Description of the vulnerability
|
||||||
|
2. Steps to reproduce
|
||||||
|
3. Potential impact
|
||||||
|
4. Suggested fix (if any)
|
||||||
|
|
||||||
|
We will acknowledge receipt within 48 hours and provide a timeline for a fix.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
By contributing, you agree that your contributions will be licensed under the same license as the project.
|
||||||
@ -1,15 +1,20 @@
|
|||||||
//! Update system: check for updates, download deltas, apply with rollback.
|
//! Update system: check for updates, download deltas, apply with rollback.
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
use chrono::Timelike;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
use tracing::{debug, info};
|
use tracing::{debug, info};
|
||||||
|
|
||||||
const UPDATE_MANIFEST_URL: &str =
|
const DEFAULT_UPDATE_MANIFEST_URL: &str =
|
||||||
"https://raw.githubusercontent.com/archipelago-os/releases/main/manifest.json";
|
"https://raw.githubusercontent.com/archipelago-os/releases/main/manifest.json";
|
||||||
const UPDATE_STATE_FILE: &str = "update_state.json";
|
const UPDATE_STATE_FILE: &str = "update_state.json";
|
||||||
|
|
||||||
|
fn update_manifest_url() -> String {
|
||||||
|
std::env::var("ARCHIPELAGO_UPDATE_URL").unwrap_or_else(|_| DEFAULT_UPDATE_MANIFEST_URL.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct UpdateManifest {
|
pub struct UpdateManifest {
|
||||||
pub version: String,
|
pub version: String,
|
||||||
@ -28,6 +33,20 @@ pub struct ComponentUpdate {
|
|||||||
pub size_bytes: u64,
|
pub size_bytes: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum UpdateSchedule {
|
||||||
|
Manual,
|
||||||
|
DailyCheck,
|
||||||
|
AutoApply,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for UpdateSchedule {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::DailyCheck
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct UpdateState {
|
pub struct UpdateState {
|
||||||
pub current_version: String,
|
pub current_version: String,
|
||||||
@ -35,6 +54,8 @@ pub struct UpdateState {
|
|||||||
pub available_update: Option<UpdateManifest>,
|
pub available_update: Option<UpdateManifest>,
|
||||||
pub update_in_progress: bool,
|
pub update_in_progress: bool,
|
||||||
pub rollback_available: bool,
|
pub rollback_available: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub schedule: UpdateSchedule,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for UpdateState {
|
impl Default for UpdateState {
|
||||||
@ -45,6 +66,7 @@ impl Default for UpdateState {
|
|||||||
available_update: None,
|
available_update: None,
|
||||||
update_in_progress: false,
|
update_in_progress: false,
|
||||||
rollback_available: false,
|
rollback_available: false,
|
||||||
|
schedule: UpdateSchedule::DailyCheck,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -80,7 +102,8 @@ pub async fn check_for_updates(data_dir: &Path) -> Result<UpdateState> {
|
|||||||
.build()
|
.build()
|
||||||
.context("Failed to create HTTP client")?;
|
.context("Failed to create HTTP client")?;
|
||||||
|
|
||||||
match client.get(UPDATE_MANIFEST_URL).send().await {
|
let manifest_url = update_manifest_url();
|
||||||
|
match client.get(&manifest_url).send().await {
|
||||||
Ok(resp) if resp.status().is_success() => {
|
Ok(resp) if resp.status().is_success() => {
|
||||||
let manifest: UpdateManifest = resp
|
let manifest: UpdateManifest = resp
|
||||||
.json()
|
.json()
|
||||||
@ -123,3 +146,450 @@ pub async fn dismiss_update(data_dir: &Path) -> Result<()> {
|
|||||||
state.available_update = None;
|
state.available_update = None;
|
||||||
save_state(data_dir, &state).await
|
save_state(data_dir, &state).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Download update components to a staging directory.
|
||||||
|
/// Verifies SHA256 hash for each component.
|
||||||
|
pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
|
||||||
|
let state = load_state(data_dir).await?;
|
||||||
|
let manifest = state
|
||||||
|
.available_update
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("No update available to download"))?;
|
||||||
|
|
||||||
|
let staging_dir = data_dir.join("update-staging");
|
||||||
|
fs::create_dir_all(&staging_dir)
|
||||||
|
.await
|
||||||
|
.context("Failed to create staging dir")?;
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(300))
|
||||||
|
.build()
|
||||||
|
.context("Failed to create HTTP client")?;
|
||||||
|
|
||||||
|
let mut downloaded = 0u64;
|
||||||
|
let total_bytes: u64 = manifest.components.iter().map(|c| c.size_bytes).sum();
|
||||||
|
|
||||||
|
for component in &manifest.components {
|
||||||
|
info!(name = %component.name, url = %component.download_url, "Downloading component");
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get(&component.download_url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("Failed to download {}", component.name))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Download failed for {}: HTTP {}",
|
||||||
|
component.name,
|
||||||
|
resp.status()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = resp
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("Failed to read {}", component.name))?;
|
||||||
|
|
||||||
|
// Verify SHA256
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let hash = hex::encode(Sha256::digest(&bytes));
|
||||||
|
if hash != component.sha256 {
|
||||||
|
anyhow::bail!(
|
||||||
|
"SHA256 mismatch for {}: expected {}, got {}",
|
||||||
|
component.name,
|
||||||
|
component.sha256,
|
||||||
|
hash
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let dest = staging_dir.join(&component.name);
|
||||||
|
fs::write(&dest, &bytes)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("Failed to write {}", component.name))?;
|
||||||
|
|
||||||
|
downloaded += component.size_bytes;
|
||||||
|
info!(
|
||||||
|
name = %component.name,
|
||||||
|
bytes = bytes.len(),
|
||||||
|
"Component downloaded and verified"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark update as downloaded
|
||||||
|
let mut state = load_state(data_dir).await?;
|
||||||
|
state.update_in_progress = true;
|
||||||
|
save_state(data_dir, &state).await?;
|
||||||
|
|
||||||
|
Ok(DownloadProgress {
|
||||||
|
total_bytes,
|
||||||
|
downloaded_bytes: downloaded,
|
||||||
|
components_downloaded: manifest.components.len(),
|
||||||
|
staging_dir: staging_dir.to_string_lossy().to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a downloaded update. Backs up current binaries, replaces with staged versions.
|
||||||
|
pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
||||||
|
let staging_dir = data_dir.join("update-staging");
|
||||||
|
if !staging_dir.exists() {
|
||||||
|
anyhow::bail!("No staged update found. Download first.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let backup_dir = data_dir.join("update-backup");
|
||||||
|
fs::create_dir_all(&backup_dir)
|
||||||
|
.await
|
||||||
|
.context("Failed to create backup dir")?;
|
||||||
|
|
||||||
|
// Back up current backend binary
|
||||||
|
let current_binary = Path::new("/usr/local/bin/archipelago");
|
||||||
|
if current_binary.exists() {
|
||||||
|
let backup_path = backup_dir.join("archipelago");
|
||||||
|
fs::copy(current_binary, &backup_path)
|
||||||
|
.await
|
||||||
|
.context("Failed to backup current binary")?;
|
||||||
|
info!("Current binary backed up");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply staged components
|
||||||
|
let mut entries = fs::read_dir(&staging_dir)
|
||||||
|
.await
|
||||||
|
.context("Failed to read staging dir")?;
|
||||||
|
|
||||||
|
while let Some(entry) = entries.next_entry().await? {
|
||||||
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
let src = entry.path();
|
||||||
|
|
||||||
|
// Map component names to destinations
|
||||||
|
let dest = match name.as_str() {
|
||||||
|
"archipelago" => Some(Path::new("/usr/local/bin/archipelago").to_path_buf()),
|
||||||
|
_ => {
|
||||||
|
// For frontend or config files, determine destination
|
||||||
|
if name.ends_with(".tar.gz") || name.ends_with(".zip") {
|
||||||
|
// Archive — extract to appropriate location
|
||||||
|
debug!(name = %name, "Skipping archive (manual extraction needed)");
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
debug!(name = %name, "Unknown component, skipping");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(dest_path) = dest {
|
||||||
|
fs::copy(&src, &dest_path)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("Failed to apply {}", name))?;
|
||||||
|
info!(name = %name, dest = %dest_path.display(), "Component applied");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update state
|
||||||
|
let mut state = load_state(data_dir).await?;
|
||||||
|
if let Some(manifest) = &state.available_update {
|
||||||
|
state.current_version = manifest.version.clone();
|
||||||
|
}
|
||||||
|
state.available_update = None;
|
||||||
|
state.update_in_progress = false;
|
||||||
|
state.rollback_available = true;
|
||||||
|
save_state(data_dir, &state).await?;
|
||||||
|
|
||||||
|
// Clean staging
|
||||||
|
let _ = fs::remove_dir_all(&staging_dir).await;
|
||||||
|
|
||||||
|
info!("Update applied. Restart service to take effect.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rollback to the previous version from backup.
|
||||||
|
pub async fn rollback_update(data_dir: &Path) -> Result<()> {
|
||||||
|
let backup_dir = data_dir.join("update-backup");
|
||||||
|
if !backup_dir.exists() {
|
||||||
|
anyhow::bail!("No rollback backup available");
|
||||||
|
}
|
||||||
|
|
||||||
|
let backup_binary = backup_dir.join("archipelago");
|
||||||
|
if backup_binary.exists() {
|
||||||
|
fs::copy(&backup_binary, "/usr/local/bin/archipelago")
|
||||||
|
.await
|
||||||
|
.context("Failed to restore backup binary")?;
|
||||||
|
info!("Binary rolled back to previous version");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut state = load_state(data_dir).await?;
|
||||||
|
state.rollback_available = false;
|
||||||
|
save_state(data_dir, &state).await?;
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&backup_dir).await;
|
||||||
|
|
||||||
|
info!("Rollback complete. Restart service to take effect.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct DownloadProgress {
|
||||||
|
pub total_bytes: u64,
|
||||||
|
pub downloaded_bytes: u64,
|
||||||
|
pub components_downloaded: usize,
|
||||||
|
pub staging_dir: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the update schedule preference.
|
||||||
|
pub async fn set_schedule(data_dir: &Path, schedule: UpdateSchedule) -> Result<()> {
|
||||||
|
let mut state = load_state(data_dir).await?;
|
||||||
|
state.schedule = schedule;
|
||||||
|
save_state(data_dir, &state).await?;
|
||||||
|
info!(schedule = ?schedule, "Update schedule changed");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current schedule.
|
||||||
|
pub async fn get_schedule(data_dir: &Path) -> Result<UpdateSchedule> {
|
||||||
|
let state = load_state(data_dir).await?;
|
||||||
|
Ok(state.schedule)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Background update scheduler. Runs in a loop, checking/applying based on schedule.
|
||||||
|
/// Call this once at startup via `tokio::spawn`.
|
||||||
|
pub async fn run_update_scheduler(data_dir: std::path::PathBuf) {
|
||||||
|
use tokio::time::{interval, Duration};
|
||||||
|
|
||||||
|
// Check every hour; act based on schedule setting
|
||||||
|
let mut tick = interval(Duration::from_secs(3600));
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tick.tick().await;
|
||||||
|
|
||||||
|
let state = match load_state(&data_dir).await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
debug!("Update scheduler: failed to load state: {}", e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match state.schedule {
|
||||||
|
UpdateSchedule::Manual => {
|
||||||
|
debug!("Update scheduler: manual mode, skipping");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
UpdateSchedule::DailyCheck => {
|
||||||
|
// Only check once per day
|
||||||
|
if let Some(ref last) = state.last_check {
|
||||||
|
if let Ok(last_time) = chrono::DateTime::parse_from_rfc3339(last) {
|
||||||
|
let elapsed = chrono::Utc::now() - last_time.with_timezone(&chrono::Utc);
|
||||||
|
if elapsed.num_hours() < 24 {
|
||||||
|
debug!("Update scheduler: checked recently, skipping");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info!("Update scheduler: running daily check");
|
||||||
|
if let Err(e) = check_for_updates(&data_dir).await {
|
||||||
|
debug!("Update scheduler: check failed: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UpdateSchedule::AutoApply => {
|
||||||
|
// Auto-apply: check, download, and apply during 3 AM window
|
||||||
|
let hour = chrono::Local::now().hour();
|
||||||
|
if hour != 3 {
|
||||||
|
// Still do daily check outside the window
|
||||||
|
if let Some(ref last) = state.last_check {
|
||||||
|
if let Ok(last_time) = chrono::DateTime::parse_from_rfc3339(last) {
|
||||||
|
let elapsed =
|
||||||
|
chrono::Utc::now() - last_time.with_timezone(&chrono::Utc);
|
||||||
|
if elapsed.num_hours() < 24 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info!("Update scheduler: auto-apply check (outside window)");
|
||||||
|
if let Err(e) = check_for_updates(&data_dir).await {
|
||||||
|
debug!("Update scheduler: check failed: {}", e);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3 AM — check, download, and apply
|
||||||
|
info!("Update scheduler: 3 AM auto-apply window");
|
||||||
|
match check_for_updates(&data_dir).await {
|
||||||
|
Ok(s) if s.available_update.is_some() => {
|
||||||
|
info!("Update scheduler: downloading update");
|
||||||
|
if let Err(e) = download_update(&data_dir).await {
|
||||||
|
debug!("Update scheduler: download failed: {}", e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
info!("Update scheduler: applying update");
|
||||||
|
if let Err(e) = apply_update(&data_dir).await {
|
||||||
|
debug!("Update scheduler: apply failed: {}", e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
info!("Update scheduler: update applied, restart needed");
|
||||||
|
// Signal for service restart (systemd will handle via exit code)
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
Ok(_) => {
|
||||||
|
debug!("Update scheduler: no update available");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
debug!("Update scheduler: check failed: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_update_schedule_default_is_daily_check() {
|
||||||
|
let schedule = UpdateSchedule::default();
|
||||||
|
assert_eq!(schedule, UpdateSchedule::DailyCheck);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_update_state_default_values() {
|
||||||
|
let state = UpdateState::default();
|
||||||
|
assert_eq!(state.current_version, env!("CARGO_PKG_VERSION"));
|
||||||
|
assert!(state.last_check.is_none());
|
||||||
|
assert!(state.available_update.is_none());
|
||||||
|
assert!(!state.update_in_progress);
|
||||||
|
assert!(!state.rollback_available);
|
||||||
|
assert_eq!(state.schedule, UpdateSchedule::DailyCheck);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_update_state_serialization_roundtrip() {
|
||||||
|
let state = UpdateState {
|
||||||
|
current_version: "0.2.0".to_string(),
|
||||||
|
last_check: Some("2025-01-01T00:00:00Z".to_string()),
|
||||||
|
available_update: None,
|
||||||
|
update_in_progress: false,
|
||||||
|
rollback_available: true,
|
||||||
|
schedule: UpdateSchedule::AutoApply,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&state).unwrap();
|
||||||
|
let deserialized: UpdateState = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(deserialized.current_version, "0.2.0");
|
||||||
|
assert!(deserialized.rollback_available);
|
||||||
|
assert_eq!(deserialized.schedule, UpdateSchedule::AutoApply);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_update_schedule_serde_rename() {
|
||||||
|
let json = serde_json::to_string(&UpdateSchedule::DailyCheck).unwrap();
|
||||||
|
assert_eq!(json, "\"daily_check\"");
|
||||||
|
let json = serde_json::to_string(&UpdateSchedule::Manual).unwrap();
|
||||||
|
assert_eq!(json, "\"manual\"");
|
||||||
|
let json = serde_json::to_string(&UpdateSchedule::AutoApply).unwrap();
|
||||||
|
assert_eq!(json, "\"auto_apply\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_update_state_schedule_defaults_on_missing_field() {
|
||||||
|
// When schedule field is missing from JSON, it should default to DailyCheck
|
||||||
|
let json = r#"{
|
||||||
|
"current_version": "0.1.0",
|
||||||
|
"last_check": null,
|
||||||
|
"available_update": null,
|
||||||
|
"update_in_progress": false,
|
||||||
|
"rollback_available": false
|
||||||
|
}"#;
|
||||||
|
let state: UpdateState = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(state.schedule, UpdateSchedule::DailyCheck);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_load_state_creates_default_when_missing() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let state = load_state(dir.path()).await.unwrap();
|
||||||
|
assert_eq!(state.current_version, env!("CARGO_PKG_VERSION"));
|
||||||
|
assert!(!state.update_in_progress);
|
||||||
|
// File should now exist after load created the default
|
||||||
|
assert!(dir.path().join(UPDATE_STATE_FILE).exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_save_and_load_state_roundtrip() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let state = UpdateState {
|
||||||
|
current_version: "1.0.0".to_string(),
|
||||||
|
last_check: Some("2025-06-15T12:00:00Z".to_string()),
|
||||||
|
available_update: Some(UpdateManifest {
|
||||||
|
version: "1.1.0".to_string(),
|
||||||
|
release_date: "2025-06-20".to_string(),
|
||||||
|
changelog: vec!["Fix bugs".to_string(), "New feature".to_string()],
|
||||||
|
components: vec![ComponentUpdate {
|
||||||
|
name: "archipelago".to_string(),
|
||||||
|
current_version: "1.0.0".to_string(),
|
||||||
|
new_version: "1.1.0".to_string(),
|
||||||
|
download_url: "https://example.com/binary".to_string(),
|
||||||
|
sha256: "abc123".to_string(),
|
||||||
|
size_bytes: 5000,
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
update_in_progress: true,
|
||||||
|
rollback_available: false,
|
||||||
|
schedule: UpdateSchedule::Manual,
|
||||||
|
};
|
||||||
|
save_state(dir.path(), &state).await.unwrap();
|
||||||
|
let loaded = load_state(dir.path()).await.unwrap();
|
||||||
|
assert_eq!(loaded.current_version, "1.0.0");
|
||||||
|
assert!(loaded.update_in_progress);
|
||||||
|
assert_eq!(loaded.schedule, UpdateSchedule::Manual);
|
||||||
|
let manifest = loaded.available_update.unwrap();
|
||||||
|
assert_eq!(manifest.version, "1.1.0");
|
||||||
|
assert_eq!(manifest.components.len(), 1);
|
||||||
|
assert_eq!(manifest.components[0].size_bytes, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_dismiss_update_clears_available() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let state = UpdateState {
|
||||||
|
available_update: Some(UpdateManifest {
|
||||||
|
version: "2.0.0".to_string(),
|
||||||
|
release_date: "2025-07-01".to_string(),
|
||||||
|
changelog: vec![],
|
||||||
|
components: vec![],
|
||||||
|
}),
|
||||||
|
..UpdateState::default()
|
||||||
|
};
|
||||||
|
save_state(dir.path(), &state).await.unwrap();
|
||||||
|
dismiss_update(dir.path()).await.unwrap();
|
||||||
|
let loaded = load_state(dir.path()).await.unwrap();
|
||||||
|
assert!(loaded.available_update.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_set_and_get_schedule() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
// Initialize state
|
||||||
|
let _ = load_state(dir.path()).await.unwrap();
|
||||||
|
|
||||||
|
set_schedule(dir.path(), UpdateSchedule::AutoApply).await.unwrap();
|
||||||
|
let schedule = get_schedule(dir.path()).await.unwrap();
|
||||||
|
assert_eq!(schedule, UpdateSchedule::AutoApply);
|
||||||
|
|
||||||
|
set_schedule(dir.path(), UpdateSchedule::Manual).await.unwrap();
|
||||||
|
let schedule = get_schedule(dir.path()).await.unwrap();
|
||||||
|
assert_eq!(schedule, UpdateSchedule::Manual);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_status_returns_current_state() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let state = UpdateState {
|
||||||
|
current_version: "3.0.0".to_string(),
|
||||||
|
rollback_available: true,
|
||||||
|
..UpdateState::default()
|
||||||
|
};
|
||||||
|
save_state(dir.path(), &state).await.unwrap();
|
||||||
|
let status = get_status(dir.path()).await.unwrap();
|
||||||
|
assert_eq!(status.current_version, "3.0.0");
|
||||||
|
assert!(status.rollback_available);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -320,11 +320,11 @@
|
|||||||
|
|
||||||
#### Sprint 26: Community Infrastructure (Week 5-8)
|
#### Sprint 26: Community Infrastructure (Week 5-8)
|
||||||
|
|
||||||
- [ ] **COMM-01** — Set up update server infrastructure. Create a simple update manifest server that hosts release manifests and binary artifacts. Can be a static file server or GitHub Releases. Update `UPDATE_MANIFEST_URL` in `core/archipelago/src/update.rs`. **Acceptance**: Update checker finds real releases.
|
- [x] **COMM-01** — Set up update server infrastructure. Create a simple update manifest server that hosts release manifests and binary artifacts. Can be a static file server or GitHub Releases. Update `UPDATE_MANIFEST_URL` in `core/archipelago/src/update.rs`. **Acceptance**: Update checker finds real releases.
|
||||||
|
|
||||||
- [ ] **COMM-02** — Create community contribution guidelines. Write `CONTRIBUTING.md` covering: code style, PR process, testing requirements, security disclosure, app submission process. **Acceptance**: Document exists and is comprehensive.
|
- [x] **COMM-02** — Create community contribution guidelines. Write `CONTRIBUTING.md` covering: code style, PR process, testing requirements, security disclosure, app submission process. **Acceptance**: Document exists and is comprehensive.
|
||||||
|
|
||||||
- [ ] **COMM-03** — Set up issue tracker and roadmap. Configure GitHub Issues with labels, templates, and project board. Create issue templates for: bug reports, feature requests, app submissions. **Acceptance**: Issue tracker ready for community use.
|
- [x] **COMM-03** — Set up issue tracker and roadmap. Configure GitHub Issues with labels, templates, and project board. Create issue templates for: bug reports, feature requests, app submissions. **Acceptance**: Issue tracker ready for community use.
|
||||||
|
|
||||||
- [ ] **COMM-04** — Publish v0.9.0 release. Final pre-1.0 release. Full ISO builds, comprehensive release notes, migration guide from 0.8. **Acceptance**: Published release, tested on 3+ hardware configs.
|
- [ ] **COMM-04** — Publish v0.9.0 release. Final pre-1.0 release. Full ISO builds, comprehensive release notes, migration guide from 0.8. **Acceptance**: Published release, tested on 3+ hardware configs.
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user