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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ If Docker tests skip with "no free 10.88-119.0.0/16 range", earlier runs leaked
| --- | --- |
| `HC_TEST_PYTHON` | Python interpreter with boto3 (default `python3`); e.g. `HC_TEST_PYTHON=$PWD/.venv/bin/python` |
| `HC_TEST_AWS` | path to the `aws` CLI (default: `aws` on `PATH`) |
| `HC_TEST_TERRAFORM` | set to run the Terraform/OpenTofu tests (they download the AWS provider; set `TF_PLUGIN_CACHE_DIR` to reuse it) |
| `HC_TEST_TERRAFORM` | set to run the Terraform/OpenTofu tests: `1` uses `terraform`, then `tofu`, from `PATH`; a path (e.g. `/opt/tofu/tofu`) uses that binary. They download the AWS provider; set `TF_PLUGIN_CACHE_DIR` to reuse it |
| `HC_TEST_NODE_PATH` | a `node_modules` directory with `amazon-cognito-identity-js`, for the Cognito SRP test in Node.js |

**End to end.** With a server running, `./scripts/smoke.sh` exercises every service through the CLI and
Expand Down
31 changes: 31 additions & 0 deletions cli/internal/awsapi/awstest/awstest.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
//
// The AWS CLI is found on PATH (or HC_TEST_AWS); Python with boto3 is
// HC_TEST_PYTHON (default python3). Tests skip when a tool is missing.
//
// The Terraform/OpenTofu tests are opt-in because they download the AWS
// provider: set HC_TEST_TERRAFORM to run them, either to "1" (use terraform,
// then tofu, from PATH) or to the path of the binary to use. See Terraform.
package awstest

