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 .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
# Pinned to an immutable core commit SHA, not @main: a mutable ref lets an
# upstream change alter this repo's gate — which runs with this repo's token
# — with no commit here. Bump deliberately alongside the go.mod core version.
uses: codefly-dev/core/.github/workflows/go-service-ci.yml@29bc3dbdda86cc5b3432f17c249bc49301a2edad
uses: codefly-dev/core/.github/workflows/go-service-ci.yml@ea49e7a968ef5f2fd325b1c41f823a47eb20141c # v0.18.1

# The shared go-service-ci runs `go test` without the race detector; this
# repo's events hub, Watch streams and readiness monitor are concurrent, so
Expand Down
60 changes: 7 additions & 53 deletions .github/workflows/publish-gateway-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,61 +17,15 @@ on:
required: true
type: string

env:
IMAGE: ghcr.io/codefly-dev/service-object-storage

jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Validate the upcoming agent version
id: version
env:
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
if [[ ! "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
echo "version must be a stable semantic version without a v prefix" >&2
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
# The version label is how the release tells this image apart from the one
# published for an earlier version: both are digests in the same
# repository, and a lock left unrefreshed would otherwise publish the
# previous release's bytes under the new tag.
- uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
id: build
with:
context: .
platforms: linux/amd64,linux/arm64
labels: org.opencontainers.image.version=${{ steps.version.outputs.version }}
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
- name: Record the digest
env:
DIGEST: ${{ steps.build.outputs.digest }}
run: |
set -euo pipefail
mkdir -p release-evidence
jq -n --arg name "$IMAGE" --arg digest "$DIGEST" '{name: $name, digest: $digest}' > release-evidence/gateway-image.json
{
echo 'Commit this as `gateway-image.json`, then tag the release:'
echo
echo '```json'
cat release-evidence/gateway-image.json
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: gateway-image-lock-${{ steps.version.outputs.version }}
path: release-evidence/gateway-image.json
if-no-files-found: error
uses: codefly-dev/core/.github/workflows/publish-service-image.yml@ea49e7a968ef5f2fd325b1c41f823a47eb20141c # v0.18.1
with:
version: ${{ inputs.version }}
lock-file: gateway-image.json
platforms: linux/amd64,linux/arm64
secrets:
registry-token: ${{ secrets.GITHUB_TOKEN }}
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -143,5 +143,5 @@ jobs:
# carries secrets: inherit, so a mutable ref would hand an upstream change
# this repo's GH_PAT with no commit here — the risk ci.yml pins against,
# on the one call that actually inherits the token. Bump deliberately.
uses: codefly-dev/core/.github/workflows/go-service-release.yml@08e37db5962cd98ddf77f8fb7dffe810a7ca1fc9
uses: codefly-dev/core/.github/workflows/go-service-release.yml@ea49e7a968ef5f2fd325b1c41f823a47eb20141c # v0.18.1
secrets: inherit
35 changes: 28 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,22 @@ and the local/deployed profiles. This file is how to work in the repo.

- The contract and its stubs: `proto/codefly/storage/v0`, `gen/`.
- The gateway server: `cmd/service-object-storage` and `internal/**` — the
backends, the per-replica Watch event hub, caller auth, the readiness probe,
backends, the per-replica Watch event hub, auth configuration, the readiness probe,
and the normalization of backend errors to gRPC codes.
- The codefly agent that runs the gateway as a first-class service: the repo
root (`main.go`, `runtime.go`, `builder.go`, `agent.codefly.yaml`) and
`templates/`, the Kubernetes manifests the Builder renders.
- SBOM evidence for the image it ships: `cmd/image-sbom`,
`internal/imageevidence`.
- The evidence command and image declaration: `cmd/image-sbom` and
`internal/imageevidence`. Core owns subject derivation, scanning and indexing.

## What it does not own

- The CLI and the agent framework — `codefly-dev/cli`, and `codefly-dev/core`,
which is both a `go.mod` dependency and the home of the shared CI workflows
this repo calls (pinned here by commit SHA, never `@main`).
this repo calls (pinned here by commit SHA, never `@main`). Core also
owns `callertoken`, the gateway image-lock parser and shared image evidence.
Do not restore service-local copies. Pin dependencies with
`codefly agent deps --pin <version>` so every owned lock is checked.
- The backing stores. Behaviour differences between MinIO, S3, GCS and Azure
are absorbed in `internal/backend/`, but a broken store is not a bug here.
- Consumers. They declare a `service-dependency` and dial the endpoint; nothing
Expand Down Expand Up @@ -109,14 +112,32 @@ one workflow, in that order. The tag must match `version:` in
that file resolves the *previous* image, which exists, so nothing fails at run
time and the evidence describes an image the agent never runs. The shipped
platform list lives in `internal/imageevidence` and a test asserts it against
the release workflow.
the inputs to core's reusable image publisher. Core's agent-release workflow
accepts only pushed tags whose commits are reachable from the default branch.

Prepare the gateway before publishing the agent: use `codefly publish --dry-run`
to determine the next version, then dispatch `publish-gateway-image.yml` with
that explicit `version` (without `v`). Adopt its `gateway-image-lock-<version>`
that explicit `version` (without `v`). Adopt its `gateway-image.json-<version>`
artifact as `gateway-image.json` in a reviewed PR. Only then run normal
`codefly publish`; the tag gate still checks the image label against the bumped
agent version and refuses a stale lock.
agent version and refuses a stale lock. The reusable publisher checks out with
`persist-credentials: false` and refuses a commit outside the default branch's
history. It changes no release tag or lock in this repository.

The gateway lock accepts only its expected registry/name, a lowercase SHA-256
digest, and the two known JSON keys; an empty digest means unpublished. MinIO's
source metadata has its own schema but shares core's digest validation. Registry
evidence rejects bare tags and declares one subject per shipped platform; local
evidence reads its image ID and actual platform from Docker.

Caller tokens are 64 hex characters, also used for generated MinIO passwords
and custody IDs. Only exact health Check/Watch RPCs bypass the token; Health/List
requires it. An empty interceptor token fails closed, while the gateway's
explicit anonymous profile continues to install no interceptors.

For an agent checkout, run `codefly agent ci` for source, release builds and
fresh generated-service conformance. `codefly ci run` needs a service workspace
and reports `no workspace found` at this repository root.

## How to behave when something does not work

Expand Down
54 changes: 41 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,18 @@ With `SOS_AUTH_TOKEN` set, every RPC — unary and streaming, `Capabilities`
included — must carry the secret as the `x-codefly-token` gRPC metadata header,
the same key the codefly host already uses to authenticate agent plugins.
Anything else is rejected with `UNAUTHENTICATED` before it reaches a backend.
The token is compared in constant time.
Core's `callertoken` package compares the token in constant time.
Only the exact `/grpc.health.v1.Health/Check` and
`/grpc.health.v1.Health/Watch` RPCs are exempt; `Health/List` requires a token.
Generated tokens are 64 lowercase hex characters (32 random bytes).
Operator-provided tokens retain their configured value. The same generator
supplies local MinIO passwords and custody identifiers.

An empty token passed to a core interceptor fails closed. The gateway installs
interceptors only when `SOS_AUTH_TOKEN` is nonempty: the explicit anonymous
profile continues to install none. Core's `CheckListener` preserves the refusal
of both or neither auth settings; its errors name the configuration and exposure,
while deployment and migration guidance lives here.

Which layer enforces caller identity, per profile:

Expand Down Expand Up @@ -322,10 +333,21 @@ The agent runs that image by digest, not by tag: `gateway-image.json` records
the digest and the agent pins it, so the container a release runs — and reports
evidence for — is the one that was built for it rather than whatever the tag
serves now. The digest only exists once the image is built, so the image is
published ahead of the release tag by `publish-gateway-image.yml`; commit the
digest it prints, then tag. `release.yml` creates the `:<version>` and `:latest`
tags from that digest and refuses one that was not built for the version being
released.
published ahead of the release tag by `publish-gateway-image.yml`. That caller
uses core's `publish-service-image.yml`, pinned by commit SHA, with the
upcoming stable version. It checks out without persisting credentials and
refuses a commit not reachable from this repository's default branch. Adopt
its `gateway-image.json-<version>` artifact in a reviewed PR before the
coordinator runs `codefly publish`; never hand-tag. `release.yml` creates the
`:<version>` and `:latest` image tags from that digest and refuses one that was
not built for the version being released.

Core's `resources.ParseImageLock` reads the gateway lock. It requires exactly
`ghcr.io/codefly-dev/service-object-storage` and `sha256:` followed by 64
lowercase hexadecimal characters, refuses unknown fields (including `tag`) and
trailing JSON documents, and reports an empty digest as `ErrImageUnpublished`.
MinIO retains its separate source/release/revision lock schema and uses core's
same lowercase SHA-256 validation.

## Supply chain evidence

Expand All @@ -347,16 +369,20 @@ claim coverage of something the deployed service does not run.
`SOS_GATEWAY_IMAGE` is honored. An override is a local build no registry serves,
so it is inventoried through the Docker daemon — which needs `syft` on PATH —
and covers the single platform the daemon holds. Its subject carries the image
ID the daemon resolved the override to: the daemon binds a scan to that ID, so
an override whose reference the daemon does not hold is refused rather than
inventoried under an identity nothing named.
ID and actual platform the daemon resolved the override to. Core scans that
immutable ID even if a local tag moves after inspection; caller-declared local
platforms are refused. An override whose reference the daemon does not hold is
refused rather than inventoried under an identity nothing named.

A failed scan, an unresolvable image, or a digest other than the one requested is
reported as an error, never as partial or complete coverage.

`cmd/image-sbom` writes that evidence to disk through the same scanner the agent
uses, so a published document is the agent's own output rather than a second,
drifting copy of it:
uses: core's `ImageSubjects` derives registry subjects through
`ExpectedFromImageReference` (which refuses bare tags), and
`CollectImageEvidence` / `WriteImageEvidenceIndex` collect and index them.
The Builder and command both validate explicit expected digests through core's
`ImageForSubject` path:

```bash
# the published multi-architecture image: one document per shipped platform
Expand All @@ -372,8 +398,11 @@ so it still names the image it describes once separated from the directory it
was written in. Every release runs the same command and publishes the documents
and an `index.txt` naming each digest and platform, as build artifacts of
`.github/workflows/release.yml`. The platform list lives only in
`internal/imageevidence`, and a test asserts it against the build step that
publishes the image.
`internal/imageevidence`, which now contains only the service declaration and
calls core. A test asserts the platforms against the reusable publisher's
inputs. Document filenames include repository, platform and digest; each index
row contains platform (`-` if unstated), repository plus scanned digest, filename
and canonical inventory checksum (not the checksum of the formatted file).

## Develop

Expand All @@ -400,7 +429,6 @@ proto/codefly/storage/v0/ the uniform API
gen/ generated gRPC stubs
internal/backend/ Backend interface + s3, gcs, azure, minio, mem
internal/events/ in-process write-event hub behind Watch
internal/auth/ caller authentication on the gRPC surface
internal/server/ gRPC ObjectStorage implementation
internal/config/ env configuration
cmd/service-object-storage/ the server binary
Expand Down
6 changes: 4 additions & 2 deletions cmd/image-sbom/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (
"fmt"
"os"

"github.com/codefly-dev/core/agents/services/sbom"

"github.com/codefly-dev/service-object-storage/internal/imageevidence"
)

Expand All @@ -34,12 +36,12 @@ func main() {
fmt.Fprintf(os.Stderr, "image-sbom: %v\n", err)
os.Exit(1)
}
documents, err := imageevidence.Collect(ctx, *out, subjects)
documents, err := sbom.CollectImageEvidence(ctx, *out, subjects)
if err != nil {
fmt.Fprintf(os.Stderr, "image-sbom: %v\n", err)
os.Exit(1)
}
if err := imageevidence.WriteIndex(*out, documents); err != nil {
if err := sbom.WriteImageEvidenceIndex(*out, documents); err != nil {
fmt.Fprintf(os.Stderr, "image-sbom: %v\n", err)
os.Exit(1)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
package auth_test
package main

import (
"context"
grpchealth "google.golang.org/grpc/health"
healthv1 "google.golang.org/grpc/health/grpc_health_v1"
"io"
"net"
"testing"
Expand All @@ -13,11 +11,13 @@ import (
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
grpchealth "google.golang.org/grpc/health"
healthv1 "google.golang.org/grpc/health/grpc_health_v1"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"

"github.com/codefly-dev/core/callertoken"
storagev0 "github.com/codefly-dev/service-object-storage/gen/codefly/storage/v0"
"github.com/codefly-dev/service-object-storage/internal/auth"
"github.com/codefly-dev/service-object-storage/internal/backend"
"github.com/codefly-dev/service-object-storage/internal/backend/mem"
"github.com/codefly-dev/service-object-storage/internal/events"
Expand Down Expand Up @@ -106,8 +106,8 @@ func startGateway(t *testing.T) string {
hub := events.NewHub()

s := grpc.NewServer(
grpc.ChainUnaryInterceptor(auth.UnaryInterceptor(serverToken)),
grpc.ChainStreamInterceptor(auth.StreamInterceptor(serverToken)),
grpc.ChainUnaryInterceptor(callertoken.UnaryServerInterceptor(serverToken)),
grpc.ChainStreamInterceptor(callertoken.StreamServerInterceptor(serverToken)),
)
storagev0.RegisterObjectStorageServer(s, server.New(be, hub, probetest.Monitor(t, be)))
healthv1.RegisterHealthServer(s, grpchealth.NewServer())
Expand All @@ -132,9 +132,9 @@ func TestUnauthenticatedPeerIsRefused(t *testing.T) {

peers := map[string]storagev0.ObjectStorageClient{
"no credentials": dial(t, address),
"wrong token": dial(t, address, auth.DialOption("00000000000000000000000000000000")),
"prefix of token": dial(t, address, auth.DialOption(serverToken[:len(serverToken)-1])),
"empty token": dial(t, address, auth.DialOption("")),
"wrong token": dial(t, address, callertoken.DialOption("00000000000000000000000000000000")),
"prefix of token": dial(t, address, callertoken.DialOption(serverToken[:len(serverToken)-1])),
"empty token": dial(t, address, callertoken.DialOption("")),
}

for peerName, client := range peers {
Expand All @@ -156,7 +156,7 @@ func TestUnauthenticatedPeerIsRefused(t *testing.T) {
// Unimplemented) still proves the interceptor let the call through; Watch has
// nothing to deliver, so it only has to not be refused before the deadline.
func TestAuthorizedPeerReachesTheHandler(t *testing.T) {
client := dial(t, startGateway(t), auth.DialOption(serverToken))
client := dial(t, startGateway(t), callertoken.DialOption(serverToken))

for rpcName, call := range rpcs {
t.Run(rpcName, func(t *testing.T) {
Expand All @@ -172,9 +172,9 @@ func TestAuthorizedPeerReachesTheHandler(t *testing.T) {
// the same metadata key the codefly host uses for agent plugins, so a caller
// built against that convention interoperates.
func TestTokenTravelsUnderTheEcosystemKey(t *testing.T) {
require.Equal(t, "x-codefly-token", auth.MetadataKey)
require.Equal(t, "x-codefly-token", callertoken.MetadataKey)

client := dial(t, startGateway(t), grpc.WithUnaryInterceptor(withMetadata(auth.MetadataKey, serverToken)))
client := dial(t, startGateway(t), grpc.WithUnaryInterceptor(withMetadata(callertoken.MetadataKey, serverToken)))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := client.Capabilities(ctx, &storagev0.CapabilitiesRequest{})
Expand All @@ -200,8 +200,8 @@ func withMetadata(key, value string) grpc.UnaryClientInterceptor {
}
}

// TestHealthNeedsNoToken pins the one exemption: the standard gRPC health
// service answers a peer that presents no token. A readiness probe — the
// TestHealthNeedsNoToken pins the exact exemptions: health Check and Watch
// answer a peer that presents no token. A readiness probe — the
// Codefly CLI's, a load balancer's, a mesh's — carries none, and a guarded
// health service made the gateway look permanently unready to all of them
// (document-store's integration graph never came up under Codefly 0.1.161,
Expand All @@ -223,4 +223,9 @@ func TestHealthNeedsNoToken(t *testing.T) {
first, err := stream.Recv()
require.NoError(t, err)
require.Equal(t, healthv1.HealthCheckResponse_SERVING, first.GetStatus())

_, err = healthv1.NewHealthClient(conn).List(ctx, &healthv1.HealthListRequest{})
require.Equal(t, codes.Unauthenticated, status.Code(err))
_, err = healthv1.NewHealthClient(conn).List(metadata.AppendToOutgoingContext(ctx, callertoken.MetadataKey, serverToken), &healthv1.HealthListRequest{})
require.NoError(t, err)
}
6 changes: 3 additions & 3 deletions cmd/service-object-storage/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ import (
grpchealth "google.golang.org/grpc/health"
healthv1 "google.golang.org/grpc/health/grpc_health_v1"

"github.com/codefly-dev/core/callertoken"
storagev0 "github.com/codefly-dev/service-object-storage/gen/codefly/storage/v0"
"github.com/codefly-dev/service-object-storage/internal/auth"
"github.com/codefly-dev/service-object-storage/internal/backend"
"github.com/codefly-dev/service-object-storage/internal/config"
"github.com/codefly-dev/service-object-storage/internal/events"
Expand Down Expand Up @@ -73,8 +73,8 @@ func run() error {
if cfg.AuthToken != "" {
authMode = "token"
options = append(options,
grpc.ChainUnaryInterceptor(auth.UnaryInterceptor(cfg.AuthToken)),
grpc.ChainStreamInterceptor(auth.StreamInterceptor(cfg.AuthToken)),
grpc.ChainUnaryInterceptor(callertoken.UnaryServerInterceptor(cfg.AuthToken)),
grpc.ChainStreamInterceptor(callertoken.StreamServerInterceptor(cfg.AuthToken)),
)
}

Expand Down
Loading
Loading