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.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::Timelike;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
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";
|
||||
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)]
|
||||
pub struct UpdateManifest {
|
||||
pub version: String,
|
||||
@ -28,6 +33,20 @@ pub struct ComponentUpdate {
|
||||
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)]
|
||||
pub struct UpdateState {
|
||||
pub current_version: String,
|
||||
@ -35,6 +54,8 @@ pub struct UpdateState {
|
||||
pub available_update: Option<UpdateManifest>,
|
||||
pub update_in_progress: bool,
|
||||
pub rollback_available: bool,
|
||||
#[serde(default)]
|
||||
pub schedule: UpdateSchedule,
|
||||
}
|
||||
|
||||
impl Default for UpdateState {
|
||||
@ -45,6 +66,7 @@ impl Default for UpdateState {
|
||||
available_update: None,
|
||||
update_in_progress: false,
|
||||
rollback_available: false,
|
||||
schedule: UpdateSchedule::DailyCheck,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -80,7 +102,8 @@ pub async fn check_for_updates(data_dir: &Path) -> Result<UpdateState> {
|
||||
.build()
|
||||
.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() => {
|
||||
let manifest: UpdateManifest = resp
|
||||
.json()
|
||||
@ -123,3 +146,450 @@ pub async fn dismiss_update(data_dir: &Path) -> Result<()> {
|
||||
state.available_update = None;
|
||||
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)
|
||||
|
||||
- [ ] **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.
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user