Skip to content
Draft
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
71 changes: 71 additions & 0 deletions agents/manager/downloader.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,52 @@ func writeArchive(f *os.File, body io.Reader, size int64) (digest string, err er
return hex.EncodeToString(hash.Sum(nil)), nil
}

// releaseDigestFile is the sidecar Download writes beside an installed agent:
// the SHA-256 of the executable as it was installed from its release, in
// sha256sum's format. Its name begins with a dot so no scan of the cache
// directory — local latest, installed inventory, the CLI's version listing —
// reads it as an agent. Its presence says the executable came from a release
// through this downloader; its value is what Load holds the executable to.
func releaseDigestFile(executable string) string {
return filepath.Join(filepath.Dir(executable), "."+filepath.Base(executable)+".sha256")
}

// InstalledReleaseDigest reads the digest an agent was installed from its
// release with: the hex SHA-256 of its executable at install time. It is ""
// with no error when the agent is cached but was never installed from a release
// by this downloader — a local build, or an install older than the sidecar.
func InstalledReleaseDigest(ctx context.Context, p *resources.Agent) (string, error) {
bin, err := p.Path(ctx)
if err != nil {
return "", err
}
return readReleaseDigest(bin)
}

func readReleaseDigest(executable string) (string, error) {
raw, err := os.ReadFile(releaseDigestFile(executable))
if os.IsNotExist(err) {
return "", nil
}
if err != nil {
return "", err
}
entries, err := parseChecksums(raw)
if err != nil {
return "", fmt.Errorf("%s: %w", releaseDigestFile(executable), err)
}
digest, ok := entries[filepath.Base(executable)]
if !ok {
return "", fmt.Errorf("%s records no digest for %s", releaseDigestFile(executable), filepath.Base(executable))
}
return digest, nil
}

func writeReleaseDigest(executable, digest string) error {
line := fmt.Sprintf("%s %s\n", digest, filepath.Base(executable))
return os.WriteFile(releaseDigestFile(executable), []byte(line), 0o600)
}

func Download(ctx context.Context, p *resources.Agent) error {
w := wool.Get(ctx).In("agents.Download", wool.Field("agent", p.Identifier()))
registration, err := resources.AgentKindRegistrationFor(p.Kind)
Expand Down Expand Up @@ -187,10 +233,22 @@ func Download(ctx context.Context, p *resources.Agent) error {
if err := ctx.Err(); err != nil {
return err
}
binaryDigest, err := fileSHA256(binary)
if err != nil {
return w.Wrapf(err, "cannot digest the extracted agent")
}
// The sidecar is removed first so a failure between here and the install
// never leaves a digest beside an executable it does not describe.
if err := os.Remove(releaseDigestFile(target)); err != nil && !os.IsNotExist(err) {
return w.Wrapf(err, "cannot clear the previous install's digest")
}
err = CopyFile(binary, target)
if err != nil {
return w.Wrapf(err, "cannot install binary")
}
if err := writeReleaseDigest(target, binaryDigest); err != nil {
return w.Wrapf(err, "cannot record the installed agent's digest")
}
return nil
}

Expand All @@ -208,6 +266,19 @@ func checkArchiveDigest(identifier string, asset *ReleaseAsset, archiveDigest st
return "", nil
}

func fileSHA256(path string) (string, error) {
file, err := os.Open(path) //nolint:gosec // G304: path is the executable this downloader just extracted
if err != nil {
return "", err
}
defer func() { _ = file.Close() }()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return "", err
}
return hex.EncodeToString(hash.Sum(nil)), nil
}

// extractTarGz decompresses gzip then extracts the tar archive at src into
// dst. Replaces mholt/archiver.Unarchive (which had two unpatched path
// traversal CVEs). Only .tar.gz is supported — this is all GitHub release
Expand Down
79 changes: 79 additions & 0 deletions agents/manager/downloader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -374,3 +374,82 @@ func TestCheckArchiveDigestHoldsBytesToTheReleaseRecord(t *testing.T) {
require.NoError(t, err)
require.Contains(t, warning, "unverified")
}

// TestReleaseDigestSidecarIsInvisibleToCacheScans: the record Download leaves
// beside an executable must never read as an agent to anything listing the
// cache by name, and must round-trip the digest it records.
func TestReleaseDigestSidecarIsInvisibleToCacheScans(t *testing.T) {
ctx := t.Context()
t.Setenv(resources.CodeflyHomeEnv, t.TempDir())
agent := &resources.Agent{Kind: resources.ServiceAgent, Publisher: "example.test", Name: "widget", Version: "1.2.3"}
bin, err := agent.Path(ctx)
require.NoError(t, err)
require.NoError(t, os.MkdirAll(filepath.Dir(bin), 0o750))
require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755))

