mirror of
https://github.com/getnora-io/nora.git
synced 2026-04-12 13:50:31 +00:00
Merge pull request #33 from getnora-io/security/scorecard-hardening
security: OpenSSF Scorecard hardening
This commit is contained in:
47
.github/workflows/ci.yml
vendored
47
.github/workflows/ci.yml
vendored
@@ -6,18 +6,20 @@ on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@v2
|
||||
uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2
|
||||
|
||||
- name: Check formatting
|
||||
run: cargo fmt --check
|
||||
@@ -33,18 +35,18 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write # for uploading SARIF to GitHub Security tab
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 0 # full history required for gitleaks
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@v2
|
||||
uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2
|
||||
|
||||
# ── Secrets ────────────────────────────────────────────────────────────
|
||||
- name: Gitleaks — scan for hardcoded secrets
|
||||
@@ -58,11 +60,11 @@ jobs:
|
||||
run: cargo install cargo-audit --locked
|
||||
|
||||
- name: cargo audit — RustSec advisory database
|
||||
run: cargo audit --ignore RUSTSEC-2025-0119 # known: number_prefix via indicatif
|
||||
run: cargo audit --ignore RUSTSEC-2025-0119
|
||||
|
||||
# ── Licenses, banned crates, supply chain policy ────────────────────────
|
||||
- name: cargo deny — licenses and banned crates
|
||||
uses: EmbarkStudios/cargo-deny-action@v2
|
||||
uses: EmbarkStudios/cargo-deny-action@82eb9f621fbc699dd0918f3ea06864c14cc84246 # v2
|
||||
with:
|
||||
command: check
|
||||
arguments: --all-features
|
||||
@@ -70,17 +72,17 @@ jobs:
|
||||
# ── CVE scan of source tree and Cargo.lock ──────────────────────────────
|
||||
- name: Trivy — filesystem scan (Cargo.lock + source)
|
||||
if: always()
|
||||
uses: aquasecurity/trivy-action@0.35.0
|
||||
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # 0.35.0
|
||||
with:
|
||||
scan-type: fs
|
||||
scan-ref: .
|
||||
format: sarif
|
||||
output: trivy-fs.sarif
|
||||
severity: HIGH,CRITICAL
|
||||
exit-code: 1 # block pipeline on HIGH/CRITICAL vulnerabilities
|
||||
exit-code: 1
|
||||
|
||||
- name: Upload Trivy fs results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
uses: github/codeql-action/upload-sarif@a60c4df7a135c7317c1e9ddf9b5a9b07a910dda9 # v4
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: trivy-fs.sarif
|
||||
@@ -92,18 +94,17 @@ jobs:
|
||||
needs: test
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@v2
|
||||
uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2
|
||||
|
||||
- name: Build NORA
|
||||
run: cargo build --release --package nora-registry
|
||||
|
||||
# -- Start NORA --
|
||||
- name: Start NORA
|
||||
run: |
|
||||
NORA_STORAGE_PATH=/tmp/nora-data ./target/release/nora &
|
||||
@@ -112,7 +113,6 @@ jobs:
|
||||
done
|
||||
curl -sf http://localhost:4000/health | jq .
|
||||
|
||||
# -- Docker push/pull --
|
||||
- name: Configure Docker for insecure registry
|
||||
run: |
|
||||
echo '{"insecure-registries": ["localhost:4000"]}' | sudo tee /etc/docker/daemon.json
|
||||
@@ -133,38 +133,35 @@ jobs:
|
||||
curl -sf http://localhost:4000/v2/_catalog | jq .
|
||||
curl -sf http://localhost:4000/v2/test/alpine/tags/list | jq .
|
||||
|
||||
# -- npm (read-only proxy, no publish support yet) --
|
||||
- name: npm — verify registry endpoint
|
||||
run: |
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:4000/npm/lodash)
|
||||
echo "npm endpoint returned: $STATUS"
|
||||
[ "$STATUS" != "000" ] && echo "npm endpoint OK" || (echo "npm endpoint unreachable" && exit 1)
|
||||
|
||||
# -- Maven deploy/download --
|
||||
- name: Maven — deploy and download artifact
|
||||
run: |
|
||||
echo "test-artifact-content-$(date +%s)" > /tmp/test-artifact.jar
|
||||
CHECKSUM=$(sha256sum /tmp/test-artifact.jar | cut -d' ' -f1)
|
||||
curl -sf -X PUT --data-binary @/tmp/test-artifact.jar http://localhost:4000/maven2/com/example/test-lib/1.0.0/test-lib-1.0.0.jar
|
||||
curl -sf -o /tmp/downloaded.jar http://localhost:4000/maven2/com/example/test-lib/1.0.0/test-lib-1.0.0.jar
|
||||
curl -sf -X PUT --data-binary @/tmp/test-artifact.jar \
|
||||
http://localhost:4000/maven2/com/example/test-lib/1.0.0/test-lib-1.0.0.jar
|
||||
curl -sf -o /tmp/downloaded.jar \
|
||||
http://localhost:4000/maven2/com/example/test-lib/1.0.0/test-lib-1.0.0.jar
|
||||
DOWNLOAD_CHECKSUM=$(sha256sum /tmp/downloaded.jar | cut -d' ' -f1)
|
||||
[ "$CHECKSUM" = "$DOWNLOAD_CHECKSUM" ] && echo "Maven deploy/download OK" || (echo "Checksum mismatch!" && exit 1)
|
||||
|
||||
# -- PyPI (read-only proxy, no upload support yet) --
|
||||
- name: PyPI — verify simple index
|
||||
run: |
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:4000/simple/)
|
||||
echo "PyPI simple index returned: $STATUS"
|
||||
[ "$STATUS" = "200" ] && echo "PyPI endpoint OK" || (echo "Expected 200, got $STATUS" && exit 1)
|
||||
|
||||
# -- Cargo (read-only proxy, no publish support yet) --
|
||||
- name: Cargo — verify registry API responds
|
||||
run: |
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:4000/cargo/api/v1/crates/serde)
|
||||
echo "Cargo API returned: $STATUS"
|
||||
[ "$STATUS" != "000" ] && echo "Cargo endpoint OK" || (echo "Cargo endpoint unreachable" && exit 1)
|
||||
|
||||
# -- API checks --
|
||||
- name: API — health, ready, metrics
|
||||
run: |
|
||||
curl -sf http://localhost:4000/health | jq .status
|
||||
|
||||
36
.github/workflows/release.yml
vendored
36
.github/workflows/release.yml
vendored
@@ -4,6 +4,8 @@ on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
permissions: read-all
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
NORA: localhost:5000
|
||||
@@ -18,7 +20,7 @@ jobs:
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Set up Rust
|
||||
run: |
|
||||
@@ -32,19 +34,19 @@ jobs:
|
||||
cp target/x86_64-unknown-linux-musl/release/nora ./nora
|
||||
|
||||
- name: Upload binary artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
|
||||
with:
|
||||
name: nora-binary-${{ github.run_id }}
|
||||
path: ./nora
|
||||
retention-days: 1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||
with:
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -53,7 +55,7 @@ jobs:
|
||||
# ── Alpine ───────────────────────────────────────────────────────────────
|
||||
- name: Extract metadata (alpine)
|
||||
id: meta-alpine
|
||||
uses: docker/metadata-action@v6
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
||||
with:
|
||||
images: |
|
||||
${{ env.NORA }}/${{ env.IMAGE_NAME }}
|
||||
@@ -64,7 +66,7 @@ jobs:
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push (alpine)
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -78,7 +80,7 @@ jobs:
|
||||
# ── RED OS ───────────────────────────────────────────────────────────────
|
||||
- name: Extract metadata (redos)
|
||||
id: meta-redos
|
||||
uses: docker/metadata-action@v6
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
||||
with:
|
||||
images: |
|
||||
${{ env.NORA }}/${{ env.IMAGE_NAME }}
|
||||
@@ -90,7 +92,7 @@ jobs:
|
||||
type=raw,value=redos
|
||||
|
||||
- name: Build and push (redos)
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.redos
|
||||
@@ -104,7 +106,7 @@ jobs:
|
||||
# ── Astra Linux SE ───────────────────────────────────────────────────────
|
||||
- name: Extract metadata (astra)
|
||||
id: meta-astra
|
||||
uses: docker/metadata-action@v6
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
||||
with:
|
||||
images: |
|
||||
${{ env.NORA }}/${{ env.IMAGE_NAME }}
|
||||
@@ -116,7 +118,7 @@ jobs:
|
||||
type=raw,value=astra
|
||||
|
||||
- name: Build and push (astra)
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.astra
|
||||
@@ -165,7 +167,7 @@ jobs:
|
||||
run: echo "tag=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Trivy — image scan (${{ matrix.name }})
|
||||
uses: aquasecurity/trivy-action@0.35.0
|
||||
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # 0.35.0
|
||||
with:
|
||||
scan-type: image
|
||||
image-ref: ${{ env.NORA }}/${{ env.IMAGE_NAME }}:${{ steps.ver.outputs.tag }}${{ matrix.suffix }}
|
||||
@@ -175,7 +177,7 @@ jobs:
|
||||
exit-code: 1
|
||||
|
||||
- name: Upload Trivy image results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
uses: github/codeql-action/upload-sarif@a60c4df7a135c7317c1e9ddf9b5a9b07a910dda9 # v4
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: trivy-image-${{ matrix.name }}.sarif
|
||||
@@ -190,14 +192,14 @@ jobs:
|
||||
packages: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Set version tag (strip leading v)
|
||||
id: ver
|
||||
run: echo "tag=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download binary artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: nora-binary-${{ github.run_id }}
|
||||
path: ./artifacts
|
||||
@@ -211,21 +213,21 @@ jobs:
|
||||
cat nora-linux-amd64.sha256
|
||||
|
||||
- name: Generate SBOM (SPDX)
|
||||
uses: anchore/sbom-action@v0
|
||||
uses: anchore/sbom-action@57aae528053a48a3f6235f2d9461b05fbcb7366d # v0
|
||||
with:
|
||||
image: ${{ env.NORA }}/${{ env.IMAGE_NAME }}:${{ steps.ver.outputs.tag }}
|
||||
format: spdx-json
|
||||
output-file: nora-${{ github.ref_name }}.sbom.spdx.json
|
||||
|
||||
- name: Generate SBOM (CycloneDX)
|
||||
uses: anchore/sbom-action@v0
|
||||
uses: anchore/sbom-action@57aae528053a48a3f6235f2d9461b05fbcb7366d # v0
|
||||
with:
|
||||
image: ${{ env.NORA }}/${{ env.IMAGE_NAME }}:${{ steps.ver.outputs.tag }}
|
||||
format: cyclonedx-json
|
||||
output-file: nora-${{ github.ref_name }}.sbom.cdx.json
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
|
||||
35
.github/workflows/scorecard.yml
vendored
Normal file
35
.github/workflows/scorecard.yml
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
name: OpenSSF Scorecard
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '0 6 * * 1' # every Monday at 06:00 UTC
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
name: Scorecard analysis
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run OpenSSF Scorecard
|
||||
uses: ossf/scorecard-action@99c09fe975337306107572b4fdf4db224cf8e2f2 # v2.4.3
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
publish_results: true
|
||||
|
||||
- name: Upload Scorecard results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@a60c4df7a135c7317c1e9ddf9b5a9b07a910dda9 # v4
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
category: scorecard
|
||||
111
dist/install.sh
vendored
Executable file
111
dist/install.sh
vendored
Executable file
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# NORA Artifact Registry — install script
|
||||
# Usage: curl -fsSL https://getnora.io/install.sh | bash
|
||||
|
||||
VERSION="${NORA_VERSION:-latest}"
|
||||
ARCH=$(uname -m)
|
||||
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
INSTALL_DIR="/usr/local/bin"
|
||||
CONFIG_DIR="/etc/nora"
|
||||
DATA_DIR="/var/lib/nora"
|
||||
LOG_DIR="/var/log/nora"
|
||||
|
||||
case "$ARCH" in
|
||||
x86_64|amd64) ARCH="x86_64" ;;
|
||||
aarch64|arm64) ARCH="aarch64" ;;
|
||||
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
|
||||
esac
|
||||
|
||||
echo "Installing NORA ($OS/$ARCH)..."
|
||||
|
||||
# Download binary
|
||||
if [ "$VERSION" = "latest" ]; then
|
||||
DOWNLOAD_URL="https://github.com/getnora-io/nora/releases/latest/download/nora-${OS}-${ARCH}"
|
||||
else
|
||||
DOWNLOAD_URL="https://github.com/getnora-io/nora/releases/download/${VERSION}/nora-${OS}-${ARCH}"
|
||||
fi
|
||||
|
||||
echo "Downloading from $DOWNLOAD_URL..."
|
||||
if command -v curl &>/dev/null; then
|
||||
curl -fsSL -o /tmp/nora "$DOWNLOAD_URL"
|
||||
elif command -v wget &>/dev/null; then
|
||||
wget -qO /tmp/nora "$DOWNLOAD_URL"
|
||||
else
|
||||
echo "Error: curl or wget required"; exit 1
|
||||
fi
|
||||
|
||||
chmod +x /tmp/nora
|
||||
sudo mv /tmp/nora "$INSTALL_DIR/nora"
|
||||
|
||||
# Create system user
|
||||
if ! id nora &>/dev/null; then
|
||||
sudo useradd --system --shell /usr/sbin/nologin --home-dir "$DATA_DIR" --create-home nora
|
||||
echo "Created system user: nora"
|
||||
fi
|
||||
|
||||
# Create directories
|
||||
sudo mkdir -p "$CONFIG_DIR" "$DATA_DIR" "$LOG_DIR"
|
||||
sudo chown nora:nora "$DATA_DIR" "$LOG_DIR"
|
||||
|
||||
# Install default config if not exists
|
||||
if [ ! -f "$CONFIG_DIR/nora.env" ]; then
|
||||
cat > /tmp/nora.env << 'ENVEOF'
|
||||
NORA_HOST=0.0.0.0
|
||||
NORA_PORT=4000
|
||||
NORA_STORAGE_PATH=/var/lib/nora
|
||||
ENVEOF
|
||||
sudo mv /tmp/nora.env "$CONFIG_DIR/nora.env"
|
||||
sudo chmod 600 "$CONFIG_DIR/nora.env"
|
||||
sudo chown nora:nora "$CONFIG_DIR/nora.env"
|
||||
echo "Created default config: $CONFIG_DIR/nora.env"
|
||||
fi
|
||||
|
||||
# Install systemd service
|
||||
if [ -d /etc/systemd/system ]; then
|
||||
cat > /tmp/nora.service << 'SVCEOF'
|
||||
[Unit]
|
||||
Description=NORA Artifact Registry
|
||||
Documentation=https://getnora.dev
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=nora
|
||||
Group=nora
|
||||
ExecStart=/usr/local/bin/nora serve
|
||||
WorkingDirectory=/etc/nora
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
LimitNOFILE=65535
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/nora /var/log/nora
|
||||
PrivateTmp=true
|
||||
EnvironmentFile=-/etc/nora/nora.env
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
SVCEOF
|
||||
sudo mv /tmp/nora.service /etc/systemd/system/nora.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable nora
|
||||
echo "Installed systemd service: nora"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "NORA installed successfully!"
|
||||
echo ""
|
||||
echo " Binary: $INSTALL_DIR/nora"
|
||||
echo " Config: $CONFIG_DIR/nora.env"
|
||||
echo " Data: $DATA_DIR"
|
||||
echo " Version: $(nora --version 2>/dev/null || echo 'unknown')"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Edit $CONFIG_DIR/nora.env"
|
||||
echo " 2. sudo systemctl start nora"
|
||||
echo " 3. curl http://localhost:4000/health"
|
||||
echo ""
|
||||
9031
dist/nora.cdx.json
vendored
Normal file
9031
dist/nora.cdx.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
31
dist/nora.env.example
vendored
Normal file
31
dist/nora.env.example
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
# NORA configuration — environment variables
|
||||
# Copy to /etc/nora/nora.env and adjust
|
||||
|
||||
# Server
|
||||
NORA_HOST=0.0.0.0
|
||||
NORA_PORT=4000
|
||||
# NORA_PUBLIC_URL=https://registry.example.com
|
||||
|
||||
# Storage
|
||||
NORA_STORAGE_PATH=/var/lib/nora
|
||||
# NORA_STORAGE_MODE=s3
|
||||
# NORA_STORAGE_S3_URL=http://minio:9000
|
||||
# NORA_STORAGE_BUCKET=registry
|
||||
|
||||
# Auth (optional)
|
||||
# NORA_AUTH_ENABLED=true
|
||||
# NORA_AUTH_HTPASSWD_FILE=/etc/nora/users.htpasswd
|
||||
|
||||
# Rate limiting
|
||||
# NORA_RATE_LIMIT_ENABLED=true
|
||||
|
||||
# npm proxy
|
||||
# NORA_NPM_PROXY=https://registry.npmjs.org
|
||||
# NORA_NPM_METADATA_TTL=300
|
||||
# NORA_NPM_PROXY_AUTH=user:pass
|
||||
|
||||
# PyPI proxy
|
||||
# NORA_PYPI_PROXY=https://pypi.org/simple/
|
||||
|
||||
# Docker upstreams
|
||||
# NORA_DOCKER_UPSTREAMS=https://registry-1.docker.io
|
||||
28
dist/nora.service
vendored
Normal file
28
dist/nora.service
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
[Unit]
|
||||
Description=NORA Artifact Registry
|
||||
Documentation=https://getnora.dev
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=nora
|
||||
Group=nora
|
||||
ExecStart=/usr/local/bin/nora serve
|
||||
WorkingDirectory=/etc/nora
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
LimitNOFILE=65535
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/nora /var/log/nora
|
||||
PrivateTmp=true
|
||||
|
||||
# Environment
|
||||
EnvironmentFile=-/etc/nora/nora.env
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
13
docs-ru/README.md
Normal file
13
docs-ru/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Документация NORA для Росреестра
|
||||
|
||||
## Структура
|
||||
|
||||
- `ТУ.md` — Технические условия
|
||||
- `Руководство.md` — Руководство пользователя
|
||||
- `Руководство_администратора.md` — Руководство администратора
|
||||
- `SBOM.md` — Перечень компонентов (Software Bill of Materials)
|
||||
|
||||
## Статус
|
||||
|
||||
Подготовка документации для включения в Единый реестр российских программ
|
||||
для электронных вычислительных машин и баз данных (Минцифры РФ).
|
||||
301
docs-ru/admin-guide.md
Normal file
301
docs-ru/admin-guide.md
Normal file
@@ -0,0 +1,301 @@
|
||||
# Руководство администратора NORA
|
||||
|
||||
**Версия:** 1.0
|
||||
**Дата:** 2026-03-16
|
||||
**Правообладатель:** ООО «ТАИАРС» (торговая марка АРТАИС)
|
||||
|
||||
---
|
||||
|
||||
## 1. Общие сведения
|
||||
|
||||
NORA — многопротокольный реестр артефактов, предназначенный для хранения, кэширования и распространения программных компонентов. Программа обеспечивает централизованное управление зависимостями при разработке и сборке программного обеспечения.
|
||||
|
||||
### 1.1. Назначение
|
||||
|
||||
- Хранение и раздача артефактов по протоколам Docker (OCI), npm, Maven, PyPI, Cargo, Helm OCI и Raw.
|
||||
- Проксирование и кэширование внешних репозиториев для ускорения сборок и обеспечения доступности при отсутствии соединения с сетью Интернет.
|
||||
- Контроль целостности артефактов посредством верификации SHA-256.
|
||||
- Аудит и протоколирование всех операций.
|
||||
|
||||
### 1.2. Системные требования
|
||||
|
||||
| Параметр | Минимальные | Рекомендуемые |
|
||||
|----------|-------------|---------------|
|
||||
| ОС | Linux (amd64, arm64) | Ubuntu 22.04+, RHEL 8+ |
|
||||
| ЦПУ | 1 ядро | 2+ ядра |
|
||||
| ОЗУ | 256 МБ | 1+ ГБ |
|
||||
| Диск | 1 ГБ | 50+ ГБ (зависит от объёма хранимых артефактов) |
|
||||
| Сеть | TCP-порт (по умолчанию 4000) | — |
|
||||
|
||||
### 1.3. Зависимости
|
||||
|
||||
Программа поставляется как единый статически слинкованный исполняемый файл. Внешние зависимости отсутствуют. Перечень библиотек, включённых в состав программы, приведён в файле `nora.cdx.json` (формат CycloneDX).
|
||||
|
||||
---
|
||||
|
||||
## 2. Установка
|
||||
|
||||
### 2.1. Автоматическая установка
|
||||
|
||||
```bash
|
||||
curl -fsSL https://getnora.io/install.sh | bash
|
||||
```
|
||||
|
||||
Скрипт выполняет следующие действия:
|
||||
|
||||
1. Определяет архитектуру процессора (amd64 или arm64).
|
||||
2. Загружает исполняемый файл с GitHub Releases.
|
||||
3. Создаёт системного пользователя `nora`.
|
||||
4. Создаёт каталоги: `/etc/nora/`, `/var/lib/nora/`, `/var/log/nora/`.
|
||||
5. Устанавливает файл конфигурации `/etc/nora/nora.env`.
|
||||
6. Устанавливает и активирует systemd-сервис.
|
||||
|
||||
### 2.2. Ручная установка
|
||||
|
||||
```bash
|
||||
# Загрузка
|
||||
wget https://github.com/getnora-io/nora/releases/download/v1.0.0/nora-linux-x86_64
|
||||
chmod +x nora-linux-x86_64
|
||||
mv nora-linux-x86_64 /usr/local/bin/nora
|
||||
|
||||
# Создание пользователя
|
||||
useradd --system --shell /usr/sbin/nologin --home-dir /var/lib/nora --create-home nora
|
||||
|
||||
# Создание каталогов
|
||||
mkdir -p /etc/nora /var/lib/nora /var/log/nora
|
||||
chown nora:nora /var/lib/nora /var/log/nora
|
||||
|
||||
# Установка systemd-сервиса
|
||||
cp dist/nora.service /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable nora
|
||||
```
|
||||
|
||||
### 2.3. Установка из Docker-образа
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name nora \
|
||||
-p 4000:4000 \
|
||||
-v nora-data:/data \
|
||||
ghcr.io/getnora-io/nora:latest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Конфигурация
|
||||
|
||||
Конфигурация задаётся через переменные окружения, файл `config.toml` или их комбинацию. Приоритет: переменные окружения > config.toml > значения по умолчанию.
|
||||
|
||||
### 3.1. Основные параметры
|
||||
|
||||
| Переменная | Описание | По умолчанию |
|
||||
|-----------|----------|--------------|
|
||||
| `NORA_HOST` | Адрес привязки | `127.0.0.1` |
|
||||
| `NORA_PORT` | Порт | `4000` |
|
||||
| `NORA_PUBLIC_URL` | Внешний URL (для генерации ссылок) | — |
|
||||
| `NORA_STORAGE_PATH` | Путь к каталогу хранилища | `data/storage` |
|
||||
| `NORA_STORAGE_MODE` | Тип хранилища: `local` или `s3` | `local` |
|
||||
| `NORA_BODY_LIMIT_MB` | Максимальный размер тела запроса (МБ) | `2048` |
|
||||
|
||||
### 3.2. Аутентификация
|
||||
|
||||
| Переменная | Описание | По умолчанию |
|
||||
|-----------|----------|--------------|
|
||||
| `NORA_AUTH_ENABLED` | Включить аутентификацию | `false` |
|
||||
| `NORA_AUTH_HTPASSWD_FILE` | Путь к файлу htpasswd | `users.htpasswd` |
|
||||
|
||||
Создание пользователя:
|
||||
|
||||
```bash
|
||||
htpasswd -Bc /etc/nora/users.htpasswd admin
|
||||
```
|
||||
|
||||
Роли: `admin` (полный доступ), `write` (чтение и запись), `read` (только чтение, по умолчанию).
|
||||
|
||||
### 3.3. Проксирование внешних репозиториев
|
||||
|
||||
| Переменная | Описание | По умолчанию |
|
||||
|-----------|----------|--------------|
|
||||
| `NORA_NPM_PROXY` | URL npm-реестра | `https://registry.npmjs.org` |
|
||||
| `NORA_NPM_PROXY_AUTH` | Учётные данные (`user:pass`) | — |
|
||||
| `NORA_NPM_METADATA_TTL` | TTL кэша метаданных (секунды) | `300` |
|
||||
| `NORA_PYPI_PROXY` | URL PyPI-реестра | `https://pypi.org/simple/` |
|
||||
| `NORA_MAVEN_PROXIES` | Список Maven-репозиториев через запятую | `https://repo1.maven.org/maven2` |
|
||||
| `NORA_DOCKER_UPSTREAMS` | Docker-реестры, формат: `url\|auth,url2` | `https://registry-1.docker.io` |
|
||||
|
||||
### 3.4. Ограничение частоты запросов
|
||||
|
||||
| Переменная | Описание | По умолчанию |
|
||||
|-----------|----------|--------------|
|
||||
| `NORA_RATE_LIMIT_ENABLED` | Включить ограничение | `true` |
|
||||
| `NORA_RATE_LIMIT_GENERAL_RPS` | Запросов в секунду (общие) | `100` |
|
||||
| `NORA_RATE_LIMIT_AUTH_RPS` | Запросов в секунду (аутентификация) | `1` |
|
||||
| `NORA_RATE_LIMIT_UPLOAD_RPS` | Запросов в секунду (загрузка) | `200` |
|
||||
|
||||
---
|
||||
|
||||
## 4. Управление сервисом
|
||||
|
||||
### 4.1. Запуск и остановка
|
||||
|
||||
```bash
|
||||
systemctl start nora # Запуск
|
||||
systemctl stop nora # Остановка
|
||||
systemctl restart nora # Перезапуск
|
||||
systemctl status nora # Статус
|
||||
journalctl -u nora -f # Просмотр журнала
|
||||
```
|
||||
|
||||
### 4.2. Проверка работоспособности
|
||||
|
||||
```bash
|
||||
curl http://localhost:4000/health
|
||||
```
|
||||
|
||||
Ответ при нормальной работе:
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"version": "1.0.0",
|
||||
"storage": { "backend": "local", "reachable": true },
|
||||
"registries": { "docker": "ok", "npm": "ok", "maven": "ok", "cargo": "ok", "pypi": "ok" }
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3. Метрики (Prometheus)
|
||||
|
||||
```
|
||||
GET /metrics
|
||||
```
|
||||
|
||||
Экспортируются: количество запросов, латентность, загрузки и выгрузки по протоколам.
|
||||
|
||||
---
|
||||
|
||||
## 5. Резервное копирование и восстановление
|
||||
|
||||
### 5.1. Создание резервной копии
|
||||
|
||||
```bash
|
||||
nora backup --output /backup/nora-$(date +%Y%m%d).tar.gz
|
||||
```
|
||||
|
||||
### 5.2. Восстановление
|
||||
|
||||
```bash
|
||||
nora restore --input /backup/nora-20260316.tar.gz
|
||||
```
|
||||
|
||||
### 5.3. Сборка мусора
|
||||
|
||||
```bash
|
||||
nora gc --dry-run # Просмотр (без удаления)
|
||||
nora gc # Удаление осиротевших блобов
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Предварительное кэширование (nora mirror)
|
||||
|
||||
Команда `nora mirror` позволяет заранее загрузить зависимости через прокси-кэш NORA. Это обеспечивает доступность артефактов при работе в изолированных средах без доступа к сети Интернет.
|
||||
|
||||
### 6.1. Кэширование по lockfile
|
||||
|
||||
```bash
|
||||
nora mirror npm --lockfile package-lock.json --registry http://localhost:4000
|
||||
nora mirror pip --lockfile requirements.txt --registry http://localhost:4000
|
||||
nora mirror cargo --lockfile Cargo.lock --registry http://localhost:4000
|
||||
```
|
||||
|
||||
### 6.2. Кэширование по списку пакетов
|
||||
|
||||
```bash
|
||||
nora mirror npm --packages lodash,express --registry http://localhost:4000
|
||||
nora mirror npm --packages lodash --all-versions --registry http://localhost:4000
|
||||
```
|
||||
|
||||
### 6.3. Параметры
|
||||
|
||||
| Флаг | Описание | По умолчанию |
|
||||
|------|----------|--------------|
|
||||
| `--registry` | URL экземпляра NORA | `http://localhost:4000` |
|
||||
| `--concurrency` | Количество параллельных загрузок | `8` |
|
||||
| `--all-versions` | Загрузить все версии (только с `--packages`) | — |
|
||||
|
||||
---
|
||||
|
||||
## 7. Миграция хранилища
|
||||
|
||||
Перенос артефактов между локальным хранилищем и S3:
|
||||
|
||||
```bash
|
||||
nora migrate --from local --to s3 --dry-run # Просмотр
|
||||
nora migrate --from local --to s3 # Выполнение
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Безопасность
|
||||
|
||||
### 8.1. Контроль целостности
|
||||
|
||||
При проксировании npm-пакетов NORA вычисляет и сохраняет контрольную сумму SHA-256 для каждого тарбола. При повторной выдаче из кэша контрольная сумма проверяется. В случае расхождения запрос отклоняется, а в журнал записывается предупреждение уровня SECURITY.
|
||||
|
||||
### 8.2. Защита от подмены пакетов
|
||||
|
||||
- Валидация имён файлов при публикации (защита от обхода каталогов).
|
||||
- Проверка соответствия имени пакета в URL и теле запроса.
|
||||
- Иммутабельность версий: повторная публикация той же версии запрещена.
|
||||
|
||||
### 8.3. Аудит
|
||||
|
||||
Все операции (загрузка, выгрузка, обращения к кэшу, ошибки) фиксируются в файле `audit.jsonl` в каталоге хранилища. Формат — JSON Lines, одна запись на строку.
|
||||
|
||||
### 8.4. Усиление systemd
|
||||
|
||||
Файл сервиса содержит параметры безопасности:
|
||||
|
||||
- `NoNewPrivileges=true` — запрет повышения привилегий.
|
||||
- `ProtectSystem=strict` — файловая система только для чтения, кроме указанных каталогов.
|
||||
- `ProtectHome=true` — запрет доступа к домашним каталогам.
|
||||
- `PrivateTmp=true` — изолированный каталог временных файлов.
|
||||
|
||||
---
|
||||
|
||||
## 9. Точки подключения (endpoints)
|
||||
|
||||
| Протокол | Endpoint | Описание |
|
||||
|----------|----------|----------|
|
||||
| Docker / OCI | `/v2/` | Docker Registry V2 API |
|
||||
| npm | `/npm/` | npm-реестр (прокси + публикация) |
|
||||
| Maven | `/maven2/` | Maven-репозиторий |
|
||||
| PyPI | `/simple/` | Python Simple API (PEP 503) |
|
||||
| Cargo | `/cargo/` | Cargo-реестр |
|
||||
| Helm | `/v2/` (OCI) | Helm-чарты через OCI-протокол |
|
||||
| Raw | `/raw/` | Произвольные файлы |
|
||||
| Мониторинг | `/health`, `/ready`, `/metrics` | Проверка и метрики |
|
||||
| Интерфейс | `/ui/` | Веб-интерфейс управления |
|
||||
| Документация API | `/api-docs` | OpenAPI (Swagger UI) |
|
||||
|
||||
---
|
||||
|
||||
## 10. Устранение неполадок
|
||||
|
||||
### Сервис не запускается
|
||||
|
||||
```bash
|
||||
journalctl -u nora --no-pager -n 50
|
||||
```
|
||||
|
||||
Частые причины: занят порт, недоступен каталог хранилища, ошибка в конфигурации.
|
||||
|
||||
### Прокси-кэш не работает
|
||||
|
||||
1. Проверьте доступность внешнего реестра: `curl https://registry.npmjs.org/lodash`.
|
||||
2. Убедитесь, что переменная `NORA_NPM_PROXY` задана корректно.
|
||||
3. При использовании приватного реестра укажите `NORA_NPM_PROXY_AUTH`.
|
||||
|
||||
### Ошибка целостности (Integrity check failed)
|
||||
|
||||
Контрольная сумма кэшированного тарбола не совпадает с сохранённой. Возможные причины: повреждение файловой системы или несанкционированное изменение файла. Удалите повреждённый файл из каталога хранилища — NORA загрузит его заново из внешнего реестра.
|
||||
165
docs-ru/technical-spec.md
Normal file
165
docs-ru/technical-spec.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# Технические условия
|
||||
|
||||
## Программа «NORA — Реестр артефактов»
|
||||
|
||||
**Версия документа:** 1.0
|
||||
**Дата:** 2026-03-16
|
||||
**Правообладатель:** ООО «ТАИАРС» (торговая марка АРТАИС)
|
||||
|
||||
---
|
||||
|
||||
## 1. Наименование и обозначение
|
||||
|
||||
**Полное наименование:** NORA — многопротокольный реестр артефактов.
|
||||
|
||||
**Краткое наименование:** NORA.
|
||||
|
||||
**Обозначение:** nora-registry.
|
||||
|
||||
---
|
||||
|
||||
## 2. Назначение
|
||||
|
||||
Программа предназначена для хранения, кэширования и распространения программных компонентов (артефактов), используемых при разработке, сборке и развёртывании программного обеспечения.
|
||||
|
||||
### 2.1. Область применения
|
||||
|
||||
- Организация внутренних репозиториев программных компонентов.
|
||||
- Проксирование и кэширование общедоступных репозиториев (npmjs.org, PyPI, Maven Central, Docker Hub, crates.io).
|
||||
- Обеспечение доступности зависимостей в изолированных средах без доступа к сети Интернет (air-gapped).
|
||||
- Контроль целостности и безопасности цепочки поставки программного обеспечения.
|
||||
|
||||
### 2.2. Класс программного обеспечения
|
||||
|
||||
Инструментальное программное обеспечение для разработки и DevOps.
|
||||
|
||||
Код ОКПД2: 62.01 — Разработка компьютерного программного обеспечения.
|
||||
|
||||
---
|
||||
|
||||
## 3. Функциональные характеристики
|
||||
|
||||
### 3.1. Поддерживаемые протоколы
|
||||
|
||||
| Протокол | Стандарт | Назначение |
|
||||
|----------|----------|------------|
|
||||
| Docker / OCI | OCI Distribution Spec v1.0 | Контейнерные образы, Helm-чарты |
|
||||
| npm | npm Registry API | Библиотеки JavaScript / TypeScript |
|
||||
| Maven | Maven Repository Layout | Библиотеки Java / Kotlin |
|
||||
| PyPI | PEP 503 (Simple API) | Библиотеки Python |
|
||||
| Cargo | Cargo Registry Protocol | Библиотеки Rust |
|
||||
| Raw | HTTP PUT/GET | Произвольные файлы |
|
||||
|
||||
### 3.2. Режимы работы
|
||||
|
||||
1. **Хранилище (hosted):** приём и хранение артефактов, опубликованных пользователями.
|
||||
2. **Прокси-кэш (proxy):** прозрачное проксирование запросов к внешним репозиториям с локальным кэшированием.
|
||||
3. **Комбинированный:** одновременная работа в режимах хранилища и прокси-кэша (поиск сначала в локальном хранилище, затем во внешнем репозитории).
|
||||
|
||||
### 3.3. Управление доступом
|
||||
|
||||
- Аутентификация на основе htpasswd (bcrypt).
|
||||
- Ролевая модель: `read` (чтение), `write` (чтение и запись), `admin` (полный доступ).
|
||||
- Токены доступа с ограниченным сроком действия.
|
||||
|
||||
### 3.4. Безопасность
|
||||
|
||||
- Контроль целостности кэшированных артефактов (SHA-256).
|
||||
- Защита от обхода каталогов (path traversal) при публикации.
|
||||
- Проверка соответствия имени пакета в URL и теле запроса.
|
||||
- Иммутабельность опубликованных версий.
|
||||
- Аудит всех операций в формате JSON Lines.
|
||||
- Поддержка TLS при размещении за обратным прокси-сервером.
|
||||
|
||||
### 3.5. Эксплуатация
|
||||
|
||||
- Предварительное кэширование зависимостей (`nora mirror`) по файлам фиксации версий (lockfile).
|
||||
- Сборка мусора (`nora gc`) — удаление осиротевших блобов.
|
||||
- Резервное копирование и восстановление (`nora backup`, `nora restore`).
|
||||
- Миграция между локальным хранилищем и S3-совместимым объектным хранилищем.
|
||||
- Мониторинг: эндпоинты `/health`, `/ready`, `/metrics` (формат Prometheus).
|
||||
- Веб-интерфейс для просмотра содержимого реестра.
|
||||
- Документация API в формате OpenAPI 3.0.
|
||||
|
||||
---
|
||||
|
||||
## 4. Технические характеристики
|
||||
|
||||
### 4.1. Среда исполнения
|
||||
|
||||
| Параметр | Значение |
|
||||
|----------|----------|
|
||||
| Язык реализации | Rust |
|
||||
| Формат поставки | Единый исполняемый файл (статическая линковка) |
|
||||
| Поддерживаемые ОС | Linux (ядро 4.15+) |
|
||||
| Архитектуры | x86_64 (amd64), aarch64 (arm64) |
|
||||
| Контейнеризация | Docker-образ на базе `scratch` |
|
||||
| Системная интеграция | systemd (файл сервиса в комплекте) |
|
||||
|
||||
### 4.2. Хранение данных
|
||||
|
||||
| Параметр | Значение |
|
||||
|----------|----------|
|
||||
| Локальное хранилище | Файловая система (ext4, XFS, ZFS) |
|
||||
| Объектное хранилище | S3-совместимое API (MinIO, Yandex Object Storage, Selectel S3) |
|
||||
| Структура | Иерархическая: `{protocol}/{package}/{artifact}` |
|
||||
| Аудит | Append-only JSONL файл |
|
||||
|
||||
### 4.3. Конфигурация
|
||||
|
||||
| Источник | Приоритет |
|
||||
|----------|-----------|
|
||||
| Переменные окружения (`NORA_*`) | Высший |
|
||||
| Файл `config.toml` | Средний |
|
||||
| Значения по умолчанию | Низший |
|
||||
|
||||
### 4.4. Производительность
|
||||
|
||||
| Параметр | Значение |
|
||||
|----------|----------|
|
||||
| Время запуска | < 100 мс |
|
||||
| Обслуживание из кэша | < 2 мс (метаданные), < 10 мс (артефакты до 1 МБ) |
|
||||
| Параллельная обработка | Асинхронный ввод-вывод (tokio runtime) |
|
||||
| Ограничение частоты | Настраиваемое (по умолчанию 100 запросов/сек) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Лицензирование
|
||||
|
||||
| Компонент | Лицензия |
|
||||
|-----------|----------|
|
||||
| NORA (core) | MIT License |
|
||||
| NORA Enterprise | Проприетарная |
|
||||
|
||||
Полный перечень лицензий включённых библиотек приведён в файле SBOM (формат CycloneDX).
|
||||
|
||||
---
|
||||
|
||||
## 6. Комплектность
|
||||
|
||||
| Компонент | Описание |
|
||||
|-----------|----------|
|
||||
| `nora` | Исполняемый файл |
|
||||
| `nora.service` | Файл systemd-сервиса |
|
||||
| `nora.env.example` | Шаблон конфигурации |
|
||||
| `install.sh` | Скрипт установки |
|
||||
| `nora.cdx.json` | SBOM в формате CycloneDX |
|
||||
| Руководство администратора | Настоящий комплект документации |
|
||||
| Руководство пользователя | Настоящий комплект документации |
|
||||
| Технические условия | Настоящий документ |
|
||||
|
||||
---
|
||||
|
||||
## 7. Контактная информация
|
||||
|
||||
**Правообладатель:** ООО «ТАИАРС»
|
||||
|
||||
**Торговая марка:** АРТАИС
|
||||
|
||||
**Сайт продукта:** https://getnora.io
|
||||
|
||||
**Документация:** https://getnora.dev
|
||||
|
||||
**Исходный код:** https://github.com/getnora-io/nora
|
||||
|
||||
**Поддержка:** https://t.me/getnora
|
||||
221
docs-ru/user-guide.md
Normal file
221
docs-ru/user-guide.md
Normal file
@@ -0,0 +1,221 @@
|
||||
# Руководство пользователя NORA
|
||||
|
||||
**Версия:** 1.0
|
||||
**Дата:** 2026-03-16
|
||||
**Правообладатель:** ООО «ТАИАРС» (торговая марка АРТАИС)
|
||||
|
||||
---
|
||||
|
||||
## 1. Общие сведения
|
||||
|
||||
NORA — реестр артефактов для команд разработки. Программа обеспечивает хранение и кэширование библиотек, Docker-образов и иных программных компонентов, используемых при сборке приложений.
|
||||
|
||||
Данное руководство предназначено для разработчиков, которые используют NORA в качестве источника зависимостей.
|
||||
|
||||
---
|
||||
|
||||
## 2. Настройка рабочего окружения
|
||||
|
||||
### 2.1. npm / Node.js
|
||||
|
||||
Укажите NORA в качестве реестра:
|
||||
|
||||
```bash
|
||||
npm config set registry http://nora.example.com:4000/npm
|
||||
```
|
||||
|
||||
Или создайте файл `.npmrc` в корне проекта:
|
||||
|
||||
```
|
||||
registry=http://nora.example.com:4000/npm
|
||||
```
|
||||
|
||||
После этого все команды `npm install` будут загружать пакеты через NORA. При первом обращении NORA загрузит пакет из внешнего реестра (npmjs.org) и сохранит его в кэш. Последующие обращения обслуживаются из кэша.
|
||||
|
||||
### 2.2. Docker
|
||||
|
||||
```bash
|
||||
docker login nora.example.com:4000
|
||||
docker pull nora.example.com:4000/library/nginx:latest
|
||||
docker push nora.example.com:4000/myteam/myapp:1.0.0
|
||||
```
|
||||
|
||||
### 2.3. Maven
|
||||
|
||||
Добавьте репозиторий в `settings.xml`:
|
||||
|
||||
```xml
|
||||
<mirrors>
|
||||
<mirror>
|
||||
<id>nora</id>
|
||||
<mirrorOf>central</mirrorOf>
|
||||
<url>http://nora.example.com:4000/maven2</url>
|
||||
</mirror>
|
||||
</mirrors>
|
||||
```
|
||||
|
||||
### 2.4. Python / pip
|
||||
|
||||
```bash
|
||||
pip install --index-url http://nora.example.com:4000/simple flask
|
||||
```
|
||||
|
||||
Или в `pip.conf`:
|
||||
|
||||
```ini
|
||||
[global]
|
||||
index-url = http://nora.example.com:4000/simple
|
||||
```
|
||||
|
||||
### 2.5. Cargo / Rust
|
||||
|
||||
Настройка в `~/.cargo/config.toml`:
|
||||
|
||||
```toml
|
||||
[registries.nora]
|
||||
index = "sparse+http://nora.example.com:4000/cargo/"
|
||||
|
||||
[source.crates-io]
|
||||
replace-with = "nora"
|
||||
```
|
||||
|
||||
### 2.6. Helm
|
||||
|
||||
Helm использует OCI-протокол через Docker Registry API:
|
||||
|
||||
```bash
|
||||
helm push mychart-0.1.0.tgz oci://nora.example.com:4000/helm
|
||||
helm pull oci://nora.example.com:4000/helm/mychart --version 0.1.0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Публикация пакетов
|
||||
|
||||
### 3.1. npm
|
||||
|
||||
```bash
|
||||
npm publish --registry http://nora.example.com:4000/npm
|
||||
```
|
||||
|
||||
Требования:
|
||||
- Файл `package.json` с полями `name` и `version`.
|
||||
- Каждая версия публикуется однократно. Повторная публикация той же версии запрещена.
|
||||
|
||||
### 3.2. Docker
|
||||
|
||||
```bash
|
||||
docker tag myapp:latest nora.example.com:4000/myteam/myapp:1.0.0
|
||||
docker push nora.example.com:4000/myteam/myapp:1.0.0
|
||||
```
|
||||
|
||||
### 3.3. Maven
|
||||
|
||||
```bash
|
||||
mvn deploy -DaltDeploymentRepository=nora::default::http://nora.example.com:4000/maven2
|
||||
```
|
||||
|
||||
### 3.4. Raw (произвольные файлы)
|
||||
|
||||
```bash
|
||||
# Загрузка
|
||||
curl -X PUT --data-binary @release.tar.gz http://nora.example.com:4000/raw/builds/release-1.0.tar.gz
|
||||
|
||||
# Скачивание
|
||||
curl -O http://nora.example.com:4000/raw/builds/release-1.0.tar.gz
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Работа в изолированной среде
|
||||
|
||||
Если сборочный сервер не имеет доступа к сети Интернет, используйте предварительное кэширование.
|
||||
|
||||
### 4.1. Кэширование зависимостей проекта
|
||||
|
||||
На машине с доступом к Интернету и NORA выполните:
|
||||
|
||||
```bash
|
||||
nora mirror npm --lockfile package-lock.json --registry http://nora.example.com:4000
|
||||
```
|
||||
|
||||
После этого все зависимости из lockfile будут доступны через NORA, даже если связь с внешними реестрами отсутствует.
|
||||
|
||||
### 4.2. Кэширование всех версий пакета
|
||||
|
||||
```bash
|
||||
nora mirror npm --packages lodash,express --all-versions --registry http://nora.example.com:4000
|
||||
```
|
||||
|
||||
Эта команда загрузит все опубликованные версии указанных пакетов.
|
||||
|
||||
---
|
||||
|
||||
## 5. Веб-интерфейс
|
||||
|
||||
NORA предоставляет веб-интерфейс для просмотра содержимого реестра:
|
||||
|
||||
```
|
||||
http://nora.example.com:4000/ui/
|
||||
```
|
||||
|
||||
Доступные функции:
|
||||
- Просмотр списка артефактов по протоколам.
|
||||
- Количество версий и размер каждого пакета.
|
||||
- Журнал последних операций.
|
||||
- Метрики загрузок.
|
||||
|
||||
---
|
||||
|
||||
## 6. Документация API
|
||||
|
||||
Интерактивная документация API доступна по адресу:
|
||||
|
||||
```
|
||||
http://nora.example.com:4000/api-docs
|
||||
```
|
||||
|
||||
Формат: OpenAPI 3.0 (Swagger UI).
|
||||
|
||||
---
|
||||
|
||||
## 7. Аутентификация
|
||||
|
||||
Если администратор включил аутентификацию, для операций записи требуется токен.
|
||||
|
||||
### 7.1. Получение токена
|
||||
|
||||
```bash
|
||||
curl -u admin:password http://nora.example.com:4000/auth/token
|
||||
```
|
||||
|
||||
### 7.2. Использование токена
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm config set //nora.example.com:4000/npm/:_authToken TOKEN
|
||||
|
||||
# Docker
|
||||
docker login nora.example.com:4000
|
||||
|
||||
# curl
|
||||
curl -H "Authorization: Bearer TOKEN" http://nora.example.com:4000/npm/my-package
|
||||
```
|
||||
|
||||
Операции чтения по умолчанию не требуют аутентификации (роль `read` назначается автоматически).
|
||||
|
||||
---
|
||||
|
||||
## 8. Часто задаваемые вопросы
|
||||
|
||||
**В: Что произойдёт, если внешний реестр (npmjs.org) станет недоступен?**
|
||||
О: NORA продолжит обслуживать запросы из кэша. Пакеты, которые ранее не запрашивались, будут недоступны до восстановления связи. Для предотвращения такой ситуации используйте `nora mirror`.
|
||||
|
||||
**В: Можно ли публиковать приватные пакеты?**
|
||||
О: Да. Пакеты, опубликованные через `npm publish` или `docker push`, сохраняются в локальном хранилище NORA и доступны всем пользователям данного экземпляра.
|
||||
|
||||
**В: Как обновить кэш метаданных?**
|
||||
О: Кэш метаданных npm обновляется автоматически по истечении TTL (по умолчанию 5 минут). Для немедленного обновления удалите файл `metadata.json` из каталога хранилища.
|
||||
|
||||
**В: Поддерживаются ли scoped-пакеты npm (@scope/package)?**
|
||||
О: Да, полностью. Например: `npm install @babel/core --registry http://nora.example.com:4000/npm`.
|
||||
5902
nora-cli/nora-cli.cdx.json
Normal file
5902
nora-cli/nora-cli.cdx.json
Normal file
File diff suppressed because it is too large
Load Diff
9031
nora-registry/nora-registry.cdx.json
Normal file
9031
nora-registry/nora-registry.cdx.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ mod gc;
|
||||
mod health;
|
||||
mod metrics;
|
||||
mod migrate;
|
||||
mod mirror;
|
||||
mod openapi;
|
||||
mod rate_limit;
|
||||
mod registry;
|
||||
@@ -82,6 +83,17 @@ enum Commands {
|
||||
#[arg(long, default_value = "false")]
|
||||
dry_run: bool,
|
||||
},
|
||||
/// Pre-fetch dependencies through NORA proxy cache
|
||||
Mirror {
|
||||
#[command(subcommand)]
|
||||
format: mirror::MirrorFormat,
|
||||
/// NORA registry URL
|
||||
#[arg(long, default_value = "http://localhost:4000", global = true)]
|
||||
registry: String,
|
||||
/// Max concurrent downloads
|
||||
#[arg(long, default_value = "8", global = true)]
|
||||
concurrency: usize,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct AppState {
|
||||
@@ -164,6 +176,16 @@ async fn main() {
|
||||
println!("\nRun without --dry-run to delete orphaned blobs.");
|
||||
}
|
||||
}
|
||||
Some(Commands::Mirror {
|
||||
format,
|
||||
registry,
|
||||
concurrency,
|
||||
}) => {
|
||||
if let Err(e) = mirror::run_mirror(format, ®istry, concurrency).await {
|
||||
error!("Mirror failed: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
Some(Commands::Migrate { from, to, dry_run }) => {
|
||||
let source = match from.as_str() {
|
||||
"local" => Storage::new_local(&config.storage.path),
|
||||
|
||||
325
nora-registry/src/mirror/mod.rs
Normal file
325
nora-registry/src/mirror/mod.rs
Normal file
@@ -0,0 +1,325 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//! `nora mirror` — pre-fetch dependencies through NORA proxy cache.
|
||||
|
||||
mod npm;
|
||||
|
||||
use clap::Subcommand;
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum MirrorFormat {
|
||||
/// Mirror npm packages
|
||||
Npm {
|
||||
/// Path to package-lock.json (v1/v2/v3)
|
||||
#[arg(long, conflicts_with = "packages")]
|
||||
lockfile: Option<PathBuf>,
|
||||
/// Comma-separated package names
|
||||
#[arg(long, conflicts_with = "lockfile", value_delimiter = ',')]
|
||||
packages: Option<Vec<String>>,
|
||||
/// Fetch all versions (only with --packages)
|
||||
#[arg(long)]
|
||||
all_versions: bool,
|
||||
},
|
||||
/// Mirror Python packages
|
||||
Pip {
|
||||
/// Path to requirements.txt
|
||||
#[arg(long)]
|
||||
lockfile: PathBuf,
|
||||
},
|
||||
/// Mirror Cargo crates
|
||||
Cargo {
|
||||
/// Path to Cargo.lock
|
||||
#[arg(long)]
|
||||
lockfile: PathBuf,
|
||||
},
|
||||
/// Mirror Maven artifacts
|
||||
Maven {
|
||||
/// Path to dependency list (mvn dependency:list output)
|
||||
#[arg(long)]
|
||||
lockfile: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
|
||||
pub struct MirrorTarget {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
pub struct MirrorResult {
|
||||
pub total: usize,
|
||||
pub fetched: usize,
|
||||
pub failed: usize,
|
||||
pub bytes: u64,
|
||||
}
|
||||
|
||||
pub fn create_progress_bar(total: u64) -> ProgressBar {
|
||||
let pb = ProgressBar::new(total);
|
||||
pb.set_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template(
|
||||
"{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta}) {msg}",
|
||||
)
|
||||
.unwrap()
|
||||
.progress_chars("=>-"),
|
||||
);
|
||||
pb
|
||||
}
|
||||
|
||||
pub async fn run_mirror(
|
||||
format: MirrorFormat,
|
||||
registry: &str,
|
||||
concurrency: usize,
|
||||
) -> Result<(), String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||
|
||||
// Health check
|
||||
let health_url = format!("{}/health", registry.trim_end_matches('/'));
|
||||
match client.get(&health_url).send().await {
|
||||
Ok(r) if r.status().is_success() => {}
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"Cannot connect to NORA at {}. Is `nora serve` running?",
|
||||
registry
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
|
||||
let result = match format {
|
||||
MirrorFormat::Npm {
|
||||
lockfile,
|
||||
packages,
|
||||
all_versions,
|
||||
} => {
|
||||
npm::run_npm_mirror(
|
||||
&client,
|
||||
registry,
|
||||
lockfile,
|
||||
packages,
|
||||
all_versions,
|
||||
concurrency,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
MirrorFormat::Pip { lockfile } => {
|
||||
mirror_lockfile(&client, registry, "pip", &lockfile).await?
|
||||
}
|
||||
MirrorFormat::Cargo { lockfile } => {
|
||||
mirror_lockfile(&client, registry, "cargo", &lockfile).await?
|
||||
}
|
||||
MirrorFormat::Maven { lockfile } => {
|
||||
mirror_lockfile(&client, registry, "maven", &lockfile).await?
|
||||
}
|
||||
};
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
println!("\nMirror complete:");
|
||||
println!(" Total: {}", result.total);
|
||||
println!(" Fetched: {}", result.fetched);
|
||||
println!(" Failed: {}", result.failed);
|
||||
println!(" Size: {:.1} MB", result.bytes as f64 / 1_048_576.0);
|
||||
println!(" Time: {:.1}s", elapsed.as_secs_f64());
|
||||
|
||||
if result.failed > 0 {
|
||||
Err(format!("{} packages failed to mirror", result.failed))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn mirror_lockfile(
|
||||
client: &reqwest::Client,
|
||||
registry: &str,
|
||||
format: &str,
|
||||
lockfile: &PathBuf,
|
||||
) -> Result<MirrorResult, String> {
|
||||
let content = std::fs::read_to_string(lockfile)
|
||||
.map_err(|e| format!("Cannot read {}: {}", lockfile.display(), e))?;
|
||||
|
||||
let targets = match format {
|
||||
"pip" => parse_requirements_txt(&content),
|
||||
"cargo" => parse_cargo_lock(&content)?,
|
||||
"maven" => parse_maven_deps(&content),
|
||||
_ => vec![],
|
||||
};
|
||||
|
||||
if targets.is_empty() {
|
||||
println!("No packages found in {}", lockfile.display());
|
||||
return Ok(MirrorResult {
|
||||
total: 0,
|
||||
fetched: 0,
|
||||
failed: 0,
|
||||
bytes: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let pb = create_progress_bar(targets.len() as u64);
|
||||
let base = registry.trim_end_matches('/');
|
||||
let mut fetched = 0;
|
||||
let mut failed = 0;
|
||||
let mut bytes = 0u64;
|
||||
|
||||
for target in &targets {
|
||||
let url = match format {
|
||||
"pip" => format!("{}/simple/{}/", base, target.name),
|
||||
"cargo" => format!(
|
||||
"{}/cargo/api/v1/crates/{}/{}/download",
|
||||
base, target.name, target.version
|
||||
),
|
||||
"maven" => {
|
||||
let parts: Vec<&str> = target.name.split(':').collect();
|
||||
if parts.len() == 2 {
|
||||
let group_path = parts[0].replace('.', "/");
|
||||
format!(
|
||||
"{}/maven2/{}/{}/{}/{}-{}.jar",
|
||||
base, group_path, parts[1], target.version, parts[1], target.version
|
||||
)
|
||||
} else {
|
||||
pb.inc(1);
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
match client.get(&url).send().await {
|
||||
Ok(r) if r.status().is_success() => {
|
||||
if let Ok(body) = r.bytes().await {
|
||||
bytes += body.len() as u64;
|
||||
}
|
||||
fetched += 1;
|
||||
}
|
||||
_ => failed += 1,
|
||||
}
|
||||
|
||||
pb.set_message(format!("{}@{}", target.name, target.version));
|
||||
pb.inc(1);
|
||||
}
|
||||
|
||||
pb.finish_with_message("done");
|
||||
Ok(MirrorResult {
|
||||
total: targets.len(),
|
||||
fetched,
|
||||
failed,
|
||||
bytes,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_requirements_txt(content: &str) -> Vec<MirrorTarget> {
|
||||
content
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty() && !l.starts_with('#') && !l.starts_with('-'))
|
||||
.filter_map(|line| {
|
||||
let line = line.split('#').next().unwrap().trim();
|
||||
if let Some((name, version)) = line.split_once("==") {
|
||||
Some(MirrorTarget {
|
||||
name: name.trim().to_string(),
|
||||
version: version.trim().to_string(),
|
||||
})
|
||||
} else {
|
||||
let name = line.split(['>', '<', '=', '!', '~', ';']).next()?.trim();
|
||||
if name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(MirrorTarget {
|
||||
name: name.to_string(),
|
||||
version: "latest".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_cargo_lock(content: &str) -> Result<Vec<MirrorTarget>, String> {
|
||||
let lock: toml::Value =
|
||||
toml::from_str(content).map_err(|e| format!("Invalid Cargo.lock: {}", e))?;
|
||||
let packages = lock
|
||||
.get("package")
|
||||
.and_then(|p| p.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
Ok(packages
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
p.get("source")
|
||||
.and_then(|s| s.as_str())
|
||||
.map(|s| s.starts_with("registry+"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.filter_map(|p| {
|
||||
let name = p.get("name")?.as_str()?.to_string();
|
||||
let version = p.get("version")?.as_str()?.to_string();
|
||||
Some(MirrorTarget { name, version })
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn parse_maven_deps(content: &str) -> Vec<MirrorTarget> {
|
||||
content
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let line = line.trim().trim_start_matches("[INFO]").trim();
|
||||
let parts: Vec<&str> = line.split(':').collect();
|
||||
if parts.len() >= 4 {
|
||||
let name = format!("{}:{}", parts[0], parts[1]);
|
||||
let version = parts[3].to_string();
|
||||
Some(MirrorTarget { name, version })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_requirements_txt() {
|
||||
let content = "flask==2.3.0\nrequests>=2.28.0\n# comment\nnumpy==1.24.3\n";
|
||||
let targets = parse_requirements_txt(content);
|
||||
assert_eq!(targets.len(), 3);
|
||||
assert_eq!(targets[0].name, "flask");
|
||||
assert_eq!(targets[0].version, "2.3.0");
|
||||
assert_eq!(targets[1].name, "requests");
|
||||
assert_eq!(targets[1].version, "latest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_cargo_lock() {
|
||||
let content = "\
|
||||
[[package]]
|
||||
name = \"serde\"
|
||||
version = \"1.0.197\"
|
||||
source = \"registry+https://github.com/rust-lang/crates.io-index\"
|
||||
|
||||
[[package]]
|
||||
name = \"my-local-crate\"
|
||||
version = \"0.1.0\"
|
||||
";
|
||||
let targets = parse_cargo_lock(content).unwrap();
|
||||
assert_eq!(targets.len(), 1);
|
||||
assert_eq!(targets[0].name, "serde");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_maven_deps() {
|
||||
let content = "[INFO] org.apache.commons:commons-lang3:jar:3.12.0:compile\n";
|
||||
let targets = parse_maven_deps(content);
|
||||
assert_eq!(targets.len(), 1);
|
||||
assert_eq!(targets[0].name, "org.apache.commons:commons-lang3");
|
||||
assert_eq!(targets[0].version, "3.12.0");
|
||||
}
|
||||
}
|
||||
323
nora-registry/src/mirror/npm.rs
Normal file
323
nora-registry/src/mirror/npm.rs
Normal file
@@ -0,0 +1,323 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//! npm lockfile parser + mirror logic.
|
||||
|
||||
use super::{create_progress_bar, MirrorResult, MirrorTarget};
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
/// Entry point for npm mirroring
|
||||
pub async fn run_npm_mirror(
|
||||
client: &reqwest::Client,
|
||||
registry: &str,
|
||||
lockfile: Option<PathBuf>,
|
||||
packages: Option<Vec<String>>,
|
||||
all_versions: bool,
|
||||
concurrency: usize,
|
||||
) -> Result<MirrorResult, String> {
|
||||
let targets = if let Some(path) = lockfile {
|
||||
let content = std::fs::read_to_string(&path)
|
||||
.map_err(|e| format!("Cannot read {}: {}", path.display(), e))?;
|
||||
parse_npm_lockfile(&content)?
|
||||
} else if let Some(names) = packages {
|
||||
resolve_npm_packages(client, registry, &names, all_versions).await?
|
||||
} else {
|
||||
return Err("Specify --lockfile or --packages".to_string());
|
||||
};
|
||||
|
||||
if targets.is_empty() {
|
||||
println!("No npm packages to mirror");
|
||||
return Ok(MirrorResult {
|
||||
total: 0,
|
||||
fetched: 0,
|
||||
failed: 0,
|
||||
bytes: 0,
|
||||
});
|
||||
}
|
||||
|
||||
println!(
|
||||
"Mirroring {} npm packages via {}...",
|
||||
targets.len(),
|
||||
registry
|
||||
);
|
||||
mirror_npm_packages(client, registry, &targets, concurrency).await
|
||||
}
|
||||
|
||||
/// Parse package-lock.json (v1, v2, v3)
|
||||
fn parse_npm_lockfile(content: &str) -> Result<Vec<MirrorTarget>, String> {
|
||||
let json: serde_json::Value =
|
||||
serde_json::from_str(content).map_err(|e| format!("Invalid JSON: {}", e))?;
|
||||
|
||||
let version = json
|
||||
.get("lockfileVersion")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(1);
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
let mut targets = Vec::new();
|
||||
|
||||
if version >= 2 {
|
||||
// v2/v3: use "packages" object
|
||||
if let Some(packages) = json.get("packages").and_then(|p| p.as_object()) {
|
||||
for (key, pkg) in packages {
|
||||
if key.is_empty() {
|
||||
continue; // root package
|
||||
}
|
||||
if let Some(name) = extract_package_name(key) {
|
||||
if let Some(ver) = pkg.get("version").and_then(|v| v.as_str()) {
|
||||
let pair = (name.to_string(), ver.to_string());
|
||||
if seen.insert(pair.clone()) {
|
||||
targets.push(MirrorTarget {
|
||||
name: pair.0,
|
||||
version: pair.1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if version == 1 || targets.is_empty() {
|
||||
// v1 fallback: recursive "dependencies"
|
||||
if let Some(deps) = json.get("dependencies").and_then(|d| d.as_object()) {
|
||||
parse_v1_deps(deps, &mut targets, &mut seen);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(targets)
|
||||
}
|
||||
|
||||
/// Extract package name from lockfile key like "node_modules/@babel/core"
|
||||
fn extract_package_name(key: &str) -> Option<&str> {
|
||||
// Handle nested: "node_modules/foo/node_modules/@scope/bar" → "@scope/bar"
|
||||
let last_nm = key.rfind("node_modules/")?;
|
||||
let after = &key[last_nm + "node_modules/".len()..];
|
||||
let name = after.trim_end_matches('/');
|
||||
if name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively parse v1 lockfile "dependencies"
|
||||
fn parse_v1_deps(
|
||||
deps: &serde_json::Map<String, serde_json::Value>,
|
||||
targets: &mut Vec<MirrorTarget>,
|
||||
seen: &mut HashSet<(String, String)>,
|
||||
) {
|
||||
for (name, pkg) in deps {
|
||||
if let Some(ver) = pkg.get("version").and_then(|v| v.as_str()) {
|
||||
let pair = (name.clone(), ver.to_string());
|
||||
if seen.insert(pair.clone()) {
|
||||
targets.push(MirrorTarget {
|
||||
name: pair.0,
|
||||
version: pair.1,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Recurse into nested dependencies
|
||||
if let Some(nested) = pkg.get("dependencies").and_then(|d| d.as_object()) {
|
||||
parse_v1_deps(nested, targets, seen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve --packages list by fetching metadata from NORA
|
||||
async fn resolve_npm_packages(
|
||||
client: &reqwest::Client,
|
||||
registry: &str,
|
||||
names: &[String],
|
||||
all_versions: bool,
|
||||
) -> Result<Vec<MirrorTarget>, String> {
|
||||
let base = registry.trim_end_matches('/');
|
||||
let mut targets = Vec::new();
|
||||
|
||||
for name in names {
|
||||
let url = format!("{}/npm/{}", base, name);
|
||||
let resp = client.get(&url).send().await.map_err(|e| e.to_string())?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
eprintln!("Warning: {} not found (HTTP {})", name, resp.status());
|
||||
continue;
|
||||
}
|
||||
|
||||
let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
|
||||
|
||||
if all_versions {
|
||||
if let Some(versions) = json.get("versions").and_then(|v| v.as_object()) {
|
||||
for ver in versions.keys() {
|
||||
targets.push(MirrorTarget {
|
||||
name: name.clone(),
|
||||
version: ver.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Just latest
|
||||
let latest = json
|
||||
.get("dist-tags")
|
||||
.and_then(|d| d.get("latest"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("latest");
|
||||
targets.push(MirrorTarget {
|
||||
name: name.clone(),
|
||||
version: latest.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(targets)
|
||||
}
|
||||
|
||||
/// Fetch packages through NORA (triggers proxy cache)
|
||||
async fn mirror_npm_packages(
|
||||
client: &reqwest::Client,
|
||||
registry: &str,
|
||||
targets: &[MirrorTarget],
|
||||
concurrency: usize,
|
||||
) -> Result<MirrorResult, String> {
|
||||
let base = registry.trim_end_matches('/');
|
||||
let pb = create_progress_bar(targets.len() as u64);
|
||||
let sem = std::sync::Arc::new(Semaphore::new(concurrency));
|
||||
|
||||
// Deduplicate metadata fetches (one per package name)
|
||||
let unique_names: HashSet<&str> = targets.iter().map(|t| t.name.as_str()).collect();
|
||||
pb.set_message("fetching metadata...");
|
||||
for name in &unique_names {
|
||||
let url = format!("{}/npm/{}", base, name);
|
||||
let _ = client.get(&url).send().await; // trigger metadata cache
|
||||
}
|
||||
|
||||
// Fetch tarballs concurrently
|
||||
let fetched = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let failed = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let bytes = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
|
||||
let mut handles = Vec::new();
|
||||
|
||||
for target in targets {
|
||||
let permit = sem.clone().acquire_owned().await.unwrap();
|
||||
let client = client.clone();
|
||||
let pb = pb.clone();
|
||||
let fetched = fetched.clone();
|
||||
let failed = failed.clone();
|
||||
let bytes = bytes.clone();
|
||||
|
||||
let short_name = target.name.split('/').next_back().unwrap_or(&target.name);
|
||||
let tarball_url = format!(
|
||||
"{}/npm/{}/-/{}-{}.tgz",
|
||||
base, target.name, short_name, target.version
|
||||
);
|
||||
let label = format!("{}@{}", target.name, target.version);
|
||||
|
||||
handles.push(tokio::spawn(async move {
|
||||
let _permit = permit;
|
||||
match client.get(&tarball_url).send().await {
|
||||
Ok(r) if r.status().is_success() => {
|
||||
if let Ok(body) = r.bytes().await {
|
||||
bytes.fetch_add(body.len() as u64, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
fetched.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
_ => {
|
||||
failed.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pb.set_message(label);
|
||||
pb.inc(1);
|
||||
}));
|
||||
}
|
||||
|
||||
for h in handles {
|
||||
let _ = h.await;
|
||||
}
|
||||
|
||||
pb.finish_with_message("done");
|
||||
|
||||
Ok(MirrorResult {
|
||||
total: targets.len(),
|
||||
fetched: fetched.load(std::sync::atomic::Ordering::Relaxed),
|
||||
failed: failed.load(std::sync::atomic::Ordering::Relaxed),
|
||||
bytes: bytes.load(std::sync::atomic::Ordering::Relaxed),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_package_name() {
|
||||
assert_eq!(extract_package_name("node_modules/lodash"), Some("lodash"));
|
||||
assert_eq!(
|
||||
extract_package_name("node_modules/@babel/core"),
|
||||
Some("@babel/core")
|
||||
);
|
||||
assert_eq!(
|
||||
extract_package_name("node_modules/foo/node_modules/bar"),
|
||||
Some("bar")
|
||||
);
|
||||
assert_eq!(
|
||||
extract_package_name("node_modules/foo/node_modules/@types/node"),
|
||||
Some("@types/node")
|
||||
);
|
||||
assert_eq!(extract_package_name(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_lockfile_v3() {
|
||||
let content = r#"{
|
||||
"lockfileVersion": 3,
|
||||
"packages": {
|
||||
"": { "name": "test" },
|
||||
"node_modules/lodash": { "version": "4.17.21" },
|
||||
"node_modules/@babel/core": { "version": "7.26.0" },
|
||||
"node_modules/@babel/core/node_modules/semver": { "version": "6.3.1" }
|
||||
}
|
||||
}"#;
|
||||
let targets = parse_npm_lockfile(content).unwrap();
|
||||
assert_eq!(targets.len(), 3);
|
||||
let names: HashSet<&str> = targets.iter().map(|t| t.name.as_str()).collect();
|
||||
assert!(names.contains("lodash"));
|
||||
assert!(names.contains("@babel/core"));
|
||||
assert!(names.contains("semver"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_lockfile_v1() {
|
||||
let content = r#"{
|
||||
"lockfileVersion": 1,
|
||||
"dependencies": {
|
||||
"express": {
|
||||
"version": "4.18.2",
|
||||
"dependencies": {
|
||||
"accepts": { "version": "1.3.8" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
let targets = parse_npm_lockfile(content).unwrap();
|
||||
assert_eq!(targets.len(), 2);
|
||||
assert_eq!(targets[0].name, "express");
|
||||
assert_eq!(targets[1].name, "accepts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deduplication() {
|
||||
let content = r#"{
|
||||
"lockfileVersion": 3,
|
||||
"packages": {
|
||||
"": {},
|
||||
"node_modules/debug": { "version": "4.3.4" },
|
||||
"node_modules/express/node_modules/debug": { "version": "4.3.4" }
|
||||
}
|
||||
}"#;
|
||||
let targets = parse_npm_lockfile(content).unwrap();
|
||||
assert_eq!(targets.len(), 1); // deduplicated
|
||||
assert_eq!(targets[0].name, "debug");
|
||||
}
|
||||
}
|
||||
4050
nora-storage/nora-storage.cdx.json
Normal file
4050
nora-storage/nora-storage.cdx.json
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user