# hopper

`hopper` is a small command-line tool that deploys content-hashed web assets
from a gzipped tarball to [Bunny CDN Storage](https://bunny.net/storage/), and
prunes obsolete files from a storage zone.



## How hopper Thinks About Your Assets

Modern site builders produce content-hashed asset filenames. The name embeds
a hash of the file's contents, e.g. `assets/app.a1b2c3.js`. Because the hash
changes whenever the bytes change, a given filename always maps to exactly the
same bytes forever. `hopper` calls these files *immutable assets*.

The exception is *HTML files* (e.g. `index.html`, `404.html`). They are the
*entry points* that reference the immutable assets, they keep the same name
across releases, and their contents change on every deploy. `hopper` calls
these *mutable assets*.

`hopper` categorizes every `*.html` file (case-insensitive) as a *mutable
asset* and every other file as an *immutable asset*. To save bandwidth,
immutable assets are uploaded only if the zone does not contain this asset
already or the sizes differ. Mutable assets, on the other hand, are always
uploaded, and uploaded only after all immutable assets succeeded. This ensures
that an application stays in a consistent state when an immutable asset upload
fails. Re-running the upload simply resumes.

Because uploading accumulates files on the storage zone, `hopper` can prune old
files. Immutable assets that are not touched for a very long time still keep
their old modification date, so pruning files only on their age would be
dangerous. Instead pruning deletes only old files which are not in the current
release.



## Installation

`hopper` is distributed as source and built with the Go toolchain, which
compiles into a single static binary. To install `hopper` clone the repository
and run `make install` in its root. This installs the binary to
`$PREFIX/bin/hopper` (`$PREFIX` defaults to `/usr/local`).



## Quickstart

Deploy a release archive to a Bunny storage zone:

```sh
export BUNNY_ZONE=my-zone
export BUNNY_ACCESS_KEY=your-storage-access-key

hopper push dist.tar.gz
```

Later, clean up files from old releases that are no longer referenced and have
aged past the grace window (default: 30 days):

```sh
hopper prune dist.tar.gz
# or define a custom grace window
hopper prune --older-than=90d dist.tar.gz
```

Always preview a destructive run first:

```sh
hopper prune --dryrun dist.tar.gz
```



## Usage

```
hopper [global flags] ⟨subcommand⟩ [flags] ⟨args⟩
```

`hopper` has exactly two subcommands: `push` and `prune`. The position of the
global flags are important. They must be before any subcommand.


### Global Flags

Global flags must be defined before any subcommand. Some flags also have a
fallback to an environment variable, so command line arguments always overwrite
the environment variables. An empty environment variable is treated as unset.

| Flag                | Env Fallback            | Default                  | Meaning                                           |
|---------------------|-------------------------|--------------------------|---------------------------------------------------|
| `--zone`            | `BUNNY_ZONE`            | *(none)*                 | The Bunny storage zone name.                      |
| `--endpoint`        | `BUNNY_ENDPOINT`        | `storage.bunnycdn.com`   | The Bunny storage endpoint hostname.              |
| `--access-key-file` | `BUNNY_ACCESS_KEY_FILE` | *(none)*                 | Path to a file containing the storage access key. |
| `--verbose`         | *(none)*                | `false`                  | Extra detail (listing walk, retries, checksums).  |
| `--quiet`           | *(none)*                | `false`                  | Print only the final summary and errors.          |

*Note:* The access key can also be provided directly using the `BUNNY_ACCESS_KEY`
environment variable. Both, `BUNNY_ACCESS_KEY_FILE` and `--access-key-file` can
overwrite this access key.


### Subcommand `push`

```
hopper [global flags] push  [flags] ⟨source⟩
```

Uploads a new release to the storage zone. It iterates over all files of the
`⟨source⟩`, uploads all new immutable assets, and then uploads all mutable
assets. After successfully uploading the assets, `hopper` checks the ratio of
the number of files contained in the storage zone to the number of files in
the given source. If this ratio becomes too large, a hint will be printed
suggesting to run `hopper prune`.

*Note:* An empty source uploads nothing and prints a warning. It is not
considered an error (i.e. it exits with code 0), because the operation is
non-destructive.

#### Flags
| Flag            | Meaning                                                                                           |
|-----------------|---------------------------------------------------------------------------------------------------|
| `--concurrency` | The number of concurrent uploads. By default it auto-detects a value based on the number of CPUs. |
| `--prefix`      | A path prefix in the storage zone. Every asset is uploaded into this directory. Default: none.    |
| `--root`        | The subtree of the source that should be uploaded. Default: All source files.                     |
| `--dryrun`      | Run the command without any mutating operations. This flag can be used to verify the upload.      |


### Subcommand `prune`

```
hopper [global flags] prune [flags] ⟨args⟩
```

Deletes obsolete files. It builds the *live set* from the given source and a
view of the storage zone. It then deletes every remote file that is not in the
live set and whose last modification is older than a certain grace window.
Finally, it removes all empty directories it can find (failures here do not
affect the exit code). All deletes are independent. If one fails, `hopper`
keeps going deleting all other obsolete files.

*Note:* If the live set is empty, the prune operation refuses to run, because
it could delete the entire zone. In this case, please check your `--root` flag
and the source.

#### Flags
| Flag            | Meaning                                                                                                                  |
|-----------------|--------------------------------------------------------------------------------------------------------------------------|
| `--concurrency` | The number of concurrent deletes. By default it auto-detects a value based on the number of CPUs.                        |
| `--prefix`      | A path prefix in the storage zone. Only assets in this directory are deleted. Default: none.                             |
| `--root`        | The subtree of the source that defines the live set. Default: All source files.                                          |
| `--older-than`  | The grace window that is used to determine obsolete files. Only files older than this will be deleted. Default: 30 days. |
| `--dryrun`      | Run the command without any mutating operations. This flag can be used to verify the deletes.                            |



## Retry Policy

If Bunny rate limits your requests, `hopper` receives a `429` and reports it.
In case the response contains the `Retry-After` header, `hopper` honors this
header and automatically retries up to three times. Every other HTTP failure
or a `429` without `Retry-After` fails immediately and reports an error message.



## License

MIT.