recorded, err := InstalledReleaseDigest(ctx, agent)
require.NoError(t, err)
require.Empty(t, recorded, "a cached executable with no record is reported as such, not as an error")

require.NoError(t, writeReleaseDigest(bin, strings.Repeat("a", 64)))
recorded, err = InstalledReleaseDigest(ctx, agent)
require.NoError(t, err)
require.Equal(t, strings.Repeat("a", 64), recorded)

latest := &resources.Agent{Kind: resources.ServiceAgent, Publisher: "example.test", Name: "widget"}
require.NoError(t, findLocalLatestInDir(filepath.Dir(bin), latest))
require.Equal(t, "1.2.3", latest.Version)
installed, err := Installed(ctx, resources.ServiceAgent)
require.NoError(t, err)
require.Len(t, installed, 1)
}

// TestAdmitCachedReleaseHoldsTheCacheToItsRecord covers the four states a cached
// executable can be in: installed from a release and unchanged; installed from
// a release and replaced since; never installed from a release under local
// resolution; and never installed from a release under remote resolution, where
// the release cannot be fetched.
func TestAdmitCachedReleaseHoldsTheCacheToItsRecord(t *testing.T) {
ctx := t.Context()
t.Setenv(resources.CodeflyHomeEnv, t.TempDir())
agent := &resources.Agent{Kind: resources.ServiceAgent, Publisher: "example.test", Name: "widget", Version: "1.2.3"}
bin, err := agent.Path(ctx)
require.NoError(t, err)
require.NoError(t, os.MkdirAll(filepath.Dir(bin), 0o750))
require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755))
digest, info, err := executableIdentity(ctx, bin)
require.NoError(t, err)

require.NoError(t, writeReleaseDigest(bin, strings.TrimPrefix(digest, "sha256:")))
got, _, err := admitCachedRelease(ctx, agent, bin, digest, info)
require.NoError(t, err)
require.Equal(t, digest, got)

require.NoError(t, writeReleaseDigest(bin, strings.Repeat("a", 64)))
_, _, err = admitCachedRelease(ctx, agent, bin, digest, info)
require.ErrorIs(t, err, ErrAgentAdmission)
require.ErrorContains(t, err, "installed from its release as sha256:"+strings.Repeat("a", 64))
require.ErrorContains(t, err, digest)

// A local rebuild over a released name leaves the release's record beside
// bytes it no longer describes. Under local resolution that is the build
// the developer asked for: admitted, and the stale record removed so a
// later remote load fetches the release instead of trusting the record.
t.Setenv(AgentSourceEnv, "local")
got, _, err = admitCachedRelease(ctx, agent, bin, digest, info)
require.NoError(t, err, "a local rebuild over a release is what CODEFLY_AGENT_SOURCE=local asks for, record or not")
require.Equal(t, digest, got)
require.NoFileExists(t, releaseDigestFile(bin), "the release record no longer describes these bytes")

_, _, err = admitCachedRelease(ctx, agent, bin, digest, info)
require.NoError(t, err, "a local build is what CODEFLY_AGENT_SOURCE=local asks for")

t.Setenv(AgentSourceEnv, "")
cancelled, cancel := context.WithCancel(ctx)
cancel()
_, _, err = admitCachedRelease(cancelled, agent, bin, digest, info)
require.ErrorIs(t, err, ErrAgentAdmission)
require.ErrorContains(t, err, "was not installed from a release and the release could not be fetched")
require.ErrorContains(t, err, "CODEFLY_AGENT_SOURCE=local")
require.FileExists(t, bin, "a refused cache entry is left for the operator to see, not deleted")
}
64 changes: 64 additions & 0 deletions agents/manager/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,7 @@ func Load(ctx context.Context, p *resources.Agent, opts ...LoadOption) (*AgentCo
}

