Compare commits

..

7 Commits

Author SHA1 Message Date
2f5e75ae25
copy: no check sigs for chroot store 2025-07-29 22:59:42 +09:00
87382aebed
go: update dependencies 2025-07-29 04:07:08 +09:00
b298c5f572
nix: static linking via nix 2025-07-24 18:12:54 +09:00
4d9d4bcef2
exec: append custom store
This is primarily for chroot stores. This might not be useful however since chroot store breaks builds.
2025-07-23 12:09:32 +09:00
b6961508e8
store: local store
This just returns the verbatim string.
2025-07-23 11:49:23 +09:00
e0278a6d7d
copy: generalise store 2025-07-23 11:43:14 +09:00
e448541464
exec: increase default wait delay
Should avoid killing Nix command whenever possible.
2025-07-23 11:31:57 +09:00
15 changed files with 247 additions and 92 deletions

View File

@ -85,7 +85,7 @@ func TestBuildBadCommand(t *testing.T) {
wantErr := os.ErrNotExist wantErr := os.ErrNotExist
breakNixCommand(t) breakNixCommand(t)
if err := nix.Build( if err := nix.Build(
nix.New(t.Context(), nil, nil, nil), nix.New(t.Context(), nil, nil, nil, nil),
nil, nil,
); !errors.Is(err, wantErr) { ); !errors.Is(err, wantErr) {
t.Errorf("Build: error = %v, want %v", err, wantErr) t.Errorf("Build: error = %v, want %v", err, wantErr)

View File

@ -45,11 +45,14 @@ func main() {
} }
} }
var extraArgs []string var (
store nix.Store
extraArgs []string
)
flagStore = strings.TrimSpace(flagStore) flagStore = strings.TrimSpace(flagStore)
if flagStore != string(os.PathSeparator) { if flagStore != string(os.PathSeparator) {
store = nix.Local(flagStore)
extraArgs = append(extraArgs, extraArgs = append(extraArgs,
"--store", flagStore,
// do not use any binary cache // do not use any binary cache
nix.FlagOption, nix.OptionBuildUseSubstitutes, nix.ValueFalse, nix.FlagOption, nix.OptionBuildUseSubstitutes, nix.ValueFalse,
nix.FlagOption, nix.OptionSubstituters, "", nix.FlagOption, nix.OptionSubstituters, "",
@ -62,7 +65,7 @@ func main() {
if flagVerbose { if flagVerbose {
stderr = os.Stderr stderr = os.Stderr
} }
ctx = nix.New(nixCtx, extraArgs, os.Stdout, stderr) ctx = nix.New(nixCtx, store, extraArgs, os.Stdout, stderr)
return nil return nil
}). }).
@ -111,7 +114,7 @@ func main() {
} }
log.Println("copying to binary cache...") log.Println("copying to binary cache...")
if err := nix.Copy(ctx, flagCacheKeyPath, &nix.BinaryCache{ if err := nix.Copy(ctx, &nix.BinaryCache{
Compression: flagCacheComp, Compression: flagCacheComp,
ParallelCompression: flagCachePComp, ParallelCompression: flagCachePComp,
Bucket: flagCacheBucket, Bucket: flagCacheBucket,
@ -119,6 +122,7 @@ func main() {
Region: flagCacheRegion, Region: flagCacheRegion,
Scheme: flagCacheScheme, Scheme: flagCacheScheme,
CredentialsPath: flagCacheCredPath, CredentialsPath: flagCacheCredPath,
KeyPath: flagCacheKeyPath,
}, slices.Values(collective)); err != nil { }, slices.Values(collective)); err != nil {
return commandHandlerError(fmt.Sprintf("cannot copy: %v", err)) return commandHandlerError(fmt.Sprintf("cannot copy: %v", err))
} }

43
copy.go
View File

@ -2,43 +2,12 @@ package nix
import ( import (
"context" "context"
"fmt"
"iter" "iter"
"os" "os"
) )
const ( // Copy copies installables to the binary cache store.
EnvAwsSharedCredentialsFile = "AWS_SHARED_CREDENTIALS_FILE" func Copy(ctx Context, store Store, installables iter.Seq[string]) error {
)
// A BinaryCache holds credentials and parameters to a s3 binary cache.
type BinaryCache struct {
// Compression is the name of the compression algorithm to use. Example: "zstd".
Compression string `json:"compression"`
// ParallelCompression determines whether parallel compression is enabled.
ParallelCompression bool `json:"parallel_compression,omitempty"`
// Bucket is the s3 bucket name.
Bucket string `json:"bucket"`
// Endpoint is the s3 endpoint. Example: "s3.example.org".
Endpoint string `json:"endpoint,omitempty"`
// Region is the s3 region. Example: "ap-northeast-1".
Region string `json:"region"`
// Scheme is the s3 protocol. Example: "https".
Scheme string `json:"scheme"`
// CredentialsPath is the path to the s3 shared credentials file.
CredentialsPath string `json:"credentials_path"`
}
func (store *BinaryCache) String() string {
return fmt.Sprintf(
"s3://%s?compression=%s&parallel-compression=%t&region=%s&scheme=%s&endpoint=%s",
store.Bucket, store.Compression, store.ParallelCompression, store.Region, store.Scheme, store.Endpoint,
)
}
// Copy copies installables to the binary cache store, signing all paths using the key at keyPath.
func Copy(ctx Context, keyPath string, store *BinaryCache, installables iter.Seq[string]) error {
if store == nil { if store == nil {
return os.ErrInvalid return os.ErrInvalid
} }
@ -47,9 +16,13 @@ func Copy(ctx Context, keyPath string, store *BinaryCache, installables iter.Seq
defer cancel() defer cancel()
cmd := ctx.Nix(c, CommandCopy, cmd := ctx.Nix(c, CommandCopy,
FlagTo, store.String()+"&secret-key="+keyPath, FlagTo, store.String(),
FlagStdin) FlagStdin)
cmd.Env = append(os.Environ(), EnvAwsSharedCredentialsFile+"="+store.CredentialsPath) cmd.Env = append(os.Environ(), store.Environ()...)
if _, ok := store.(Local); ok {
// this is required for chroot stores, but does not seem to have any effect on binary cache
cmd.Args = append(cmd.Args, FlagNoCheckSigs)
}
cmd.Stdout, cmd.Stderr = ctx.Streams() cmd.Stdout, cmd.Stderr = ctx.Streams()
_, err := ctx.WriteStdin(cmd, installables, nil) _, err := ctx.WriteStdin(cmd, installables, nil)

View File

@ -14,10 +14,15 @@ import (
func init() { func init() {
stubCommandInit = append(stubCommandInit, func(c command.Command) { stubCommandInit = append(stubCommandInit, func(c command.Command) {
var ( var (
flagCopyTo string flagCopyTo string
flagCopyStdin bool flagCopyStdin bool
flagNoCheckSigs bool
) )
c.NewCommand(nix.CommandCopy, "emit samples for various `nix copy` cases", func(args []string) error { c.NewCommand(nix.CommandCopy, "emit samples for various `nix copy` cases", func(args []string) error {
if flagNoCheckSigs && flagCopyStdin && flagCopyTo == "/" {
return nil
}
if flagCopyTo != "s3://example?compression=none&parallel-compression=false&region=us-east-1&scheme=http&endpoint=s3.example.org&secret-key="+nonexistent || !flagCopyStdin { if flagCopyTo != "s3://example?compression=none&parallel-compression=false&region=us-east-1&scheme=http&endpoint=s3.example.org&secret-key="+nonexistent || !flagCopyStdin {
return syscall.ENOSYS return syscall.ENOSYS
} }
@ -32,50 +37,15 @@ func init() {
return nil return nil
}). }).
Flag(&flagCopyTo, trimFlagName(nix.FlagTo), command.StringFlag(""), nix.FlagTo). Flag(&flagCopyTo, trimFlagName(nix.FlagTo), command.StringFlag(""), nix.FlagTo).
Flag(&flagCopyStdin, trimFlagName(nix.FlagStdin), command.BoolFlag(false), nix.FlagStdin) Flag(&flagCopyStdin, trimFlagName(nix.FlagStdin), command.BoolFlag(false), nix.FlagStdin).
Flag(&flagNoCheckSigs, trimFlagName(nix.FlagNoCheckSigs), command.BoolFlag(false), nix.FlagNoCheckSigs)
}) })
} }
func TestBinaryCache(t *testing.T) {
testCases := []struct {
name string
store *nix.BinaryCache
want string
}{
{"example", &nix.BinaryCache{
Compression: "none",
ParallelCompression: false,
Bucket: "example",
Endpoint: "s3.example.org",
Region: "us-east-1",
Scheme: "http",
CredentialsPath: "/dev/null",
}, "s3://example?compression=none&parallel-compression=false&region=us-east-1&scheme=http&endpoint=s3.example.org"},
{"gensokyo", &nix.BinaryCache{
Compression: "zstd",
ParallelCompression: true,
Bucket: "nix-cache",
Endpoint: "s3.gensokyo.uk",
Region: "ap-northeast-1",
Scheme: "https",
CredentialsPath: "/var/lib/persist/cache/s3",
}, "s3://nix-cache?compression=zstd&parallel-compression=true&region=ap-northeast-1&scheme=https&endpoint=s3.gensokyo.uk"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.store.String(); got != tc.want {
t.Errorf("String: %q, want %q", got, tc.want)
}
})
}
}
func TestCopy(t *testing.T) { func TestCopy(t *testing.T) {
stubNixCommand(t) stubNixCommand(t)
if err := nix.Copy( if err := nix.Copy(
newStubContext(t.Context(), nil, os.Stdout, os.Stderr), newStubContext(t.Context(), nil, os.Stdout, os.Stderr),
nonexistent,
&nix.BinaryCache{ &nix.BinaryCache{
Compression: "none", Compression: "none",
ParallelCompression: false, ParallelCompression: false,
@ -84,6 +54,7 @@ func TestCopy(t *testing.T) {
Region: "us-east-1", Region: "us-east-1",
Scheme: "http", Scheme: "http",
CredentialsPath: "/dev/null", CredentialsPath: "/dev/null",
KeyPath: nonexistent,
}, },
slices.Values(instWant["pluiedev pappardelle"]), slices.Values(instWant["pluiedev pappardelle"]),
); err != nil { ); err != nil {
@ -93,11 +64,20 @@ func TestCopy(t *testing.T) {
t.Run("nil store", func(t *testing.T) { t.Run("nil store", func(t *testing.T) {
if err := nix.Copy( if err := nix.Copy(
newStubContext(t.Context(), nil, os.Stdout, os.Stderr), newStubContext(t.Context(), nil, os.Stdout, os.Stderr),
nonexistent,
nil, nil,
nil, nil,
); !errors.Is(err, os.ErrInvalid) { ); !errors.Is(err, os.ErrInvalid) {
t.Errorf("Copy: error = %v, want %v", err, os.ErrInvalid) t.Errorf("Copy: error = %v, want %v", err, os.ErrInvalid)
} }
}) })
t.Run("chroot store", func(t *testing.T) {
if err := nix.Copy(
newStubContext(t.Context(), nil, os.Stdout, os.Stderr),
nix.Local(os.PathSeparator),
slices.Values([]string{"/nix/store"}),
); err != nil {
t.Errorf("Copy: error = %v", err)
}
})
} }

1
default.nix Normal file
View File

@ -0,0 +1 @@
with import <nixpkgs> { }; pkgsStatic.callPackage ./package.nix { }

19
exec.go
View File

@ -11,7 +11,7 @@ import (
) )
const ( const (
defaultWaitDelay = 15 * time.Second defaultWaitDelay = 30 * time.Second
) )
// Nix is the name of the nix program. // Nix is the name of the nix program.
@ -19,6 +19,7 @@ var Nix = "nix"
type nix struct { type nix struct {
name string name string
store Store
ctx context.Context ctx context.Context
extra []string extra []string
@ -40,12 +41,17 @@ A non-nil stderr implies verbose.
Streams will not be connected for commands outputting JSON. Streams will not be connected for commands outputting JSON.
*/ */
func New(ctx context.Context, extraArgs []string, stdout, stderr io.Writer) Context { func New(ctx context.Context, store Store, extraArgs []string, stdout, stderr io.Writer) Context {
extra := []string{ExtraExperimentalFeatures, ExperimentalFeaturesFlakes}
if store != nil {
extra = append(extraArgs, FlagStore, store.String())
}
return &nix{ return &nix{
name: Nix, name: Nix,
ctx: ctx, store: store,
ctx: ctx,
// since flakes are supposedly experimental // since flakes are supposedly experimental
extra: append(extraArgs, ExtraExperimentalFeatures, ExperimentalFeaturesFlakes), extra: append(extraArgs, extra...),
stdout: stdout, stdout: stdout,
stderr: stderr, stderr: stderr,
@ -56,6 +62,9 @@ func (n *nix) Nix(ctx context.Context, arg ...string) *exec.Cmd {
cmd := exec.CommandContext(ctx, n.name, append(n.extra, arg...)...) cmd := exec.CommandContext(ctx, n.name, append(n.extra, arg...)...)
cmd.Cancel = func() error { return cmd.Process.Signal(os.Interrupt) } cmd.Cancel = func() error { return cmd.Process.Signal(os.Interrupt) }
cmd.WaitDelay = defaultWaitDelay cmd.WaitDelay = defaultWaitDelay
if n.store != nil {
cmd.Env = append(cmd.Env, n.store.Environ()...)
}
return cmd return cmd
} }

View File

@ -54,7 +54,7 @@ func stubNixCommand(t *testing.T) {
// newStubContext creates a context for use with the nix command stub. // newStubContext creates a context for use with the nix command stub.
func newStubContext(ctx context.Context, extraArgs []string, stdout, stderr io.Writer) nix.Context { func newStubContext(ctx context.Context, extraArgs []string, stdout, stderr io.Writer) nix.Context {
return nix.New(ctx, append(stubExtraArgs, extraArgs...), stdout, stderr) return nix.New(ctx, nil, append(stubExtraArgs, extraArgs...), stdout, stderr)
} }
type stubContextCommand struct { type stubContextCommand struct {
@ -107,7 +107,7 @@ func TestNixStub(t *testing.T) {
Flag(&flagExtraExperimentalFeatures, trimFlagName(nix.ExtraExperimentalFeatures), command.StringFlag(""), Flag(&flagExtraExperimentalFeatures, trimFlagName(nix.ExtraExperimentalFeatures), command.StringFlag(""),
fmt.Sprintf("expects exactly %q", nix.ExperimentalFeaturesFlakes)) fmt.Sprintf("expects exactly %q", nix.ExperimentalFeaturesFlakes))
c.Command("true", command.UsageInternal, func([]string) error { return nil }) c.Command(nix.ValueTrue, command.UsageInternal, func([]string) error { return nil })
for _, f := range stubCommandInit { for _, f := range stubCommandInit {
f(c) f(c)

View File

@ -1,6 +1,7 @@
package nix_test package nix_test
import ( import (
"context"
"errors" "errors"
"os" "os"
"os/exec" "os/exec"
@ -12,8 +13,36 @@ import (
) )
func TestNixWriteStdin(t *testing.T) { func TestNixWriteStdin(t *testing.T) {
t.Run("store", func(t *testing.T) {
ctx := nix.New(t.Context(), &nix.BinaryCache{
Compression: "none",
ParallelCompression: false,
Bucket: "example",
Endpoint: "s3.example.org",
Region: "us-east-1",
Scheme: "http",
CredentialsPath: "/dev/null",
KeyPath: nonexistent,
}, nil, nil, nil)
cmd := ctx.Nix(t.Context(), nix.FlagVersion)
wantArgs := []string{
nix.Nix,
nix.FlagStore,
"s3://example?compression=none&parallel-compression=false&region=us-east-1&scheme=http&endpoint=s3.example.org&secret-key=/proc/nonexistent",
nix.FlagVersion}
if !slices.Equal(cmd.Args, wantArgs) {
t.Errorf("Args = %#v, want %#v", cmd.Args, wantArgs)
}
wantEnv := []string{nix.EnvAwsSharedCredentialsFile + "=/dev/null"}
if !slices.Equal(cmd.Env, wantEnv) {
t.Errorf("Env = %#v, want %#v", cmd.Env, wantEnv)
}
})
t.Run("already set", func(t *testing.T) { t.Run("already set", func(t *testing.T) {
ctx := nix.New(t.Context(), nil, os.Stdout, os.Stderr) ctx := nix.New(t.Context(), nil, nil, os.Stdout, os.Stderr)
cmd := exec.CommandContext(t.Context(), nonexistent) cmd := exec.CommandContext(t.Context(), nonexistent)
cmd.Stdin = os.Stdin cmd.Stdin = os.Stdin
if _, err := ctx.WriteStdin(cmd, nil, nil); err == nil { if _, err := ctx.WriteStdin(cmd, nil, nil); err == nil {
@ -33,4 +62,23 @@ func TestNixWriteStdin(t *testing.T) {
t.Fatalf("WriteStdinCommand: error = %v, want %v", err, syscall.ENOSYS) t.Fatalf("WriteStdinCommand: error = %v, want %v", err, syscall.ENOSYS)
} }
}) })
t.Run("exit before cancel", func(t *testing.T) {
stubNixCommand(t)
ctx := newStubContext(t.Context(), nil, os.Stdout, os.Stderr)
c, cancel := context.WithCancel(t.Context())
defer cancel()
cmd := ctx.Nix(c, "true")
if err := cmd.Start(); err != nil {
t.Fatalf("Start: error = %v", err)
}
// Cancel is skipped after exec.Cmd.Wait completes
if _, err := cmd.Process.Wait(); err != nil {
t.Fatalf("Wait: error = %v", err)
}
cancel()
if cmd.Err != nil {
t.Fatalf("Err = %v", cmd.Err)
}
})
} }

View File

@ -80,6 +80,9 @@ const (
// FlagVersion show version information. // FlagVersion show version information.
FlagVersion = "--version" FlagVersion = "--version"
// FlagStore is a loosely documented flag for specifying the store url to operate on.
FlagStore = "--store"
// FlagKeepGoing keep going in case of failed builds, to the greatest extent possible. // FlagKeepGoing keep going in case of failed builds, to the greatest extent possible.
// That is, if building an input of some derivation fails, Nix will still build the other inputs, // That is, if building an input of some derivation fails, Nix will still build the other inputs,
// but not the derivation itself. // but not the derivation itself.

2
go.mod
View File

@ -2,4 +2,4 @@ module gensokyo.uk/nix
go 1.24.4 go 1.24.4
require hakurei.app v0.1.1 require hakurei.app v0.1.2

4
go.sum
View File

@ -1,2 +1,2 @@
hakurei.app v0.1.1 h1:b1ooWWIdvvBRj0BCOmK2wLfVs3nvYbxEHtQX5DP6bos= hakurei.app v0.1.2 h1:jJLAThl15C1+4N8Ss95pk+FMtqb6m+foUTrhhwefMFg=
hakurei.app v0.1.1/go.mod h1:bWcF0vCO+ZOtZ2zK7L3e08sNb0kIkv1CBcdWTZtu1Gs= hakurei.app v0.1.2/go.mod h1:bWcF0vCO+ZOtZ2zK7L3e08sNb0kIkv1CBcdWTZtu1Gs=

View File

@ -107,7 +107,7 @@ func TestInstantiatedEvaluatorBadCommand(t *testing.T) {
breakNixCommand(t) breakNixCommand(t)
if _, err := nix.EvalInstantiated( if _, err := nix.EvalInstantiated(
nix.New(t.Context(), nil, os.Stdout, os.Stderr), nix.New(t.Context(), nil, nil, os.Stdout, os.Stderr),
"", "",
); !errors.Is(err, wantErr) { ); !errors.Is(err, wantErr) {
t.Errorf("EvalInstantiated: error = %v, want %v", err, wantErr) t.Errorf("EvalInstantiated: error = %v, want %v", err, wantErr)

24
package.nix Normal file
View File

@ -0,0 +1,24 @@
{
lib,
stdenv,
buildGoModule,
pkg-config,
}:
buildGoModule {
pname = "nix-tool";
version = "0.1.4";
src = ./.;
vendorHash = "sha256-z07S4eulIujnFR5Sn2tpg8gxl+lh0zKcLQwPBg8gKsI=";
ldflags =
[ "-s -w" ]
++ lib.optionals stdenv.hostPlatform.isStatic [
"-linkmode external"
"-extldflags \"-static\""
];
nativeBuildInputs = [
pkg-config
];
}

56
store.go Normal file
View File

@ -0,0 +1,56 @@
package nix
import (
"fmt"
"strings"
)
type Store interface {
// Environ returns extra environment variables specified by Store.
Environ() []string
fmt.Stringer
}
// Local points to a local filesystem path containing a nix store.
type Local string
func (Local) Environ() []string { return nil }
func (store Local) String() string { return string(store) }
const (
EnvAwsSharedCredentialsFile = "AWS_SHARED_CREDENTIALS_FILE"
)
// A BinaryCache holds credentials and parameters to a s3 binary cache.
type BinaryCache struct {
// Compression is the name of the compression algorithm to use. Example: "zstd".
Compression string `json:"compression"`
// ParallelCompression determines whether parallel compression is enabled.
ParallelCompression bool `json:"parallel_compression,omitempty"`
// Bucket is the s3 bucket name.
Bucket string `json:"bucket"`
// Endpoint is the s3 endpoint. Example: "s3.example.org".
Endpoint string `json:"endpoint,omitempty"`
// Region is the s3 region. Example: "ap-northeast-1".
Region string `json:"region"`
// Scheme is the s3 protocol. Example: "https".
Scheme string `json:"scheme"`
// CredentialsPath is the path to the s3 shared credentials file.
CredentialsPath string `json:"credentials_path"`
// KeyPath is the path to the nix secret key for signing all newly copied paths.
KeyPath string `json:"key_path"`
}
func (store *BinaryCache) Environ() []string {
return []string{EnvAwsSharedCredentialsFile + "=" + strings.TrimSpace(store.CredentialsPath)}
}
func (store *BinaryCache) String() string {
return fmt.Sprintf(
"s3://%s?compression=%s&parallel-compression=%t&region=%s&scheme=%s&endpoint=%s&secret-key=%s",
store.Bucket, store.Compression, store.ParallelCompression, store.Region, store.Scheme, store.Endpoint, store.KeyPath,
)
}

57
store_test.go Normal file
View File

@ -0,0 +1,57 @@
package nix_test
import (
"testing"
"gensokyo.uk/nix"
)
func TestLocal(t *testing.T) {
if got := nix.Local("/").String(); got != "/" {
t.Errorf("String: %v, want %v", got, "/")
}
if got := nix.Local("").Environ(); got != nil {
t.Errorf("Environ: %v, want %v", got, nil)
}
}
func TestBinaryCache(t *testing.T) {
testCases := []struct {
name string
store *nix.BinaryCache
want string
wantEnv []string
}{
{"example", &nix.BinaryCache{
Compression: "none",
ParallelCompression: false,
Bucket: "example",
Endpoint: "s3.example.org",
Region: "us-east-1",
Scheme: "http",
CredentialsPath: "/dev/null",
KeyPath: nonexistent,
}, "s3://example?compression=none&parallel-compression=false&region=us-east-1&scheme=http&endpoint=s3.example.org&secret-key=/proc/nonexistent",
[]string{nix.EnvAwsSharedCredentialsFile + "=/dev/null"}},
{"gensokyo", &nix.BinaryCache{
Compression: "zstd",
ParallelCompression: true,
Bucket: "nix-cache",
Endpoint: "s3.gensokyo.uk",
Region: "ap-northeast-1",
Scheme: "https",
CredentialsPath: "/var/lib/persist/cache/s3",
KeyPath: "/var/lib/persist/cache/key",
}, "s3://nix-cache?compression=zstd&parallel-compression=true&region=ap-northeast-1&scheme=https&endpoint=s3.gensokyo.uk&secret-key=/var/lib/persist/cache/key",
[]string{nix.EnvAwsSharedCredentialsFile + "=/var/lib/persist/cache/s3"}},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.store.String(); got != tc.want {
t.Errorf("String: %q, want %q", got, tc.want)
}
})
}
}