import (
Expand Down Expand Up @@ -179,6 +183,33 @@ func (h *Harness) Signed(t *testing.T, service, method, path string, header http
return resp.StatusCode, resp.Header, out
}

// Terraform returns the Terraform or OpenTofu binary for a Terraform test, or
// skips the test. The tests are opt-in: they run only when HC_TEST_TERRAFORM
// is set, and not in -short mode. A value that names an executable file (for
// example /opt/tofu/tofu) is used as the binary; any other value (such as 1)
// looks up terraform, then tofu, on PATH. Set TF_PLUGIN_CACHE_DIR to reuse the
// provider download across runs.
func Terraform(t *testing.T) string {
t.Helper()
v := os.Getenv("HC_TEST_TERRAFORM")
if v == "" {
t.Skip("set HC_TEST_TERRAFORM=1 (or the path to terraform/tofu) to run")
}
if testing.Short() {
t.Skip("terraform test is slow")
}
if fi, err := os.Stat(v); err == nil && fi.Mode().IsRegular() && fi.Mode().Perm()&0o111 != 0 {
return v
}
for _, name := range []string{"terraform", "tofu"} {
if p, err := exec.LookPath(name); err == nil {
return p
}
}
t.Skipf("HC_TEST_TERRAFORM=%q is not an executable, and neither terraform nor tofu is on PATH", v)
return ""
}

func awsBin(t *testing.T) string {
if p := os.Getenv("HC_TEST_AWS"); p != "" {
return p
Expand Down
12 changes: 3 additions & 9 deletions cli/internal/svc/cfn/terraform_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,16 @@ import (
"path/filepath"
"strings"
"testing"

"github.com/homecloudhq/homecloud/cli/internal/awsapi/awstest"
)

// TestTerraformCloudFormationStack applies, re-plans, updates and destroys an
// aws_cloudformation_stack with Terraform or OpenTofu. Opt-in
// (HC_TEST_TERRAFORM=1) since it downloads the provider; set TF_PLUGIN_CACHE_DIR
// to reuse it.
func TestTerraformCloudFormationStack(t *testing.T) {
if os.Getenv("HC_TEST_TERRAFORM") == "" {
t.Skip("set HC_TEST_TERRAFORM=1 to run")
}
bin, err := exec.LookPath("terraform")
if err != nil {
if bin, err = exec.LookPath("tofu"); err != nil {
t.Skip("terraform/tofu not installed")
}
}
bin := awstest.Terraform(t)
e := newEnv(t, false)
dir := t.TempDir()
cfg := func(retention string) string {
Expand Down
16 changes: 3 additions & 13 deletions cli/internal/svc/iam/terraform_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,10 @@ import (
)

// TestTerraform applies and destroys a Terraform configuration with the AWS
// provider against HomeCloud's IAM and STS. It needs terraform (HC_TEST_TERRAFORM
// or PATH) and network access to download the provider on first use; set
// TF_PLUGIN_CACHE_DIR to reuse the download.
// provider against HomeCloud's IAM and STS. Opt-in (see awstest.Terraform) since it
// downloads the provider; set TF_PLUGIN_CACHE_DIR to reuse the download.
func TestTerraform(t *testing.T) {
bin := os.Getenv("HC_TEST_TERRAFORM")
if bin == "" {
var err error
if bin, err = exec.LookPath("terraform"); err != nil {
t.Skip("terraform not installed (set HC_TEST_TERRAFORM)")
}
}
if testing.Short() {
t.Skip("terraform test is slow")
}
bin := awstest.Terraform(t)
h := awstest.New(t)
dir := t.TempDir()
write := func(policyAction string) {
Expand Down
11 changes: 2 additions & 9 deletions cli/internal/svc/route53/terraform_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"path/filepath"
"testing"

"github.com/homecloudhq/homecloud/cli/internal/awsapi/awstest"
"github.com/homecloudhq/homecloud/cli/internal/svc/acm"
)

Expand All @@ -14,15 +15,7 @@ import (
// and the hashicorp/aws provider. Opt-in (HC_TEST_TERRAFORM=1) since it
// downloads the provider; set TF_PLUGIN_CACHE_DIR to reuse it.
func TestTerraform(t *testing.T) {
if os.Getenv("HC_TEST_TERRAFORM") == "" {
t.Skip("set HC_TEST_TERRAFORM=1 to run")
}
bin, err := exec.LookPath("terraform")
if err != nil {
if bin, err = exec.LookPath("tofu"); err != nil {
t.Skip("terraform/tofu not installed")
}
}
bin := awstest.Terraform(t)
h, _ := setup(t)
a := acm.New(h.Env, h.Secrets)
a.RegisterAWS()
Expand Down
14 changes: 4 additions & 10 deletions cli/internal/svc/s3/terraform_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,15 @@ import (
"path/filepath"
"strings"
"testing"

"github.com/homecloudhq/homecloud/cli/internal/awsapi/awstest"
)

// TestTerraform applies, re-plans and destroys S3 resources with Terraform or
// OpenTofu and the hashicorp/aws provider. Opt-in (HC_TEST_TERRAFORM=1) since
// OpenTofu and the hashicorp/aws provider. Opt-in (see awstest.Terraform) since
// it downloads the provider; set TF_PLUGIN_CACHE_DIR to reuse it.
func TestTerraform(t *testing.T) {
if os.Getenv("HC_TEST_TERRAFORM") == "" {
t.Skip("set HC_TEST_TERRAFORM=1 to run")
}
bin, err := exec.LookPath("terraform")
if err != nil {
if bin, err = exec.LookPath("tofu"); err != nil {
t.Skip("terraform/tofu not installed")
}
}
bin := awstest.Terraform(t)
h, _ := newHarness(t)
b := bucketName()
dir := t.TempDir()
Expand Down
2 changes: 2 additions & 0 deletions console/app/(console)/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { ConsoleHome } from "@/components/home/console-home"

export const metadata = { title: "Home" }

export default function HomePage() {
return <ConsoleHome />
}
2 changes: 1 addition & 1 deletion console/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const metadata: Metadata = {
default: "HomeCloud Console",
template: "%s | HomeCloud Console",
},
description: "Manage your self-hosted HomeCloud: EC2, S3, VPC, IAM, Secrets Manager, CloudWatch and CloudTrail.",
description: "The web console for HomeCloud, a self-hosted AWS-compatible cloud.",
applicationName: "HomeCloud Console",
icons: { icon: `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/favicon.svg` },
}
Expand Down
6 changes: 6 additions & 0 deletions console/app/login/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// The sign-in page is a client component, so its tab title is set here.
export const metadata = { title: "Sign in" }

export default function LoginLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return children
}
4 changes: 3 additions & 1 deletion docs/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,9 @@ with types. See [integrations/testcontainers-node/README.md](../integrations/tes

All four modules start the image (default `ghcr.io/solinode/homecloud:latest`, configurable)
with the Docker socket mounted and **host networking**, and the API on
`127.0.0.1:18080` of the Docker host (configurable; not 8080, which is often taken).
`127.0.0.1:18080` of the Docker host (configurable; not 8080, which is often taken). The
endpoint they hand out names it `http://localhost:18080`, for the reason in
[SDK notes](#sdk-notes).
HomeCloud then reaches the containers it starts on the host's loopback ports, and they
call it back through `host.docker.internal`, exactly as when it runs on the host. This
works on Linux (GitHub Actions included), OrbStack, and Docker Desktop with host
Expand Down
27 changes: 26 additions & 1 deletion integrations/github-action/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ an AWS account.

The action:

1. downloads the HomeCloud release you ask for and verifies it against the release's `checksums.txt`,
1. downloads the HomeCloud release you ask for, verifies the Sigstore signature on its `checksums.txt` (releases from v0.5.0 on are signed) and checks the download against it,
2. starts `homecloud serve` in the background (data in `$RUNNER_TEMP/homecloud-data`, log in `$RUNNER_TEMP/homecloud.log`),
3. waits for `/api/v1/health` and for any services listed in `services-wait`,
4. exports `AWS_ENDPOINT_URL`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` (masked), `AWS_REGION`, `AWS_DEFAULT_REGION` and `HOMECLOUD_DATA_DIR` for the following steps, and puts `homecloud` on `PATH`,
Expand Down Expand Up @@ -44,6 +44,7 @@ jobs:
| `port` | `8080` | Port the API listens on, on `127.0.0.1`. |
| `services-wait` | (none) | Comma or newline separated services to wait for. `s3`, `ecr` and `route53` start containers in the background; in-process services (`sqs`, `dynamodb`, `lambda`, ...) are ready with the API; an unknown name fails the step. |
| `wait-timeout` | `180` | Seconds to wait for the API and the services. |
| `verify-signature` | `true` | Verify the release signature with cosign before trusting `checksums.txt` (see [Release verification](#release-verification)). `false` checks the SHA-256 checksum only. |

### Outputs

Expand Down Expand Up @@ -126,6 +127,30 @@ provider "aws" {
- run: ./run-tests.sh --endpoint "${{ steps.homecloud.outputs.endpoint }}"
```

## Release verification

HomeCloud releases from v0.5.0 on publish `checksums.txt.sigstore.json`, a Sigstore bundle
over `checksums.txt` made by keyless signing in HomeCloud's release workflow. When the bundle
exists, the action runs

```sh
cosign verify-blob checksums.txt --bundle checksums.txt.sigstore.json \
--certificate-identity https://github.com/solinode/homecloud/.github/workflows/release.yml@refs/tags/<tag> \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
```

and fails the step if the signature does not verify, or if a release from v0.5.0 on has no
bundle. Only then does it trust the SHA-256 in `checksums.txt` for the archive. `latest` is
resolved to its tag first, so the signer identity names the exact release.

- cosign comes from the runner's `PATH` if it is there (for example installed with
`sigstore/cosign-installer`; cosign 2.4 or newer), otherwise the action downloads cosign
v3.1.3 from its GitHub release and checks it against a SHA-256 pinned in `setup.sh`.
- Verification fetches Sigstore's trust root over the network. On a runner that cannot
reach Sigstore, set `verify-signature: false` to check the checksum only.
- Releases before v0.5.0 are unsigned: they are checked against `checksums.txt` only, as
before.

## Notes

- One HomeCloud per Docker host: it names its helper containers (`homecloud-s3`, ...) and
Expand Down
7 changes: 7 additions & 0 deletions integrations/github-action/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ inputs:
description: Seconds to wait for the API and every service in services-wait.
required: false
default: "180"
verify-signature:
description: >-
Verify the release's Sigstore signature (checksums.txt.sigstore.json, published from
v0.5.0 on) with cosign before trusting checksums.txt: "true" or "false". Releases
before v0.5.0 are unsigned and get the checksum check only.
required: false
default: "true"

outputs:
endpoint:
Expand Down
1 change: 1 addition & 0 deletions integrations/github-action/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const env = {
HC_PORT: input("port", "8080"),
HC_SERVICES_WAIT: input("services-wait", ""),
HC_WAIT_TIMEOUT: input("wait-timeout", "180"),
HC_VERIFY_SIGNATURE: input("verify-signature", "true"),
};
const r = spawnSync("bash", [path.join(__dirname, "setup.sh")], { stdio: "inherit", env });
if (r.error) {
Expand Down
94 changes: 84 additions & 10 deletions integrations/github-action/setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ port=$((10#$port))
case "$timeout" in '' | *[!0-9]*) fail "wait-timeout must be a whole number of seconds, got '$timeout'" ;; esac
timeout=$((10#$timeout))
[ "$timeout" -ge 1 ] || fail "wait-timeout must be at least 1 second"
case "$(echo "${HC_VERIFY_SIGNATURE:-true}" | tr '[:upper:]' '[:lower:]')" in
true) verify_sig=1 ;;
false) verify_sig= ;;
*) fail "verify-signature must be true or false, got '$HC_VERIFY_SIGNATURE'" ;;
esac

# Services that start containers in the background log a line when they are ready;
# the others run inside the HomeCloud process and are ready with the API.
Expand All @@ -36,7 +41,7 @@ for svc in $(echo "${HC_SERVICES_WAIT:-}" | tr ',\n' ' ' | tr '[:upper:]' '[:lo
*) case "$in_process" in *" $svc "*) ;; *) fail "services-wait: unknown service '$svc' (background: s3, ecr, route53; in-process:$in_process)" ;; esac ;;
esac
done
[ "${HC_VALIDATE_ONLY:-}" = 1 ] && { echo "inputs OK: port=$port wait-timeout=$timeout waits=${waits[*]:-}"; exit 0; }
[ "${HC_VALIDATE_ONLY:-}" = 1 ] && { echo "inputs OK: port=$port wait-timeout=$timeout waits=${waits[*]:-} verify-signature=${verify_sig:-0}"; exit 0; }

command -v docker >/dev/null 2>&1 || fail "HomeCloud needs Docker on the runner (ubuntu-* runners have it)"
docker info >/dev/null 2>&1 || fail "Docker is installed but not running"
Expand All @@ -49,24 +54,91 @@ case "$(uname -m)" in
esac
name="homecloud-$os-$arch"
if [ "$version" = latest ]; then
base="https://github.com/$REPO/releases/latest/download"
else
case "$version" in v*) ;; *) version="v$version" ;; esac
base="https://github.com/$REPO/releases/download/$version"
# Resolve "latest" to its tag: the signature names the tag it was made for, and
# every file then comes from the same release.
version=$(curl -fsSL --retry 3 -o /dev/null -w '%{url_effective}' "https://github.com/$REPO/releases/latest" | sed -n 's#.*/releases/tag/##p') || true
[ -n "$version" ] || fail "could not determine the latest HomeCloud release"
fi
case "$version" in v*) ;; *) version="v$version" ;; esac
base="https://github.com/$REPO/releases/download/$version"

sha256() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | cut -d' ' -f1
else
shasum -a 256 "$1" | cut -d' ' -f1
fi
}

# signed_release VERSION: true for v0.5.0 and later, which are all signed.
signed_release() {
local v=${1#v} major rest minor
v=${v%%-*}
major=${v%%.*}
rest=${v#*.}
minor=${rest%%.*}
case "$major$minor" in *[!0-9]* | "") return 1 ;; esac
[ "$major" -gt 0 ] || [ "$minor" -ge 5 ]
}

# cosign for the signature check: the runner's own (for example from
# sigstore/cosign-installer), else this pinned release, checked against its SHA-256.
COSIGN_VERSION=v3.1.3
cosign_sha256() {
case "$1" in
linux-amd64) echo 4629c757b7618056f8ddd7e2625ae9fdd94c0372a65049520bc7d9df9efc7f71 ;;
linux-arm64) echo c5d324e091826b0d7a78eb16fef316450b4eb9aaec045611c08ba06f5e73220a ;;
darwin-amd64) echo 2347488e5d5b25336644024dfeca5601b190e91197a71a917bda44744aff106c ;;
darwin-arm64) echo 5cf948c2f4dfe59687bdd0b8523709067383e03982cc543475c8a7dc70e92a76 ;;
esac
}
find_cosign() {
if cosign=$(command -v cosign); then
return
fi
local dir="$tmp/homecloud-cosign" want got
want=$(cosign_sha256 "$os-$arch")
[ -n "$want" ] || fail "no pinned cosign for $os/$arch: install cosign first, or set verify-signature: false"
mkdir -p "$dir"
curl -fsSL --retry 3 "https://github.com/sigstore/cosign/releases/download/$COSIGN_VERSION/cosign-$os-$arch" -o "$dir/cosign" ||
fail "download of cosign $COSIGN_VERSION failed"
got=$(sha256 "$dir/cosign")
[ "$got" = "$want" ] || fail "checksum mismatch for cosign $COSIGN_VERSION (expected $want, got $got)"
chmod +x "$dir/cosign"
cosign="$dir/cosign"
}

