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
17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<a href="https://github.com/predatorray/candybox/actions/workflows/ci.yml"><img alt="Build" src="https://img.shields.io/github/actions/workflow/status/predatorray/candybox/ci.yml?branch=main"></a>
<a href="https://github.com/predatorray/candybox/actions/workflows/docker-publish.yml"><img alt="Docker" src="https://img.shields.io/github/actions/workflow/status/predatorray/candybox/docker-publish.yml?branch=main&label=deploy"></a>
<a href="https://codecov.io/github/predatorray/candybox" ><img src="https://codecov.io/github/predatorray/candybox/graph/badge.svg?token=L1FZZY3569"/></a>
<a href="compat/s3-tests/README.md"><img alt="S3 compatibility" src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/predatorray/candybox/main/compat/s3-tests/badge.json"></a>
</p>

---
Expand Down Expand Up @@ -77,12 +78,14 @@ operators at the right command.
The gateway's S3 compatibility is verified against the industry-standard
[`ceph/s3-tests`](https://github.com/ceph/s3-tests) suite — see
[`compat/s3-tests/`](compat/s3-tests/) (`compat/s3-tests/run.sh --calibrate` against the running
gateway). The latest calibration was taken after the Phase 5 additions (multipart upload, Range
GET) landed: **164 / 838 boto3 functional tests pass** (up from 149 pre-Phase-5). The remaining
gaps the v1 gateway does not yet implement are versioning, ACLs, SSE, POST object, lifecycle,
CORS — see
[`compat/s3-tests/README.md`](compat/s3-tests/README.md#latest-calibration) for the family-by-family
breakdown.
gateway). The latest calibration (the **S3 compatibility** badge above tracks it automatically) runs
the gateway with **SigV4 auth + S3 ACLs enabled**: **192 / 838 boto3 functional tests pass** (up from
164 pre-auth, and 149 pre-Phase-5), zero suite errors. The extra passes are the multi-user / ACL /
cross-account-access tests that real authentication unlocks (`bucket_acl_*`, `object_acl_*`,
`access_bucket_*`, anonymous-access and bad-auth checks). The remaining gaps the v1 gateway does not
yet implement are versioning, SSE, POST object, lifecycle, bucket policy, CORS, and conditional GET —
see [`compat/s3-tests/README.md`](compat/s3-tests/README.md#latest-calibration) for the
family-by-family breakdown.

## Storing and retrieving objects

Expand Down Expand Up @@ -289,7 +292,7 @@ hard fencing/handover scenarios. No mocking frameworks are used anywhere.
| `candybox-protocol` | The framed TCP wire protocol and transport. |
| `candybox-server` | The storage node: wires the engine behind the protocol, plus the runnable entrypoint, health/metrics, and ownership. |
| `candybox-client` | The thin client library and the `candybox` command-line tool. |
| `candybox-s3-gateway` | An anonymous, path-style, S3-compatible HTTP gateway (Netty) that translates the S3 REST/XML API onto the client. Stateless; runs behind an HTTP(S) load balancer. See `S3_GATEWAY_PLAN.md`. |
| `candybox-s3-gateway` | A path-style, S3-compatible HTTP gateway (Netty) with optional SigV4 auth + S3 ACL enforcement that translates the S3 REST/XML API onto the client. Stateless; runs behind an HTTP(S) load balancer. See `S3_GATEWAY_PLAN.md`. |
| `candybox-admin-api` | A stateless HTTP service exposing cluster / boxes / LSM / metrics as JSON, plus the static SPA mount. See `WEB_DASHBOARD_PLAN.md`. |
| `candybox-web` | React + TypeScript + MUI dashboard, built by `frontend-maven-plugin` under `-Pfrontend` and packaged into a jar so the admin API serves it from the classpath. |
| `candybox-dist` | Packages the runnable distribution (`bin/ lib/ conf/`) and the Docker/Kubernetes assets. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,25 @@ public final class EmbeddedBookKeeper implements AutoCloseable {
private final LocalBookKeeper localBookKeeper;

public EmbeddedBookKeeper(int numBookies) {
this(numBookies, 0);
}

/**
* @param numBookies bookies to start in-JVM
* @param openFileLimit max open ledger-index files per bookie ({@code <= 0} keeps the BookKeeper
* default). Bounding this keeps the bookie's file-descriptor footprint flat
* when a workload churns through many ledgers, which matters in sandboxes
* with a low {@code RLIMIT_NOFILE}.
*/
public EmbeddedBookKeeper(int numBookies, int openFileLimit) {
this.zkPort = freePort();
try {
ServerConfiguration serverConf = new ServerConfiguration();
serverConf.setMetadataServiceUri(metadataServiceUri());
serverConf.setAllowLoopback(true);
if (openFileLimit > 0) {
serverConf.setOpenFileLimit(openFileLimit);
}
// getLocalBookies(zkHost, zkPort, numBookies, shouldStartZK, conf) stands up the in-JVM
// ZooKeeper and the bookies; start() launches them.
this.localBookKeeper =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/*
* Copyright (c) 2026 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package me.predatorray.candybox.it;

import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.Properties;
import me.predatorray.candybox.bookkeeper.bk.BookKeeperLedgerStore;
import me.predatorray.candybox.client.CandyboxClient;
import me.predatorray.candybox.common.SystemClock;
import me.predatorray.candybox.common.config.CandyboxConfig;
import me.predatorray.candybox.common.config.LedgerRole;
import me.predatorray.candybox.common.config.QuorumConfig;
import me.predatorray.candybox.coordination.zk.ZooKeeperCoordinationService;
import me.predatorray.candybox.protocol.FrameCodec;
import me.predatorray.candybox.protocol.transport.TcpTransport;
import me.predatorray.candybox.protocol.transport.TcpTransportServer;
import me.predatorray.candybox.s3.S3Gateway;
import me.predatorray.candybox.s3.S3GatewayConfig;
import me.predatorray.candybox.server.CandyboxNode;
import org.apache.curator.test.TestingServer;

/**
* A throwaway, long-lived launcher that stands up the full Candybox stack in one JVM — embedded
* BookKeeper, in-process ZooKeeper, a real node, and the Netty S3 gateway — bound to a fixed port so
* the {@code ceph/s3-tests} compatibility suite ({@code compat/s3-tests/run.sh}) can be pointed at it
* <em>without Docker</em>. This mirrors what {@code docker-compose.ci.yml} brings up (SigV4 auth + ACL
* enforcement against the s3tests credentials) and exists only to run a calibration when the Docker
* Hub image path is unavailable. It is not a unit/integration test; it blocks until killed.
*
* <pre>
* mvn -q -pl candybox-integration-tests -Dexec.classpathScope=test \
* -Dexec.mainClass=me.predatorray.candybox.it.S3CompatGatewayLauncher \
* -Dexec.args="9711 /abs/path/to/gateway-credentials.properties" \
* org.codehaus.mojo:exec-maven-plugin:3.5.0:java
* </pre>
*/
public final class S3CompatGatewayLauncher {

private S3CompatGatewayLauncher() {
}

public static void main(String[] args) throws Exception {
int port = args.length > 0 ? Integer.parseInt(args[0]) : 9711;
String credentialsFile = args.length > 1 ? args[1] : null;

// One bookie with 1/1/1 quorum and a bounded index-file cache: replication factor is
// irrelevant to S3-API compatibility, and a single bookie keeps the file-descriptor
// footprint flat as the suite churns through thousands of ledgers (sandbox RLIMIT_NOFILE
// is only 4096; the default 3 bookies × one .idx-per-ledger exhausts it mid-suite).
EmbeddedBookKeeper bookKeeper = new EmbeddedBookKeeper(1, 1024);
TestingServer zookeeper = new TestingServer(true);

QuorumConfig single = new QuorumConfig(1, 1, 1);
CandyboxConfig.Builder configBuilder = CandyboxConfig.builder();
for (LedgerRole role : LedgerRole.values()) {
configBuilder.quorum(role, single);
}
CandyboxConfig config = configBuilder.build();
BookKeeperLedgerStore store = new BookKeeperLedgerStore(
bookKeeper.clientConfiguration(), "candybox".getBytes(StandardCharsets.UTF_8));
ZooKeeperCoordinationService nodeCoord =
new ZooKeeperCoordinationService(zookeeper.getConnectString(), SystemClock.INSTANCE);
int nodePort = 9709;
CandyboxNode node = new CandyboxNode(
1, config, store, nodeCoord, SystemClock.INSTANCE, "127.0.0.1:" + nodePort);
TcpTransportServer transportServer =
new TcpTransportServer(nodePort, node.requestHandler(), new FrameCodec());

TcpTransport transport = new TcpTransport();
ZooKeeperCoordinationService clientCoord =
new ZooKeeperCoordinationService(zookeeper.getConnectString(), SystemClock.INSTANCE);
CandyboxClient client = new CandyboxClient(transport, clientCoord, config);

Properties props = new Properties();
props.setProperty("zookeeper.connect", zookeeper.getConnectString());
props.setProperty("s3.bind", "127.0.0.1:" + port);
// Mirror docker-compose.ci.yml: real SigV4 + ACL enforcement. allow-anonymous keeps its
// default (true) there, so unsigned requests fall through to the anonymous principal.
if (credentialsFile != null) {
props.setProperty("s3.auth.enabled", "true");
props.setProperty("auth.credentials.file", credentialsFile);
}
S3Gateway gateway = new S3Gateway(S3GatewayConfig.fromProperties(props, Map.of()), client);
gateway.start();

System.out.println("[launcher] S3 gateway up on 127.0.0.1:" + gateway.port()
+ " (auth=" + (credentialsFile != null) + ")");
System.out.flush();

Runtime.getRuntime().addShutdownHook(new Thread(() -> {
close(gateway);
close(client);
close(transport);
close(transportServer);
close(node);
close(nodeCoord);
close(clientCoord);
close(store);
close(zookeeper);
close(bookKeeper);
}));

Thread.currentThread().join();
}

private static void close(AutoCloseable c) {
if (c == null) {
return;
}
try {
c.close();
} catch (Exception ignored) {
// best effort
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,14 @@ void authorize(S3Action action, S3Handler.PathParts parts, Principal principal)
}
}

/** True when {@code principal} may READ the Box (for ListBuckets filtering). */
boolean mayRead(String box, Principal principal) {
return !enabled || authorizer.authorize(principal, Operation.READ, Resource.box(box));
/** True when {@code principal} owns the Box. {@code ListAllMyBuckets} lists owned buckets only —
* never buckets merely granted READ (e.g. a {@code public-read} bucket owned by someone else),
* matching AWS/RGW, so one account's public bucket can't leak into another account's listing. */
boolean owns(String box, Principal principal) {
if (!enabled) {
return true;
}
return boxAcl(box).map(acl -> acl.owner().equals(principal)).orElse(false);
}

private void require(Principal principal, Operation operation, Resource resource,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,11 @@ private void dispatch(ChannelHandlerContext ctx, FullHttpRequest request, S3Acti
Principal principal = auth.principal();
switch (action) {
case LIST_BUCKETS -> {
// Anonymous sees an empty list (RGW-compatible); others see their READable Boxes.
// Anonymous sees an empty list (RGW-compatible); others see only the Boxes they own
// (ListAllMyBuckets is ownership-scoped, not READ-scoped).
List<String> visible = principal.isAnonymous() && access.enabled() ? List.of()
: store.listBoxes().stream()
.filter(box -> access.mayRead(box, principal)).toList();
.filter(box -> access.owns(box, principal)).toList();
sendXml(ctx, request, HttpResponseStatus.OK, S3Xml.listAllMyBuckets(visible),
requestId);
}
Expand Down
Loading
Loading