// Resolve binary: local cache → Nix flake → OCI registry → GitHub release.
cachePath := bin
if _, err := exec.LookPath(bin); err != nil {
pulled := false
// Try Nix store first (AGENT_NIX_FLAKE env var). Content-addressed,
Expand Down Expand Up @@ -850,6 +851,12 @@ func Load(ctx context.Context, p *resources.Agent, opts ...LoadOption) (*AgentCo
if err != nil {
return nil, err
}
if bin == cachePath && resolvesFromGitHubRelease(p) && registration.AutoDownload {
artifactDigest, artifactInfo, err = admitCachedRelease(ctx, p, bin, artifactDigest, artifactInfo)
if err != nil {
return nil, err
}
}
var verifiedProvider *providerartifact.Verified
if registration.Resolution.Local == resources.AgentResolutionVerifiedArtifact {
verifiedProvider, err = providerartifact.VerifyExecutable(bin, p)
Expand All @@ -860,6 +867,63 @@ func Load(ctx context.Context, p *resources.Agent, opts ...LoadOption) (*AgentCo
return spawnAgent(ctx, bin, p.Unique(), string(p.Kind), artifactDigest, artifactInfo, verifiedProvider, cfg)
}

// admitCachedRelease holds an executable found in the agent cache to the digest
// it was installed from its release with.
//
// The cache is keyed by name alone — <name>__<version> — and nothing used to
// compare its bytes with the release that name stands for. So a build left
// under a released version's name, by a developer or by a CI run that installed
// what it built, was run by every process resolving that version, silently and
// with nothing in any output to tell it from the release. Download now records
// the installed digest beside the executable; this is where it is held to it.
//
// An executable with no record was not installed from a release by this
// downloader: a local build, or an install that predates the record. Under
// CODEFLY_AGENT_SOURCE=local that is what the developer asked for. Under remote
// resolution the name means the release, so the release is fetched over it; if
// it cannot be, the cached bytes are refused rather than run on trust, and the
// refusal names the way to run a local build deliberately.
func admitCachedRelease(ctx context.Context, p *resources.Agent, bin, digest string, info os.FileInfo) (string, os.FileInfo, error) {
w := wool.Get(ctx).In("manager.admitCachedRelease", wool.Field("agent", p.Identifier()))
recorded, err := readReleaseDigest(bin)
if err != nil {
return "", nil, fmt.Errorf("%w: %v", ErrAgentAdmission, err)
}
switch {
case recorded != "" && "sha256:"+recorded == digest:
return digest, info, nil
case AgentSourceLocal():
// A local build is what the developer asked for, whatever sits beside
// it. A record left by an earlier release install no longer describes
// these bytes, so it goes: the next remote load then sees an entry with
// no record and fetches the release, instead of trusting a stale one.
if recorded != "" {
if err := os.Remove(releaseDigestFile(bin)); err != nil {
return "", nil, fmt.Errorf("%w: cached agent %s was rebuilt locally over its release but the release record beside it cannot be removed: %v", ErrAgentAdmission, bin, err)
}
w.Debug("cached agent was rebuilt over its release; CODEFLY_AGENT_SOURCE=local admits it and the release record is removed", wool.Field("digest", digest), wool.Field("recorded", recorded))
return digest, info, nil
}
w.Debug("cached agent was not installed from a release; CODEFLY_AGENT_SOURCE=local admits it", wool.Field("digest", digest))
return digest, info, nil
case recorded != "":
return "", nil, fmt.Errorf("%w: cached agent %s is %s but was installed from its release as sha256:%s; something replaced it after install — delete it and it is downloaded again, or export %s=local to run a local build deliberately", ErrAgentAdmission, bin, digest, recorded, AgentSourceEnv)
}
w.Warn(fmt.Sprintf("cached agent %s (%s) was not installed from a release; fetching the release over it", p.Identifier(), digest))
if err := Download(ctx, p); err != nil {
return "", nil, fmt.Errorf("%w: cached agent %s (%s) was not installed from a release and the release could not be fetched to replace it: %v; export %s=local to run local builds deliberately", ErrAgentAdmission, bin, digest, err, AgentSourceEnv)
}
digest, info, err = executableIdentity(ctx, bin)
if err != nil {
return "", nil, err
}
recorded, err = readReleaseDigest(bin)
if err != nil || "sha256:"+recorded != digest {
return "", nil, fmt.Errorf("%w: agent %s was just installed from its release as sha256:%s but reads as %s", ErrAgentAdmission, bin, recorded, digest)
}
return digest, info, nil
}

func spawnAgent(ctx context.Context, bin, identity, telemetryName, artifactDigest string, artifactInfo os.FileInfo, verifiedProvider *providerartifact.Verified, cfg loadConfig) (*AgentConn, error) {
sweepStaleAgentSocketsOnce()
w := wool.Get(ctx).In("manager.Load", wool.Field("agent", identity))
Expand Down
Loading