diff --git a/.almide-checksums.sha256 b/.almide-checksums.sha256 new file mode 100644 index 0000000..9de0dd3 --- /dev/null +++ b/.almide-checksums.sha256 @@ -0,0 +1,4 @@ +4c5ba89fdeabea7a07679795a714a5f840e1b4024205b1012554c580bb4820fe almide-linux-aarch64.tar.gz +9a723b256e85b48e36c1a1d6faab9a2d4154e71ea8c0f41b8d9534e2aea6ae50 almide-linux-x86_64.tar.gz +0432fe433bc56e3ce14dab7b5a2c6e4d5de9bb5c58cdb191da77b17181199921 almide-macos-aarch64.tar.gz +5cb07a8115410705249b2b5f46f73561aaa28a4356817e2b8815edbf7c9cace9 almide-macos-x86_64.tar.gz diff --git a/.almide-release b/.almide-release new file mode 100644 index 0000000..a2a9338 --- /dev/null +++ b/.almide-release @@ -0,0 +1 @@ +v0.66.0 diff --git a/.almide-revision b/.almide-revision deleted file mode 100644 index 1ef46ef..0000000 --- a/.almide-revision +++ /dev/null @@ -1 +0,0 @@ -852b028a5706801fd008a753bcbdf8b3ea93156f diff --git a/.dockerignore b/.dockerignore index 069a315..f28db08 100644 --- a/.dockerignore +++ b/.dockerignore @@ -15,3 +15,6 @@ node_modules **/.env* **/.dev.vars* **/*.private.json +**/.terraform +**/*.tfstate* +**/*.tfvars diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9f24f45..6afac24 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,7 +17,7 @@ jobs: cache: npm - name: Install pinned Rust run: rustup toolchain install 1.99.0 --profile minimal - - name: Build pinned Almide + - name: Install pinned Almide release run: ./scripts/install-almide.sh - run: npm ci - run: npm run build diff --git a/.gitignore b/.gitignore index 742a1c8..6dedf75 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,11 @@ node_modules/ local.settings.json .azure/ *.private.json +.terraform/ +*.tfstate +*.tfstate.* +*.tfvars +!*.tfvars.example +.terraform.tfstate.lock.info +.terraform.lock.hcl +crash.log diff --git a/Dockerfile b/Dockerfile index d54a620..4db85e3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,15 @@ -# Exact Rust version and Almide commit; base-image digests are not locked yet. -FROM rust:1.99.0-bookworm AS build +# Exact Rust version and Almide release (checksum-verified); base-image digests are +# not locked yet. The release binary needs glibc 2.39+, hence Debian trixie. Native +# `almide build` emits Rust and compiles it with cargo, so the build stage keeps Rust. +FROM rust:1.99.0-trixie AS build WORKDIR /work -COPY .almide-revision rust-toolchain.toml ./ +COPY .almide-release .almide-checksums.sha256 rust-toolchain.toml ./ COPY scripts/install-almide.sh scripts/install-almide.sh RUN ./scripts/install-almide.sh COPY src/ src/ RUN mkdir build && .tools/bin/almide build src/native.almd -o build/server -FROM debian:bookworm-slim AS runtime +FROM debian:trixie-slim AS runtime WORKDIR /app COPY --from=build /work/build/server ./server COPY LICENSE /app/LICENSE diff --git a/README.md b/README.md index 076b0bc..fdb61b1 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,17 @@ every provider. ## Verified scope -Checked on **2026-10-04** against Almide commit -[`852b028a5706801fd008a753bcbdf8b3ea93156f`](https://github.com/almide/almide/commit/852b028a5706801fd008a753bcbdf8b3ea93156f) -(compiler reports `0.66.0 (dev)`). The exact source revision is in -[.almide-revision](.almide-revision). A released `0.66.0` binary is not assumed to -be the same compiler. +The compiler is the official Almide +[`v0.66.0`](https://github.com/almide/almide/releases/tag/v0.66.0) release binary +(`almide 0.66.0 (release, 819bbc74f)`), pinned by [.almide-release](.almide-release) +and the archive checksums in [.almide-checksums.sha256](.almide-checksums.sha256). +Results checked on **2026-10-04**. Most rows below were first established with a +source build of the earlier pin, commit +[`852b028`](https://github.com/almide/almide/commit/852b028a5706801fd008a753bcbdf8b3ea93156f) +(`0.66.0 (dev)`); after the switch, every local suite, Docker/Compose on macOS arm64 +and on the ConoHa VPS, and GitHub Actions were rerun with the release binary. +Cloudflare and Google deployments have not been repeated with it. +See [verification notes](docs/verification.md#compiler-pin-moved-to-the-v0660-release). | Route | Build / bundle | Shared API contract | Live cloud | | --- | --- | --- | --- | @@ -24,27 +30,29 @@ be the same compiler. | macOS arm64 native HTTP | Passed locally with the `--release` installer | 18 cases passed over HTTP | Not applicable | | Wasm + generated JS, Node 24.19.0 | Passed locally | Same 18 cases + 1,000 repeated string calls | Not applicable | | Workers, Wrangler 4.147.0 / local workerd | Dry-run bundle passed; real `wrangler deploy` uploaded | Same 18 cases passed over HTTP locally and on the workers.dev edge | Deployed temporarily to workers.dev, verified, deleted | -| ConoHa Docker / Compose | Image built and Compose started on Docker 29.6.1, linux/arm64 only | Same 18 cases passed against the container | Not deployed | +| ConoHa Docker / Compose | Image built and Compose started on macOS arm64 (Docker 29.6.1) and on a ConoHa VPS, x86_64 (Docker 29.2.1, Compose v5.0.2) | Same 18 cases passed against the container on both | VPS created with [Terraform](providers/conoha/terraform/), verified, destroyed | | Google Cloud Run container | linux/amd64 image built (QEMU on Apple silicon), pushed by digest; `replace --dry-run` and deploy passed | Same 18 cases passed from a VM inside the VPC with an ID token | Deployed temporarily with internal ingress + IAM, verified, deleted | | Azure Container Apps / ECS Fargate | Provider templates and local safety/shape checks passed; image not built for them | Shared native contract passed; provider runtime not run | Not deployed | | AWS Lambda, Node 24 | Source package generated; adapter/config tests passed | 18 common cases, base64/event/HEAD/warm-call checks; staged package executed locally | Not deployed | | Google Cloud Run functions, Node 24 | Source package, local Functions Framework, and managed source build via `deploy.sh --execute` | 18 direct adapter cases; in the cloud, 18 octet-stream cases passed and JSON showed the same 3 framework rejections as locally | Deployed temporarily with internal ingress + IAM, verified, deleted | | Azure Functions v4, Node 24 | Source package, adapter/config tests and actual SDK request objects tested | 18 common cases; Functions host/key enforcement not run | Not deployed | -GitHub Actions (`ubuntu-24.04`, full bootstrap and every reproduction step) has -passed on this branch. See [verification notes](docs/verification.md) for exact -commands and limits. +GitHub Actions (`ubuntu-24.04`, compiler install and every reproduction step) has +passed. See [verification notes](docs/verification.md) for exact commands and limits. ## Quick start -Requires Linux or macOS, Git, Rust **1.99.0**, a C build toolchain, Bash, and Node -**22+**. Local evidence used Linux x86_64 with Node **24.19.0**, and macOS arm64 -with Node **24.21.0** and **22.23.1**. Rust installation is a prerequisite; the -repository does not install it for you. `rust-toolchain.toml` selects 1.99.0 only -when `cargo` is the rustup proxy; a standalone `cargo` earlier on `PATH` ignores it. +Requires Linux (glibc 2.39+, e.g. Ubuntu 24.04 or Debian 13) or macOS, curl, Rust +**1.99.0**, a C build toolchain, Bash, and Node **22+**. Rust is still needed +because native `almide build` emits Rust and compiles it with cargo; the compiler +itself is downloaded. Local evidence used Linux x86_64 with Node **24.19.0**, and +macOS arm64 with Node **24.21.0** and **22.23.1**. Rust installation is a +prerequisite; the repository does not install it for you. `rust-toolchain.toml` +selects 1.99.0 only when `cargo` is the rustup proxy; a standalone `cargo` earlier +on `PATH` ignores it. ```sh -./scripts/install-almide.sh # builds the exact compiler commit; first build is substantial +./scripts/install-almide.sh # downloads the pinned release and checks its sha256 (seconds) npm ci npm run build npm test @@ -84,9 +92,10 @@ npm run dev:workers Container templates use existing infrastructure and private/internal ingress; function examples retain IAM or function-key authentication. They do not create accounts or silently grant caller access. Read [deployment safety and cleanup](docs/deployment-safety.md) -before applying any example. The Cloudflare Workers, Google Cloud Run container and -Cloud Run functions examples have been deployed temporarily for verification and -then deleted; the other providers have not been deployed. +before applying any example. The Cloudflare Workers, Google Cloud Run container, +Cloud Run functions and ConoHa VPS examples have been deployed temporarily for +verification and then deleted; the AWS and Azure examples have not been deployed. +The ConoHa VPS itself is created by the optional [Terraform](providers/conoha/terraform/). ### Prepare and test function packages locally @@ -149,13 +158,16 @@ custom WASI shim or a provider-specific compiler backend. ## Build discipline -The installer refuses modified or untracked compiler source without deleting edits. -Almide is pinned by full commit, Rust by exact version, Wrangler by exact version -and npm lockfile, and GitHub Actions by commit. Docker base images use explicit -version tags but are not digest-locked; this is not yet a fully hermetic build. +The installer downloads the release archive for the current platform and refuses +to unpack or install it unless its sha256 matches the committed checksum. +Almide is pinned by release tag and archive checksum, Rust by exact version, +Wrangler by exact version and npm lockfile, and GitHub Actions by commit. Docker +base images use explicit version tags but are not digest-locked; this is not yet +a fully hermetic build. To move to another release, update `.almide-release` and +copy that release's `almide-checksums.sha256` lines into `.almide-checksums.sha256`. `ALMIDE_BIN=/absolute/path/to/almide npm run build` is available for local compiler -development, but bypasses the default revision-file check; record the source -revision yourself when using it. +development, but bypasses the default release check; record the compiler version +yourself when using it. Generated Wasm/JS, executables, dependencies and local credentials are ignored. Nothing in the default build/test workflow provisions cloud resources or deploys. @@ -168,4 +180,4 @@ Copyright (c) 2026 Aid-On Inc. Almide and other third-party tools and dependencies retain their own licenses. This repository's MIT license does not relicense the pinned Almide compiler, its runtime, generated third-party code, or npm dependencies. See the -[upstream Almide license at the pinned revision](https://github.com/almide/almide/blob/852b028a5706801fd008a753bcbdf8b3ea93156f/LICENSE). +[upstream Almide license at the pinned release](https://github.com/almide/almide/blob/v0.66.0/LICENSE). diff --git a/docs/verification.md b/docs/verification.md index 50177fb..cd410c3 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -2,6 +2,10 @@ Date: 2026-10-04 (UTC) +The compiler pin is now the official `v0.66.0` release binary; see +[Compiler pin moved to the v0.66.0 release](#compiler-pin-moved-to-the-v0660-release). +The sections before it record runs made with a source build of the earlier pin. + ## Toolchain and source - Almide source: `852b028a5706801fd008a753bcbdf8b3ea93156f` @@ -184,11 +188,66 @@ Cloud Run functions: (invalid JSON, body limit, trailing text) failed, matching the local record 4. 403 without a token from the VPC; 404 with a valid token from the internet +## Compiler pin moved to the v0.66.0 release + +Building the compiler from source dominated every build: about 7.5 minutes on an +Apple silicon Mac, and 5m46s of a 7m18s `docker compose build` on a 4-core VPS. +The official `v0.66.0` release (2026-10-03) ships linux x86_64/aarch64 and macOS +binaries with a checksum file, so the pin moved from source commit `852b028` to +that release. Its tag commit `819bbc7` and `852b028` have diverged (30 and 155 +commits apart), so it is a different compiler and was re-verified: + +1. `install-almide.sh` downloads the platform archive and installs it only if its + sha256 matches `.almide-checksums.sha256` (copied from the release's + `almide-checksums.sha256`). 2.4 seconds on macOS arm64 +2. The installer tests stub only the download: a matching archive installs + `almide` and `almide-verify`; a wrong checksum installs nothing; a malformed + tag is refused before any download +3. macOS arm64, Node 24.21.0: `npm test` 117/117, `test:workers` 19/19, + `test:google-framework` 39/39, `test:staged-functions` 38/38. App build 7 s +4. The release binary needs glibc 2.39+, so it fails on Debian bookworm; the + Dockerfile moved to `rust:1.99.0-trixie` / `debian:trixie-slim`. Native + `almide build` still emits Rust and runs cargo (it fails without cargo), so + the build stage keeps Rust +5. macOS arm64 `docker build`: 10 s with cached base images; Compose and the 18 + cases passed, read-only root filesystem and UID 65532 retained +6. ConoHa VPS x86_64 (below): 91 s including base-image pulls, 24 s with + `--no-cache`; 18 cases passed + +The earlier Cloudflare and Google deployments used the source-built compiler and +were not repeated with the release binary. License texts are identical at both pins. + +## ConoHa VPS + +Date: 2026-10-04. Created with [providers/conoha/terraform](../providers/conoha/terraform/) +(Terraform 1.14.9, the Aid-On fork of the conohavps provider at `ff59ace`, built +locally and used through `dev_overrides`), region c3j1. + +1. `terraform plan`: 5 to add (server, boot volume, key pair, security group, one + SSH rule from the operator's /32). Existing servers, keys and groups in the + account were not in the plan and were unchanged afterwards +2. `terraform apply`: 1m10s. `g2l-t-c4m4`, `vmi-docker-29.2-ubuntu-24.04-amd64`: + Ubuntu 24.04.4, x86_64, 4 vCPU, 3.8 GiB, Docker 29.2.1, Compose v5.0.2. The + account's second server was accepted +3. First boot ran unattended upgrades through cloud-init for about 15 minutes; + the build waited for `cloud-init status --wait` +4. ConoHa README commands from a clone of the repository: + - with the source-build installer (main at `42cecaa`): build 7m18s (compiler + 5m46s); `/health`, `/greet` and the 18 cases passed + - with the release installer (`4f72f7b`): build 91 s including base-image + pulls, 24 s with `--no-cache`; the 18 cases passed +5. The 18 cases ran from the Mac through `ssh -L` to the VPS loopback. The app + listened only on `127.0.0.1:8080`; port 8080 on the public address was not + reachable. The container ran as 65532 with a read-only root filesystem and + `CapDrop=[ALL]`; `docker compose down` took 0.5 s +6. `terraform destroy` removed all 5 resources; the VPS existed about 23 minutes + ## Not established -- x86_64 Compose startup or native x86_64 Docker build (the amd64 image was - built and run only under QEMU, and run on Cloud Run) -- A ConoHa VPS installation, ingress, TLS, restart behavior or production load +- Native x86_64 Docker build outside the ConoHa VPS +- ConoHa TLS/reverse proxy, restart behavior, production load, or plans smaller + than `g2l-t-c4m4` +- Cloudflare and Google deployments with the release compiler - Azure Container Apps or ECS Fargate provider validation/deployment - Lambda managed runtime or Azure Functions host execution/authentication - Google Cloud costs, load, cold-start latency or long-running behavior @@ -214,12 +273,12 @@ npm run test:google-framework npm run test:staged-functions ``` -For a future Docker gate, run the Compose commands in the ConoHa README on a -machine with Docker and verify `/health` and `/greet` before marking the route -container-tested. Deployments require a separate, explicit decision. +For a Docker gate, run the Compose commands in the ConoHa README on a machine with +Docker and verify `/health` and `/greet`. Deployments require a separate, +explicit decision. ## Upstream references -- [HTTP server semantics at the pin](https://github.com/almide/almide/blob/852b028a5706801fd008a753bcbdf8b3ea93156f/docs/stdlib/http.md) -- [Generated JS host at the pin](https://github.com/almide/almide/blob/852b028a5706801fd008a753bcbdf8b3ea93156f/src/cli/js_host.rs) -- [JS host contract at the pin](https://github.com/almide/almide/blob/852b028a5706801fd008a753bcbdf8b3ea93156f/docs/wasm/WASM-OUTPUT.md) +- [HTTP server semantics at the pin](https://github.com/almide/almide/blob/v0.66.0/docs/stdlib/http.md) +- [Generated JS host at the pin](https://github.com/almide/almide/blob/v0.66.0/src/cli/js_host.rs) +- [JS host contract at the pin](https://github.com/almide/almide/blob/v0.66.0/docs/wasm/WASM-OUTPUT.md) diff --git a/licenses/README.md b/licenses/README.md index 6e74c71..c647f9b 100644 --- a/licenses/README.md +++ b/licenses/README.md @@ -1,7 +1,8 @@ # Third-party notices The files `Almide-MIT.txt` and `Almide-APACHE.txt` reproduce the license texts from -[Almide at the compiler revision pinned by this repository](https://github.com/almide/almide/tree/852b028a5706801fd008a753bcbdf8b3ea93156f). +[Almide at the release pinned by this repository](https://github.com/almide/almide/tree/v0.66.0) +(identical to those at the earlier source pin `852b028`). Almide is offered under MIT or Apache-2.0, at the recipient's option. These notices accompany the generated Wasm packages; our root MIT license does not replace them. diff --git a/providers/azure-container-apps/README.md b/providers/azure-container-apps/README.md index 9d65e33..7511bc7 100644 --- a/providers/azure-container-apps/README.md +++ b/providers/azure-container-apps/README.md @@ -95,8 +95,8 @@ Use the returned SHA-256 digest, removing only the `sha256:` prefix, for the tag. Confirm that this digest identifies the tested amd64 image. ARM's length constraints do not prove the digest exists or validate its architecture. -The shared image runs as UID/GID 65532 and uses `PORT=8080`. Its compiler build is -substantial; build on a suitably sized machine. Explicit `--platform` matters +The shared image runs as UID/GID 65532 and uses `PORT=8080`. Its build downloads +the pinned compiler and compiles only the app. Explicit `--platform` matters on ARM laptops. Base images currently use version tags, so pinning the deployed image digest does not make source rebuilds fully hermetic. diff --git a/providers/conoha/README.md b/providers/conoha/README.md index 04b9693..430ba48 100644 --- a/providers/conoha/README.md +++ b/providers/conoha/README.md @@ -1,20 +1,60 @@ # ConoHa VPS: native container route -Status: native executable tested on Linux locally. The image and the Compose -commands below were run locally on linux/arm64 (Docker 29.6.1) and passed the -shared 18-case HTTP contract. An x86_64 image and a ConoHa VPS deployment have -**not** been run yet. This directory is a reproducible setup candidate, not a -claim of hosted support. No ConoHa SDK is required. +Status: on 2026-10-04 a VPS was created with the [Terraform](#optional-a-disposable-vps-with-terraform) +below (`g2l-t-c4m4`, Docker 29.2.1, Compose v5.0.2, Ubuntu 24.04.4, x86_64). The +Compose commands below built the image on the VPS, and the shared 18-case HTTP +contract passed through an SSH tunnel; port 8080 was not reachable from the +internet. The VPS was then destroyed. The same commands also passed on macOS +arm64 (Docker 29.6.1). TLS, a reverse proxy, restart behavior and load were not +tested. No ConoHa SDK is required. ## Prerequisites - An existing Linux VPS, compatible Docker Engine and Compose plugin, and a deliberate plan for TLS and ingress. Do not create resources just to run tests. -- Build on a matching Linux architecture; the first local proof was x86_64. -- Sufficient build resources. The Almide compiler build embeds Wasmtime and is much - heavier than the tiny deployed app; build elsewhere and transfer an image if - the VPS is small. ConoHa's documented 1 GiB template minimum is not a build-RAM - recommendation. +- Build on a matching Linux architecture. +- The image build downloads the pinned compiler and compiles only the app with + cargo. On a 4-core, 4 GB VPS it took 91 seconds including base-image pulls, and + 24 seconds for a rebuild without layer cache. Smaller plans were not measured. + +## Optional: a disposable VPS with Terraform + +[terraform/](terraform/) creates one new VPS for this example and nothing else: +a `g2l-t-c4m4` server (Linux, hourly billing, 4 cores, 4 GB) from ConoHa's Docker +image, its 100 GB boot volume, an SSH key pair, and a security group that admits +only TCP 22 from the CIDRs you give. ConoHa's `IPv4v6-SSH` group admits SSH from +anywhere, so it is not used. Existing servers, keys and groups in the account are +not read or changed. The server is billed hourly until it is destroyed, stopped or +not; check [ConoHa pricing](https://vps.conoha.jp/pricing/) first. + +It uses the Aid-On fork of the ConoHa provider, which is not on the Terraform +Registry. Build it and point `dev_overrides` at it as its +[setup section](https://github.com/Aid-On/terraform-provider-conohavps#セットアップ) +describes. Credentials are those of a ConoHa API user, given only through the +environment; keep them in a file outside Git: + +```sh +cat > ~/.config/conoha/credentials.env <<'X' +export CONOHAVPS_TENANT_ID='...' +export CONOHAVPS_USER_ID='...' +export CONOHAVPS_PASSWORD='...' +X +chmod 600 ~/.config/conoha/credentials.env +set -a; . ~/.config/conoha/credentials.env; set +a + +cd providers/conoha/terraform +cp terraform.tfvars.example terraform.tfvars # set ssh_allowed_cidrs to your /32 +terraform init +terraform plan +terraform apply +ssh root@$(terraform output -raw ipv4) cloud-init status --wait +``` + +`terraform.tfvars`, state and `.terraform/` are ignored by Git. A new ConoHa +account may refuse a second server (`Number of flavors (plans) allowed per project +is limit`) until ConoHa raises the limit. On first boot the Docker image runs +unattended upgrades, so wait for cloud-init before building. Then run the +commands below on the server, and finish with `terraform destroy`. From the repository root: @@ -30,8 +70,8 @@ docker compose -f providers/conoha/compose.yaml down Compose binds the app only to the host loopback address. For public access, place a TLS reverse proxy in front and configure the intended ConoHa security group and guest firewall yourself. Docker-published ports can bypass UFW; UFW alone is -not proof the app is private. No firewall, TLS, DNS or cloud-account automation is -included here. +not proof the app is private. The optional Terraform creates only the server and +its SSH-only security group; no TLS, DNS or public ingress is included. The runtime image uses a non-root UID, and Compose drops capabilities and makes the root filesystem read-only. These choices are a starting point, not a security diff --git a/providers/conoha/terraform/main.tf b/providers/conoha/terraform/main.tf new file mode 100644 index 0000000..80f55cb --- /dev/null +++ b/providers/conoha/terraform/main.tf @@ -0,0 +1,50 @@ +# One disposable Docker VPS for the Compose example. Everything here is new and +# named after var.name; existing servers, keys and security groups are left alone. + +data "conohavps_flavor" "plan" { name = var.flavor } +data "conohavps_image" "docker" { name = var.image } + +resource "conohavps_keypair" "admin" { + name = "${var.name}-admin" + public_key = file(pathexpand(var.ssh_public_key)) +} + +# SSH from the operator's addresses and nothing else inbound. compose.yaml +# publishes the app on host loopback only, so it is reached through SSH. +resource "conohavps_securitygroup" "ssh" { + name = "${var.name}-ssh" + description = "SSH from operator CIDRs only" +} + +resource "conohavps_securitygroup_rule" "ssh" { + for_each = toset(var.ssh_allowed_cidrs) + securitygroup_id = conohavps_securitygroup.ssh.id + direction = "ingress" + ethertype = strcontains(each.value, ":") ? "IPv6" : "IPv4" + protocol = "tcp" + port_range_min = 22 + port_range_max = 22 + remote_ip_prefix = each.value +} + +resource "conohavps_volume" "boot" { + name = "${var.name}-boot" + size = 100 # the plan's own boot storage; 200 or 500 is billed as added storage + volume_type = "c3j1-ds02-boot" + image_ref = data.conohavps_image.docker.id +} + +resource "conohavps_instance" "host" { + instance_name_tag = var.name + flavor_id = data.conohavps_flavor.plan.id + block_device = [{ uuid = conohavps_volume.boot.id }] + key_name = conohavps_keypair.admin.name + security_group = [{ name = conohavps_securitygroup.ssh.name }] + power_state = "ACTIVE" + depends_on = [conohavps_securitygroup_rule.ssh] +} + +locals { + # The global address: addresses also lists additional IPs (add-) and local networks (local-). + ipv4 = [for net, addrs in conohavps_instance.host.addresses : [for a in addrs : a.addr if a.version == 4][0] if startswith(net, "ext-")][0] +} diff --git a/providers/conoha/terraform/outputs.tf b/providers/conoha/terraform/outputs.tf new file mode 100644 index 0000000..e33b221 --- /dev/null +++ b/providers/conoha/terraform/outputs.tf @@ -0,0 +1,11 @@ +output "ipv4" { + value = local.ipv4 +} + +output "ssh" { + value = "ssh root@${local.ipv4}" +} + +output "instance_id" { + value = conohavps_instance.host.id +} diff --git a/providers/conoha/terraform/terraform.tfvars.example b/providers/conoha/terraform/terraform.tfvars.example new file mode 100644 index 0000000..57bd6dd --- /dev/null +++ b/providers/conoha/terraform/terraform.tfvars.example @@ -0,0 +1,4 @@ +# Copy to terraform.tfvars (ignored by Git) and fill in. +ssh_allowed_cidrs = ["203.0.113.10/32"] +# ssh_public_key = "~/.ssh/id_ed25519.pub" +# flavor = "g2l-t-c4m4" diff --git a/providers/conoha/terraform/variables.tf b/providers/conoha/terraform/variables.tf new file mode 100644 index 0000000..0812e6b --- /dev/null +++ b/providers/conoha/terraform/variables.tf @@ -0,0 +1,42 @@ +variable "name" { + description = "Name of the server and the prefix of everything created with it" + type = string + default = "almide-cloud-example" + validation { + condition = can(regex("^[a-z][a-z0-9-]{2,40}$", var.name)) + error_message = "Use 3-41 lowercase letters, digits or hyphens." + } +} + +variable "flavor" { + # The verified plan. The image build compiles only the app (the compiler is + # downloaded); smaller plans were not measured. + description = "ConoHa plan by flavor name: g2l-t-c4m4 is Linux, hourly billing, 4 cores, 4 GB" + type = string + default = "g2l-t-c4m4" +} + +variable "image" { + description = "ConoHa Docker application image (Docker Engine and Compose on Ubuntu 24.04)" + type = string + default = "vmi-docker-29.2-ubuntu-24.04-amd64" +} + +variable "ssh_public_key" { + description = "Path of the public key installed for root" + type = string + default = "~/.ssh/id_ed25519.pub" +} + +variable "ssh_allowed_cidrs" { + # No default on purpose. ConoHa's IPv4v6-SSH group admits SSH from anywhere; + # this configuration admits only the given sources. + description = "Source CIDRs allowed to reach SSH, for example your own address as /32" + type = list(string) + validation { + condition = length(var.ssh_allowed_cidrs) > 0 && alltrue([ + for c in var.ssh_allowed_cidrs : can(cidrhost(c, 0)) && !contains(["0.0.0.0/0", "::/0"], c) + ]) + error_message = "Give at least one specific CIDR; 0.0.0.0/0 and ::/0 are refused." + } +} diff --git a/providers/conoha/terraform/versions.tf b/providers/conoha/terraform/versions.tf new file mode 100644 index 0000000..458859c --- /dev/null +++ b/providers/conoha/terraform/versions.tf @@ -0,0 +1,11 @@ +# The conohavps provider is the Aid-On fork (https://github.com/Aid-On/terraform-provider-conohavps), +# which is not on the Registry: build it and point dev_overrides at it (README.md). +terraform { + required_version = ">= 1.5" + required_providers { + conohavps = { source = "gmo-internet/conohavps" } + } +} + +# Credentials from CONOHAVPS_TENANT_ID, CONOHAVPS_USER_ID and CONOHAVPS_PASSWORD. +provider "conohavps" {} diff --git a/providers/google-cloud-run/README.md b/providers/google-cloud-run/README.md index b95c1a4..60036d7 100644 --- a/providers/google-cloud-run/README.md +++ b/providers/google-cloud-run/README.md @@ -100,7 +100,8 @@ selects 8080 and HTTP/1. Cloud Run terminates HTTPS before forwarding to it. Do not add TLS inside this container or override `PORT` in the service environment. See the [container contract](https://docs.cloud.google.com/run/docs/container-contract). -The compiler build is much heavier than the deployed program. Docker base tags +The image build downloads the pinned compiler and compiles only the app; under +QEMU on Apple silicon it is still much slower than a native amd64 build. Docker base tags in the shared Dockerfile are not digest-locked; pinning the deployed image fixes the deployed bytes but does not make rebuilding hermetic. Review/scanning of that image and graceful shutdown/load behavior remain operator responsibilities. diff --git a/scripts/build.sh b/scripts/build.sh index 0b3a9f1..494ccde 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -6,8 +6,8 @@ if [[ ! -x "$almide" ]]; then echo 'Compiler missing. Run ./scripts/install-almide.sh first.' >&2; exit 1 fi if [[ -z "${ALMIDE_BIN:-}" ]]; then - cmp -s .almide-revision .tools/bin/almide.revision || { - echo 'Compiler revision mismatch. Re-run ./scripts/install-almide.sh.' >&2; exit 1 + cmp -s .almide-release .tools/bin/almide.release || { + echo 'Compiler release mismatch. Re-run ./scripts/install-almide.sh.' >&2; exit 1 } fi mkdir -p build diff --git a/scripts/install-almide.sh b/scripts/install-almide.sh index 04d5b47..d65e5ff 100755 --- a/scripts/install-almide.sh +++ b/scripts/install-almide.sh @@ -1,34 +1,39 @@ #!/usr/bin/env bash -# Build the exact reviewed compiler revision. Rust 1.99.0, git, and a C toolchain required. +# Install the pinned Almide release binary. The tag is in .almide-release and the +# expected sha256 of each platform archive in .almide-checksums.sha256; an archive +# that does not match is never unpacked or installed. Needs curl and tar. set -euo pipefail cd "$(dirname "$0")/.." -revision=$(tr -d '\r\n' < .almide-revision) -[[ "$revision" =~ ^[0-9a-f]{40}$ ]] || { echo 'Invalid compiler revision' >&2; exit 1; } -source_dir="$PWD/.tools/almide-source" -if [[ ! -d "$source_dir/.git" ]]; then - mkdir -p .tools - git init "$source_dir" - git -C "$source_dir" remote add origin https://github.com/almide/almide.git +tag=$(tr -d '\r\n' < .almide-release) +[[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo 'Invalid release tag in .almide-release' >&2; exit 1; } +case "$(uname -s)-$(uname -m)" in + Linux-x86_64) platform=linux-x86_64 ;; + Linux-aarch64 | Linux-arm64) platform=linux-aarch64 ;; + Darwin-arm64) platform=macos-aarch64 ;; + Darwin-x86_64) platform=macos-x86_64 ;; + *) echo "No Almide release archive for $(uname -s) $(uname -m)" >&2; exit 1 ;; +esac +archive="almide-$platform.tar.gz" +expected=$(awk -v f="$archive" '$2 == f { print $1 }' .almide-checksums.sha256) +[[ "$expected" =~ ^[0-9a-f]{64}$ ]] || { echo "No checksum for $archive" >&2; exit 1; } + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +curl --fail --location --silent --show-error --retry 3 \ + -o "$work/$archive" "https://github.com/almide/almide/releases/download/$tag/$archive" +if command -v sha256sum >/dev/null; then + actual=$(sha256sum "$work/$archive" | awk '{ print $1 }') +else + actual=$(shasum -a 256 "$work/$archive" | awk '{ print $1 }') fi -if [[ "$(git -C "$source_dir" remote get-url origin)" != https://github.com/almide/almide.git ]]; then - echo 'Unexpected compiler source remote' >&2; exit 1 +if [[ "$actual" != "$expected" ]]; then + echo "Checksum mismatch for $archive: expected $expected, got $actual" >&2; exit 1 fi -require_clean_source() { - if [[ -n "$(git -C "$source_dir" status --porcelain --untracked-files=all)" ]]; then - echo 'Compiler source has modified or untracked files. Preserve your edits and use a clean checkout.' >&2 - exit 1 - fi -} -# Never replace local edits, or label them as the pinned upstream revision. -# Normal ignored build artifacts (for example target/) do not make it dirty. -require_clean_source -git -C "$source_dir" fetch --depth 1 origin "$revision" -git -C "$source_dir" checkout --detach "$revision" -[[ "$(git -C "$source_dir" rev-parse HEAD)" == "$revision" ]] -require_clean_source -# The root rust-toolchain.toml selects the pinned Rust toolchain. -cargo build --locked --release --bin almide --manifest-path "$source_dir/Cargo.toml" +tar -xzf "$work/$archive" -C "$work" +# almide verify execs almide-verify from next to itself, so both are installed. mkdir -p .tools/bin -cp "$source_dir/target/release/almide" .tools/bin/almide -printf '%s\n' "$revision" > .tools/bin/almide.revision +for tool in almide almide-verify; do + install -m 0755 "$work/almide-$platform/$tool" ".tools/bin/$tool" +done +printf '%s\n' "$tag" > .tools/bin/almide.release .tools/bin/almide --version diff --git a/tests/installer.test.mjs b/tests/installer.test.mjs index 8a946dd..622465b 100644 --- a/tests/installer.test.mjs +++ b/tests/installer.test.mjs @@ -1,67 +1,72 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { mkdtemp, mkdir, readFile, writeFile, copyFile, rm } from 'node:fs/promises'; +import { mkdtemp, mkdir, readFile, writeFile, copyFile, rm, access } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { spawnSync } from 'node:child_process'; -// Real git status/checkout, but no network or compiler build. Only those two -// expensive operations are replaced, so the provenance guard is tested directly. -async function fixture(t) { +// Real checksum, tar and install steps; only the download is replaced by a stub +// curl that serves a locally built archive, so the integrity guard is tested directly. +function platform() { + const os = spawnSync('uname', ['-s'], { encoding: 'utf8' }).stdout.trim(); + const arch = spawnSync('uname', ['-m'], { encoding: 'utf8' }).stdout.trim(); + return { 'Linux-x86_64': 'linux-x86_64', 'Linux-aarch64': 'linux-aarch64', 'Linux-arm64': 'linux-aarch64', + 'Darwin-arm64': 'macos-aarch64', 'Darwin-x86_64': 'macos-x86_64' }[`${os}-${arch}`]; +} + +async function fixture(t, { tag = 'v9.9.9', tamper = false } = {}) { const root = await mkdtemp(join(tmpdir(), 'almide-installer-')); t.after(() => rm(root, { recursive: true, force: true })); - const source = join(root, '.tools/almide-source'); + const name = `almide-${platform()}`; + const staging = join(root, 'staging', name); const bin = join(root, 'test-bin'); - await mkdir(source, { recursive: true }); + await mkdir(staging, { recursive: true }); await mkdir(bin); await mkdir(join(root, 'scripts')); await copyFile(new URL('../scripts/install-almide.sh', import.meta.url), join(root, 'scripts/install-almide.sh')); - const runGit = (...args) => { - const result = spawnSync('git', ['-C', source, ...args], { encoding: 'utf8' }); - assert.equal(result.status, 0, result.stderr); - return result.stdout.trim(); - }; - runGit('init', '-q'); - runGit('config', 'user.name', 'Installer Test'); - runGit('config', 'user.email', 'installer-test@example.invalid'); - runGit('config', 'commit.gpgsign', 'false'); - runGit('remote', 'add', 'origin', 'https://github.com/almide/almide.git'); - await writeFile(join(source, '.gitignore'), 'target/\n'); - await writeFile(join(source, 'compiler.rs'), '// unchanged\n'); - runGit('add', '.'); - runGit('commit', '-qm', 'fixture'); - const revision = runGit('rev-parse', 'HEAD'); - await writeFile(join(root, '.almide-revision'), revision + '\n'); - const realGit = spawnSync('sh', ['-c', 'command -v git'], { encoding: 'utf8' }).stdout.trim(); - await writeFile(join(bin, 'git'), `#!/bin/sh\nif [ "$3" = fetch ]; then exit 0; fi\nexec "${realGit}" "$@"\n`, { mode: 0o755 }); - await writeFile(join(bin, 'cargo'), `#!/bin/sh\nset -eu\necho called > "$TEST_ROOT/cargo-called"\nmkdir -p "$TEST_ROOT/.tools/almide-source/target/release"\nprintf '#!/bin/sh\\necho fake-test-compiler\\n' > "$TEST_ROOT/.tools/almide-source/target/release/almide"\nchmod +x "$TEST_ROOT/.tools/almide-source/target/release/almide"\n`, { mode: 0o755 }); + for (const tool of ['almide', 'almide-verify']) { + await writeFile(join(staging, tool), `#!/bin/sh\necho fake-${tool}\n`, { mode: 0o755 }); + } + const archive = join(root, `${name}.tar.gz`); + const tar = spawnSync('tar', ['-czf', archive, '-C', join(root, 'staging'), name], { encoding: 'utf8' }); + assert.equal(tar.status, 0, tar.stderr); + const digest = createHash('sha256').update(await readFile(archive)).digest('hex'); + const listed = tamper ? digest.replace(/^./, c => (c === '0' ? '1' : '0')) : digest; + await writeFile(join(root, '.almide-release'), `${tag}\n`); + await writeFile(join(root, '.almide-checksums.sha256'), `${listed} ${name}.tar.gz\n`); + // Stub curl: record the URL and copy the fixture archive to the -o target. + await writeFile(join(bin, 'curl'), `#!/bin/sh\nset -eu\nout=\nwhile [ $# -gt 0 ]; do case "$1" in -o) out=$2; shift 2;; -*) shift;; *) echo "$1" > "$TEST_ROOT/curl-url"; shift;; esac; done\ncp "$TEST_ROOT/${name}.tar.gz" "$out"\n`, { mode: 0o755 }); const run = () => spawnSync('bash', ['scripts/install-almide.sh'], { cwd: root, encoding: 'utf8', env: { ...process.env, PATH: bin + ':' + process.env.PATH, TEST_ROOT: root }, }); - return { root, source, revision, run }; -} - -for (const kind of ['tracked modification', 'untracked file']) { - test(`installer refuses ${kind} without deleting it`, async t => { - const f = await fixture(t); - const edited = join(f.source, kind === 'tracked modification' ? 'compiler.rs' : 'new-file.rs'); - await writeFile(edited, '// preserve this edit\n'); - const result = f.run(); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /modified or untracked/); - assert.equal(await readFile(edited, 'utf8'), '// preserve this edit\n'); - await assert.rejects(readFile(join(f.root, 'cargo-called')), { code: 'ENOENT' }); - await assert.rejects(readFile(join(f.root, '.tools/bin/almide.revision')), { code: 'ENOENT' }); - }); + return { root, name, tag, run }; } -test('installer accepts a clean checkout with ignored build artifacts', async t => { +test('installer installs both tools from a release archive whose checksum matches', async t => { const f = await fixture(t); - await mkdir(join(f.source, 'target')); - await writeFile(join(f.source, 'target/existing-build'), 'keep\n'); const result = f.run(); assert.equal(result.status, 0, result.stderr); - assert.equal(await readFile(join(f.root, '.tools/bin/almide.revision'), 'utf8'), f.revision + '\n'); - assert.equal(await readFile(join(f.source, 'target/existing-build'), 'utf8'), 'keep\n'); + assert.match(result.stdout, /fake-almide/); + assert.equal(await readFile(join(f.root, '.tools/bin/almide.release'), 'utf8'), `${f.tag}\n`); + await access(join(f.root, '.tools/bin/almide-verify')); + assert.equal((await readFile(join(f.root, 'curl-url'), 'utf8')).trim(), + `https://github.com/almide/almide/releases/download/${f.tag}/${f.name}.tar.gz`); +}); + +test('installer refuses an archive with the wrong checksum and installs nothing', async t => { + const f = await fixture(t, { tamper: true }); + const result = f.run(); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Checksum mismatch/); + await assert.rejects(access(join(f.root, '.tools/bin')), { code: 'ENOENT' }); +}); + +test('installer refuses a malformed release tag before downloading', async t => { + const f = await fixture(t, { tag: 'main' }); + const result = f.run(); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Invalid release tag/); + await assert.rejects(access(join(f.root, 'curl-url')), { code: 'ENOENT' }); }); diff --git a/tests/provider-config.test.mjs b/tests/provider-config.test.mjs index 2785460..c6475b1 100644 --- a/tests/provider-config.test.mjs +++ b/tests/provider-config.test.mjs @@ -128,6 +128,25 @@ test('function deployments preserve IAM/key authentication defaults', async () = }); +test('ConoHa Terraform admits only operator SSH and keeps state and credentials out of Git', async () => { + const read = path => readFile(new URL(`../providers/conoha/terraform/${path}`, import.meta.url), 'utf8'); + const main = await read('main.tf'); + const variables = await read('variables.tf'); + const versions = await read('versions.tf'); + assert.match(versions, /source\s*=\s*"gmo-internet\/conohavps"/); + assert.match(versions, /provider "conohavps" \{\}/, 'credentials come from the environment'); + // Only port 22, only from the given CIDRs, and not ConoHa's world-open SSH group. + assert.equal(main.match(/resource "conohavps_securitygroup_rule"/g).length, 1); + assert.match(main, /port_range_min\s*=\s*22\s*\n\s*port_range_max\s*=\s*22/); + assert.match(main, /remote_ip_prefix\s*=\s*each\.value/); + assert.match(main, /security_group\s*=\s*\[\{ name = conohavps_securitygroup\.ssh\.name \}\]/); + assert.doesNotMatch(main, /IPv4v6-SSH|0\.0\.0\.0\/0|::\/0/); + assert.match(variables, /variable "ssh_allowed_cidrs" \{(?:(?!default\s*=)[\s\S])*?validation/, 'no default CIDR'); + assert.match(variables, /"0\.0\.0\.0\/0", "::\/0"/); + const gitIgnore = await readFile(new URL('../.gitignore', import.meta.url), 'utf8'); + for (const entry of ['.terraform/', '*.tfstate', '*.tfvars', '!*.tfvars.example']) assert.ok(gitIgnore.includes(entry), entry); +}); + test('local credential and tool outputs are excluded from source and Docker context', async () => { const gitIgnore = await readFile(new URL('../.gitignore', import.meta.url), 'utf8'); const dockerIgnore = await readFile(new URL('../.dockerignore', import.meta.url), 'utf8');