echo "::group::Install HomeCloud ($version, $os/$arch)"
dl="$tmp/homecloud-dl"
rm -rf "$dl" && mkdir -p "$dl" "$bin_dir"
curl -fsSL --retry 3 "$base/$name.tar.gz" -o "$dl/$name.tar.gz" || fail "download $base/$name.tar.gz failed"
curl -fsSL --retry 3 "$base/checksums.txt" -o "$dl/checksums.txt" || fail "download $base/checksums.txt failed"
expected=$(awk -v f="$name.tar.gz" '$2 == f || $2 == "*" f {print $1}' "$dl/checksums.txt")
[ -n "$expected" ] || fail "checksums.txt has no entry for $name.tar.gz"
if command -v sha256sum >/dev/null 2>&1; then
actual=$(sha256sum "$dl/$name.tar.gz" | cut -d' ' -f1)

# Releases from v0.5.0 on sign checksums.txt with Sigstore keyless signing in the
# release workflow (checksums.txt.sigstore.json). Verify it when it exists, so the
# checksum below is trusted; older releases are unsigned and get the checksum only.
if [ -z "$verify_sig" ]; then
echo "verify-signature is false: checking the SHA-256 checksum only"
elif curl -fsSL --retry 3 "$base/checksums.txt.sigstore.json" -o "$dl/checksums.txt.sigstore.json" 2>/dev/null; then
find_cosign
echo "verifying the release signature with $cosign"
identity="https://github.com/$REPO/.github/workflows/release.yml@refs/tags/$version"
if ! "$cosign" verify-blob "$dl/checksums.txt" --bundle "$dl/checksums.txt.sigstore.json" \
--certificate-identity "$identity" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com >"$dl/cosign.log" 2>&1; then
cat "$dl/cosign.log"
fail "signature verification of checksums.txt failed for $version (expected signer $identity)"
fi
echo "signature OK (checksums.txt signed by $identity)"
elif signed_release "$version"; then
fail "$version should be signed, but $base/checksums.txt.sigstore.json could not be downloaded (set verify-signature: false to skip the check)"
else
actual=$(shasum -a 256 "$dl/$name.tar.gz" | cut -d' ' -f1)
echo "$version is not signed (releases before v0.5.0 were not): checking the SHA-256 checksum only"
fi

expected=$(awk -v f="$name.tar.gz" '$2 == f || $2 == "*" f {print $1}' "$dl/checksums.txt")
[ -n "$expected" ] || fail "checksums.txt has no entry for $name.tar.gz"
actual=$(sha256 "$dl/$name.tar.gz")
[ "$expected" = "$actual" ] || fail "checksum mismatch for $name.tar.gz (expected $expected, got $actual)"
echo "checksum OK ($actual)"
tar -xzf "$dl/$name.tar.gz" -C "$dl"
Expand All @@ -75,6 +147,8 @@ rm -rf "$dl"
[ -n "${GITHUB_PATH:-}" ] && echo "$bin_dir" >> "$GITHUB_PATH"
"$bin_dir/homecloud" version
echo "::endgroup::"
# Tests of the download and verification stop here (test.yml).
[ "${HC_INSTALL_ONLY:-}" = 1 ] && exit 0

echo "Starting homecloud serve on 127.0.0.1:$port (data: $data_dir, log: $log)"
mkdir -p "$data_dir"
Expand Down
Loading
Loading