git-csi-driver

InputUnitCoveredTotalPercent
Gostatements95796599.2%

Go

957 of 965 statements, 99.2%.

FileCovered statementsTotal statementsPercent
arming.go707198.6%
attributes.go5555100.0%
credentials.go4444100.0%
events.go2626100.0%
follow.go8080100.0%
git.go2121100.0%
identity.go99100.0%
main.go303390.9%
metrics.go2828100.0%
mount.go1515100.0%
node.go128128100.0%
records.go616298.4%
server.go353697.2%
stage.go102102100.0%
store.go8484100.0%
volume.go4040100.0%
watch.go8888100.0%
worktree.go414395.3%
arming.go 98.6%
1package main23// arming.go finds the claim a writeable volume is bound to and reads4// the class that arms it. Plan 04 records the answer, and plan 05 acts5// on it.67import (8	"context"9	"fmt"10	"log/slog"11	"time"1213	corev1 "k8s.io/api/core/v1"14	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"15	"k8s.io/client-go/kubernetes"16)1718// defaultResync is how long the driver waits before it reads the claim19// again. It covers a watch that ended and a claim that arrived after20// the volume did.21const defaultResync = 30 * time.Second2223// claimReference is the claim a PersistentVolume is bound to. It labels24// the volume's metrics and takes its Events.25type claimReference struct {26	namespace string27	name      string28}2930// arming reads the cluster for one node's volumes. A driver outside a31// cluster holds no client and arms nothing.32type arming struct {33	node   *node34	client kubernetes.Interface35	logger *slog.Logger36	resync time.Duration37}3839func newArming(answering *node, client kubernetes.Interface, logger *slog.Logger) *arming {40	return &arming{node: answering, client: client, logger: logger, resync: defaultResync}41}4243// arm starts the loop that reads the volume's claim. The caller holds44// the node's lock.45func (n *node) arm(staged *volume) {46	if n.arms.client == nil {47		return48	}49	ctx, cancel := context.WithCancel(n.base)50	n.armings[staged.id] = cancel51	go n.arms.follow(ctx, staged)52}5354// disarm ends that loop and takes the volume off the gauges. The caller55// holds the node's lock.56func (n *node) disarm(staged *volume) {57	cancel, found := n.armings[staged.id]58	if !found {59		return60	}61	delete(n.armings, staged.id)62	cancel()63	n.readings.forget(staged)64}6566// follow reads the claim, then waits for the claim to change or for the67// resync, until the driver stops.68func (a *arming) follow(ctx context.Context, staged *volume) {69	for ctx.Err() == nil {70		a.pass(ctx, staged)71	}72}7374// pass finds the claim, reads the class, and holds a watch open until75// the claim changes or the resync says to start again.76func (a *arming) pass(ctx context.Context, staged *volume) {77	claim, err := a.claimOf(ctx, staged.id)78	if err != nil {79		a.logger.WarnContext(ctx, "the claim was not found",80			"volume", staged.id, "error", err)81		a.rest(ctx)82		return83	}84	a.read(ctx, staged, claim)8586	watching, err := a.client.CoreV1().PersistentVolumeClaims(claim.namespace).87		Watch(ctx, metav1.ListOptions{FieldSelector: "metadata.name=" + claim.name})88	if err != nil {89		a.logger.WarnContext(ctx, "the claim is not watched",90			"claim", claim.namespace+"/"+claim.name, "error", err)91		a.rest(ctx)92		return93	}94	defer watching.Stop()9596	resync := time.NewTimer(a.resync)97	defer resync.Stop()98	for {99		select {100		case <-ctx.Done():101			return102		case <-resync.C:103			return104		case _, open := <-watching.ResultChan():105			if !open {106				return107			}108			a.read(ctx, staged, claim)109		}110	}111}112113// rest waits out the resync after a read that failed, so a claim that114// is not there yet costs one call per resync.115func (a *arming) rest(ctx context.Context) {116	timer := time.NewTimer(a.resync)117	defer timer.Stop()118	select {119	case <-ctx.Done():120	case <-timer.C:121	}122}123124// claimOf finds the claim through the PersistentVolume that carries the125// handle. The kubelet passes the handle and never the object's name, so126// the driver lists the volumes and matches on the handle.127func (a *arming) claimOf(ctx context.Context, handle string) (claimReference, error) {128	volumes, err := a.client.CoreV1().PersistentVolumes().List(ctx, metav1.ListOptions{})129	if err != nil {130		return claimReference{}, err131	}132	for _, held := range volumes.Items {133		source := held.Spec.CSI134		if source == nil || source.Driver != driverName || source.VolumeHandle != handle {135			continue136		}137		if held.Spec.ClaimRef == nil {138			return claimReference{}, fmt.Errorf("the PersistentVolume %s is bound to no claim", held.Name)139		}140		return claimReference{141			namespace: held.Spec.ClaimRef.Namespace,142			name:      held.Spec.ClaimRef.Name,143		}, nil144	}145	return claimReference{}, fmt.Errorf("no PersistentVolume of %s carries the handle %s", driverName, handle)146}147148// read takes the class the claim names and arms the volume when that149// class belongs to this driver.150func (a *arming) read(ctx context.Context, staged *volume, claim claimReference) {151	held, err := a.client.CoreV1().PersistentVolumeClaims(claim.namespace).152		Get(ctx, claim.name, metav1.GetOptions{})153	if err != nil {154		a.logger.WarnContext(ctx, "the claim was not read",155			"claim", claim.namespace+"/"+claim.name, "error", err)156		return157	}158159	name, armed := className(held), false160	if name != "" {161		class, err := a.client.StorageV1().VolumeAttributesClasses().162			Get(ctx, name, metav1.GetOptions{})163		switch {164		case err != nil:165			a.logger.WarnContext(ctx, "the class was not read",166				"class", name, "error", err)167		default:168			armed = class.DriverName == driverName169		}170	}171	a.node.armed(ctx, staged, claim, name, armed)172}173174// className is the class in force: the one the claim's status carries,175// or the one its spec names until a resizer records the modify. Without176// a resizer the status is never filled, so the spec has to count or177// nothing ever arms.178func className(held *corev1.PersistentVolumeClaim) string {179	if current := held.Status.CurrentVolumeAttributesClassName; current != nil && *current != "" {180		return *current181	}182	if asked := held.Spec.VolumeAttributesClassName; asked != nil {183		return *asked184	}185	return ""186}187188// armed records the answer and posts an Event on the pod and the claim189// when the volume moved between armed and unarmed.190func (n *node) armed(191	ctx context.Context, staged *volume, claim claimReference, class string, armed bool,192) {193	if staged.reportArmed(claim, class, armed) {194		reason, message := reasonArmed, fmt.Sprintf("armed by the class %s", class)195		if !armed {196			reason, message = reasonUnarmed, "unarmed: the claim names no class of "+driverName197		}198		n.report(ctx, staged, claim, corev1.EventTypeNormal, reason, message)199	}200	n.readings.record(staged)201}
attributes.go 100.0%
1package main23// attributes.go reads a volume's attributes and refuses what the driver4// cannot serve, before any git runs.56import (7	"regexp"8	"strconv"9	"strings"10	"time"1112	"github.com/container-storage-interface/spec/lib/go/csi"13	"google.golang.org/grpc/codes"14	"google.golang.org/grpc/status"15)1617// The keys the kubelet adds to the volume context itself. podInfoOnMount18// on the CSIDriver object turns them on.19const (20	ephemeralKey      = "csi.storage.k8s.io/ephemeral"21	podNameKey        = "csi.storage.k8s.io/pod.name"22	podNamespaceKey   = "csi.storage.k8s.io/pod.namespace"23	podUIDKey         = "csi.storage.k8s.io/pod.uid"24	serviceAccountKey = "csi.storage.k8s.io/serviceAccount.name"25)2627// offlinePolicy is what a volume does when the fetch at publish fails.28type offlinePolicy string2930const (31	offlineRefuse     offlinePolicy = "refuse"32	offlineAllowStale offlinePolicy = "allowStale"33)3435// The defaults for a read-only volume.36const (37	defaultRef  = "main"38	defaultPull = 5 * time.Minute39)4041// podReference is the pod the kubelet named, where an Event goes.42type podReference struct {43	name      string44	namespace string45	uid       string46}4748// attributes is one read-only volume as its attributes describe it.49type attributes struct {50	url       string51	ref       string52	pull      time.Duration53	depth     int54	offline   offlinePolicy55	ephemeral bool56	pod       podReference57}5859// parseAttributes reads the volume context. It refuses an unknown60// attribute and a malformed value with InvalidArgument and the61// attribute's name, so the pod's events say what to fix.62func parseAttributes(request *csi.NodePublishVolumeRequest) (*attributes, error) {63	parsed, err := parseVolumeContext(request.GetVolumeContext())64	if err != nil {65		return nil, err66	}67	if parsed.ephemeral && !request.GetReadonly() {68		return nil, status.Error(codes.InvalidArgument,69			"readOnly: an inline volume of this driver has to be read-only")70	}71	return parsed, nil72}7374// readOnlyAttributes are the attributes a read-only volume alone75// accepts. A writeable volume follows its ref at stage and never after.76var readOnlyAttributes = []string{"pull", "depth", "offline"}7778// parseStageAttributes reads a persistent volume's attributes and79// refuses the read-only ones.80func parseStageAttributes(context map[string]string) (*attributes, error) {81	for _, key := range readOnlyAttributes {82		if _, found := context[key]; found {83			return nil, status.Errorf(codes.InvalidArgument,84				"%s: a writeable volume follows its ref at stage alone", key)85		}86	}87	return parseVolumeContext(context)88}8990// parseVolumeContext reads the attributes both a stage call and a91// publish call carry.92func parseVolumeContext(context map[string]string) (*attributes, error) {93	parsed := &attributes{ref: defaultRef, pull: defaultPull, offline: offlineRefuse}9495	for key, value := range context {96		switch key {97		case "url":98			parsed.url = value99		case "ref":100			parsed.ref = value101		case "pull":102			pull, err := parsePull(value)103			if err != nil {104				return nil, err105			}106			parsed.pull = pull107		case "depth":108			depth, err := parseDepth(value)109			if err != nil {110				return nil, err111			}112			parsed.depth = depth113		case "offline":114			switch offlinePolicy(value) {115			case offlineRefuse, offlineAllowStale:116				parsed.offline = offlinePolicy(value)117			default:118				return nil, status.Errorf(codes.InvalidArgument,119					"offline: %q is not refuse or allowStale", value)120			}121		case ephemeralKey:122			parsed.ephemeral = value == "true"123		case podNameKey:124			parsed.pod.name = value125		case podNamespaceKey:126			parsed.pod.namespace = value127		case podUIDKey:128			parsed.pod.uid = value129		case serviceAccountKey:130		default:131			return nil, status.Errorf(codes.InvalidArgument, "%s: unknown attribute", key)132		}133	}134135	if parsed.url == "" {136		return nil, status.Error(codes.InvalidArgument, "url: an attribute the volume must set")137	}138	if err := checkURL(parsed.url); err != nil {139		return nil, err140	}141	if parsed.ref == "" {142		return nil, status.Error(codes.InvalidArgument, "ref: an empty ref names nothing")143	}144	return parsed, nil145}146147// checkURL refuses two URL shapes that would run a command. git accepts148// options after the repository, so a URL that starts with a dash is an149// option. A <transport>::<address> URL names a helper program git runs.150// The driver fetches as root on the node and the URL comes from a pod151// spec, so both are refused before git sees them.152func checkURL(url string) error {153	if strings.HasPrefix(url, "-") {154		return status.Errorf(codes.InvalidArgument, "url: %q reads as an option to git", url)155	}156	if transportHelper.MatchString(url) {157		return status.Errorf(codes.InvalidArgument,158			"url: %q names a transport helper, and the driver runs none", url)159	}160	return nil161}162163// transportHelper matches git's <transport>::<address> form. No https,164// ssh, git, or file URL has two colons after its first word.165var transportHelper = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9+.\-]*::`)166167// parsePull reads pull. never is zero: no fetch after the publish.168func parsePull(value string) (time.Duration, error) {169	if value == "never" {170		return 0, nil171	}172	pull, err := time.ParseDuration(value)173	if err != nil {174		return 0, status.Errorf(codes.InvalidArgument,175			"pull: %q is not a duration or never", value)176	}177	if pull <= 0 {178		return 0, status.Errorf(codes.InvalidArgument,179			"pull: %q is not longer than zero", value)180	}181	return pull, nil182}183184// parseDepth reads depth. Zero is a full clone.185func parseDepth(value string) (int, error) {186	depth, err := strconv.Atoi(value)187	if err != nil || depth < 0 {188		return 0, status.Errorf(codes.InvalidArgument,189			"depth: %q is not a whole number of commits", value)190	}191	return depth, nil192}
credentials.go 100.0%
1package main23// credentials.go turns a Secret's data into the environment one git4// invocation runs under.56import (7	"os"8	"path/filepath"9	"strings"1011	"google.golang.org/grpc/codes"12	"google.golang.org/grpc/status"13)1415// The keys the driver reads out of a nodePublishSecretRef Secret.16const (17	privateKeyKey = "ssh-privatekey"18	knownHostsKey = "known_hosts"19	tokenKey      = "token"20	usernameKey   = "username"21)2223// defaultUsername is the user a token authenticates as when the Secret24// names none. Forges accept any user with a token; git is the custom.25const defaultUsername = "git"2627// The file names the credentials take under the volume's directory.28const (29	privateKeyFile = "ssh-privatekey"30	knownHostsFile = "known_hosts"31	helperFile     = "credential-helper"32)3334// credentials is what the kubelet passed from the Secret. It stays in35// memory on the volume, so a later fetch can use it, and reaches the36// disk only around a git invocation.37type credentials struct {38	privateKey string39	knownHosts string40	token      string41	username   string42}4344// parseCredentials reads the Secret. No Secret is fine. A Secret with45// neither key is refused, because the person who named it meant one.46func parseCredentials(secrets map[string]string) (*credentials, error) {47	if len(secrets) == 0 {48		return nil, nil49	}50	parsed := &credentials{51		privateKey: secrets[privateKeyKey],52		knownHosts: secrets[knownHostsKey],53		token:      secrets[tokenKey],54		username:   secrets[usernameKey],55	}56	if parsed.privateKey == "" && parsed.token == "" {57		return nil, status.Errorf(codes.InvalidArgument,58			"nodePublishSecretRef: the Secret carries no %s and no %s", privateKeyKey, tokenKey)59	}60	if parsed.username == "" {61		parsed.username = defaultUsername62	}63	return parsed, nil64}6566// use writes the credential files under dir and returns the environment67// that names them and the function that removes them. A private key on68// the node's disk lives no longer than the git invocation that reads it.69// The token goes through a helper script, not the command line, so it70// never appears in the process table.71func (c *credentials) use(dir string) ([]string, func(), error) {72	if c == nil {73		return nil, func() {}, nil74	}75	written := []string{}76	remove := func() {77		for _, path := range written {78			os.Remove(path)79		}80	}8182	var env []string83	if c.privateKey != "" {84		key := filepath.Join(dir, privateKeyFile)85		if err := os.WriteFile(key, []byte(endWithNewline(c.privateKey)), 0o600); err != nil {86			remove()87			return nil, func() {}, err88		}89		written = append(written, key)9091		hosts := filepath.Join(dir, knownHostsFile)92		if err := os.WriteFile(hosts, []byte(c.knownHosts), 0o600); err != nil {93			remove()94			return nil, func() {}, err95		}96		written = append(written, hosts)97		env = append(env, "GIT_SSH_COMMAND="+sshCommand(key, hosts, c.knownHosts != ""))98	}99100	if c.token != "" {101		helper := filepath.Join(dir, helperFile)102		if err := os.WriteFile(helper, []byte(credentialHelper(c.username, c.token)), 0o700); err != nil {103			remove()104			return nil, func() {}, err105		}106		written = append(written, helper)107		env = append(env,108			"GIT_CONFIG_COUNT=1",109			"GIT_CONFIG_KEY_0=credential.helper",110			"GIT_CONFIG_VALUE_0="+helper,111		)112	}113	return env, remove, nil114}115116// sshCommand makes ssh read the key and the hosts file the driver wrote117// and nothing of the node's own. A Secret with known_hosts can check118// the host key, so that one demands a match. Without it, ssh accepts119// the first key it sees and refuses a later change.120func sshCommand(key, hosts string, knownHosts bool) string {121	checking := "accept-new"122	if knownHosts {123		checking = "yes"124	}125	return strings.Join([]string{126		"ssh",127		"-i", quote(key),128		"-o", "IdentitiesOnly=yes",129		"-o", "BatchMode=yes",130		"-o", "UserKnownHostsFile=" + quote(hosts),131		"-o", "StrictHostKeyChecking=" + checking,132	}, " ")133}134135// credentialHelper is the script git runs for a password. git reads the136// answer from its standard output.137func credentialHelper(username, token string) string {138	return strings.Join([]string{139		"#!/bin/sh",140		"cat <<'GIT_CSI_CREDENTIAL'",141		"username=" + username,142		"password=" + token,143		"GIT_CSI_CREDENTIAL",144		"",145	}, "\n")146}147148// quote makes a path safe inside GIT_SSH_COMMAND, which git splits with149// a shell's rules.150func quote(path string) string {151	return "'" + strings.ReplaceAll(path, "'", `'\''`) + "'"152}153154// endWithNewline appends the newline ssh requires at the end of a key155// file. A Secret authored by hand often lacks it.156func endWithNewline(text string) string {157	if strings.HasSuffix(text, "\n") {158		return text159	}160	return text + "\n"161}
events.go 100.0%
1package main23// events.go posts an Event on the pod that mounts a volume. Events are4// what kubectl describe shows, so a refused or stale mount is explained5// where a person looks first.67import (8	"context"9	"log/slog"10	"time"1112	corev1 "k8s.io/api/core/v1"13	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"14	"k8s.io/apimachinery/pkg/types"15	"k8s.io/client-go/kubernetes"16	"k8s.io/client-go/rest"17)1819// The reasons, one per state change a person has to see.20const (21	reasonRefused = "GitVolumeRefused"22	reasonStale   = "GitVolumeStale"23	reasonFailed  = "GitFetchFailed"24	// The three a writeable volume adds: a class armed it, the class left25	// it, and the tree holds work the driver has not committed.26	reasonArmed   = "GitVolumeArmed"27	reasonUnarmed = "GitVolumeUnarmed"28	reasonPending = "GitVolumePending"29)3031// events posts Events through the cluster's API, or posts nothing when32// the driver runs outside a cluster.33type events struct {34	client kubernetes.Interface35	node   string36	logger *slog.Logger37	now    func() time.Time38}3940// newEvents reads the driver's own credentials from the pod it runs in.41func newEvents(nodeID string, logger *slog.Logger) *events {42	return eventsFrom(nodeID, logger, rest.InClusterConfig)43}4445// eventsFrom builds the client from the configuration load returns. A46// driver that finds no cluster still serves volumes and says so once,47// because a mount is worth more than an Event.48func eventsFrom(nodeID string, logger *slog.Logger, load func() (*rest.Config, error)) *events {49	posting := &events{node: nodeID, logger: logger, now: time.Now}50	config, err := load()51	if err != nil {52		logger.Warn("no events", "reason", err)53		return posting54	}55	client, err := kubernetes.NewForConfig(config)56	if err != nil {57		logger.Warn("no events", "reason", err)58		return posting59	}60	posting.client = client61	return posting62}6364// post creates one Event on the pod. A failure to post is logged and65// nothing more, because a mount must never fail on the API server.66func (e *events) post(ctx context.Context, pod podReference, kind, reason, message string) {67	if pod.name == "" || pod.namespace == "" {68		return69	}70	e.create(ctx, corev1.ObjectReference{71		Kind:       "Pod",72		APIVersion: "v1",73		Name:       pod.name,74		Namespace:  pod.namespace,75		UID:        types.UID(pod.uid),76	}, kind, reason, message)77}7879// postClaim creates the same Event on the claim, where a person who80// describes the claim learns whether the volume is armed.81func (e *events) postClaim(ctx context.Context, claim claimReference, kind, reason, message string) {82	if claim.name == "" || claim.namespace == "" {83		return84	}85	e.create(ctx, corev1.ObjectReference{86		Kind:       "PersistentVolumeClaim",87		APIVersion: "v1",88		Name:       claim.name,89		Namespace:  claim.namespace,90	}, kind, reason, message)91}9293// create posts one Event on the object it names.94func (e *events) create(95	ctx context.Context, involved corev1.ObjectReference, kind, reason, message string,96) {97	if e == nil || e.client == nil {98		return99	}100	now := metav1.NewTime(e.now())101	event := &corev1.Event{102		ObjectMeta: metav1.ObjectMeta{103			GenerateName: involved.Name + ".",104			Namespace:    involved.Namespace,105		},106		InvolvedObject: involved,107		Reason:         reason,108		Message:        message,109		Type:           kind,110		Source:         corev1.EventSource{Component: driverName, Host: e.node},111		FirstTimestamp: now,112		LastTimestamp:  now,113		Count:          1,114	}115	if _, err := e.client.CoreV1().Events(involved.Namespace).116		Create(ctx, event, metav1.CreateOptions{}); err != nil {117		e.logger.WarnContext(ctx, "the event was not posted",118			"object", involved.Namespace+"/"+involved.Name, "reason", reason, "error", err)119	}120}121122// report posts one fact in both places a person looks: on the pod that123// mounts the volume and on the claim that binds it.124func (n *node) report(125	ctx context.Context, held *volume, claim claimReference, kind, reason, message string,126) {127	n.events.post(ctx, held.podRef(), kind, reason, message)128	n.events.postClaim(ctx, claim, kind, reason, message)129}
follow.go 100.0%
1package main23// follow.go holds the fetch loop that keeps a published tree on the ref4// it follows.56import (7	"context"8	"sync"9	"time"1011	corev1 "k8s.io/api/core/v1"12)1314// follower is one fetch loop for one repository, shared by every volume15// of that URL on this node. The fetch is the repository's work, not the16// volume's, so ten pods on one repository cost one fetch.17type follower struct {18	node       *node19	repository *repository20	cancel     context.CancelFunc21	wake       chan struct{}2223	mu      sync.Mutex24	volumes map[string]*volume25}2627// follow adds a volume to its repository's loop, starting the loop on28// the first volume. The caller holds the node's lock. A volume with29// pull never joins no loop, so a repository every volume pins fetches30// nothing.31func (n *node) follow(mounting *volume) {32	if mounting.attributes.pull == 0 {33		return34	}35	repo := n.store.repository(mounting.attributes.url)36	loop, found := n.followers[repo.name]37	if !found {38		ctx, cancel := context.WithCancel(n.base)39		loop = &follower{40			node:       n,41			repository: repo,42			cancel:     cancel,43			wake:       make(chan struct{}, 1),44			volumes:    map[string]*volume{},45		}46		n.followers[repo.name] = loop47		go loop.run(ctx)48	}49	loop.add(mounting)50}5152// unfollow removes a volume from its loop and stops the loop when the53// last volume of the repository goes. The caller holds the node's lock.54func (n *node) unfollow(published *volume) {55	if published.attributes.pull == 0 {56		return57	}58	repo := n.store.repository(published.attributes.url)59	loop, found := n.followers[repo.name]60	if !found {61		return62	}63	if loop.remove(published) == 0 {64		loop.cancel()65		delete(n.followers, repo.name)66	}67}6869func (f *follower) add(mounting *volume) {70	f.mu.Lock()71	f.volumes[mounting.id] = mounting72	f.mu.Unlock()73	f.nudge()74}7576func (f *follower) remove(published *volume) int {77	f.mu.Lock()78	delete(f.volumes, published.id)79	left := len(f.volumes)80	f.mu.Unlock()81	f.nudge()82	return left83}8485// nudge wakes the loop so it reads the interval again. The channel has86// one slot and the send never blocks, so a publish never waits on a87// loop that is fetching.88func (f *follower) nudge() {89	select {90	case f.wake <- struct{}{}:91	default:92	}93}9495// interval is the shortest pull among the volumes that share the96// repository.97func (f *follower) interval() time.Duration {98	f.mu.Lock()99	defer f.mu.Unlock()100	shortest := time.Duration(0)101	for _, held := range f.volumes {102		if shortest == 0 || held.attributes.pull < shortest {103			shortest = held.attributes.pull104		}105	}106	if shortest == 0 {107		shortest = defaultPull108	}109	return shortest110}111112// run fetches on the interval until the context ends. The context113// descends from the driver's run, so the pod's stop ends every loop.114func (f *follower) run(ctx context.Context) {115	timer := time.NewTimer(f.interval())116	defer timer.Stop()117	for {118		select {119		case <-ctx.Done():120			return121		case <-f.wake:122			timer.Reset(f.interval())123		case <-timer.C:124			f.tick(ctx)125			timer.Reset(f.interval())126		}127	}128}129130// tick is one pass over the volumes of this repository, under the131// repository's lock, so a fetch never races a publish.132func (f *follower) tick(ctx context.Context) {133	defer f.repository.lock()()134	for _, held := range f.snapshot() {135		f.refresh(ctx, held)136	}137}138139func (f *follower) snapshot() []*volume {140	f.mu.Lock()141	defer f.mu.Unlock()142	held := make([]*volume, 0, len(f.volumes))143	for _, one := range f.volumes {144		held = append(held, one)145	}146	return held147}148149// refresh fetches the volume's ref and, when it moved, places the new150// commit in the published tree.151func (f *follower) refresh(ctx context.Context, held *volume) {152	env, remove, err := held.credentials.use(held.directory)153	if err != nil {154		f.trouble(ctx, held, err.Error())155		return156	}157	fetchErr := f.repository.fetch(ctx, env, held.attributes.ref, 0)158	remove()159	if fetchErr != nil {160		f.trouble(ctx, held, fetchErr.Error())161		return162	}163	commit, err := f.repository.resolve(ctx, held.attributes.ref)164	if err != nil {165		f.trouble(ctx, held, err.Error())166		return167	}168	if standing, _ := held.condition(); standing == commit {169		held.reportCommit(commit)170		return171	}172173	if err := f.repository.place(ctx, commit, held.directory, held.tree); err != nil {174		f.trouble(ctx, held, err.Error())175		return176	}177	held.reportCommit(commit)178	f.node.logger.InfoContext(ctx, "the tree moved",179		"volume", held.id, "ref", held.attributes.ref, "commit", short(commit))180}181182// trouble records a failed fetch. The first failure after a success183// posts one Event, and the condition carries the failure until a fetch184// works again.185func (f *follower) trouble(ctx context.Context, held *volume, message string) {186	if held.reportTrouble(message) {187		f.node.events.post(ctx, held.attributes.pod, corev1.EventTypeWarning, reasonFailed, message)188	}189	f.node.logger.WarnContext(ctx, "the fetch failed",190		"volume", held.id, "ref", held.attributes.ref, "error", message)191}
git.go 100.0%
1package main23// git.go holds the one function every git invocation goes through, so4// every call has the same deadline, the same environment, and the same5// error shape.67import (8	"bytes"9	"context"10	"fmt"11	"os"12	"os/exec"13	"strings"14	"time"15)1617// gitDeadline bounds one git invocation. A remote that never answers18// must not hold a kubelet call open forever.19const gitDeadline = 60 * time.Second2021// gitWaitDelay is how long a killed git has to exit before its output22// pipes are abandoned.23const gitWaitDelay = 5 * time.Second2425// gitOutput is what one git invocation answers.26type gitOutput struct {27	stdout string28	stderr string29	code   int30}3132// runGit runs git in dir, with env added to the hermetic environment33// below, under the deadline. The error carries git's own last line of34// stderr, which is where git says why it failed.35func runGit(ctx context.Context, dir string, env []string, args ...string) (gitOutput, error) {36	ctx, cancel := context.WithTimeout(ctx, gitDeadline)37	defer cancel()3839	command := exec.CommandContext(ctx, "git", args...)40	command.Dir = dir41	command.Env = append(gitEnvironment(), env...)42	command.WaitDelay = gitWaitDelay4344	var stdout, stderr bytes.Buffer45	command.Stdout = &stdout46	command.Stderr = &stderr4748	err := command.Run()49	output := gitOutput{stdout: stdout.String(), stderr: stderr.String(), code: -1}50	if command.ProcessState != nil {51		output.code = command.ProcessState.ExitCode()52	}53	if err != nil {54		return output, fmt.Errorf("git %s: %s", strings.Join(args, " "), gitReason(output, err))55	}56	return output, nil57}5859// gitEnvironment is the environment every invocation starts from.60// Nothing of the node's own reaches git: no HOME, no agent socket, no61// global or system configuration, and no prompt, so a fetch behaves the62// same on every node and never waits for a password.63func gitEnvironment() []string {64	return []string{65		"PATH=" + os.Getenv("PATH"),66		"GIT_CONFIG_GLOBAL=/dev/null",67		"GIT_CONFIG_SYSTEM=/dev/null",68		"GIT_TERMINAL_PROMPT=0",69		"LC_ALL=C",70	}71}7273// gitReason is the last non-empty line of stderr, where git states its74// reason, or the exec error when git wrote nothing.75func gitReason(output gitOutput, err error) string {76	lines := strings.Split(strings.TrimSpace(output.stderr), "\n")77	if last := strings.TrimSpace(lines[len(lines)-1]); last != "" {78		return last79	}80	return err.Error()81}
identity.go 100.0%
1package main23// identity.go holds the CSI Identity service: the three calls a plugin4// answers before it holds any volume.56import (7	"context"8	"os"910	"github.com/container-storage-interface/spec/lib/go/csi"11	"google.golang.org/protobuf/types/known/wrapperspb"12)1314// driverName is the name every volume and the CSIDriver object use to15// select this driver.16const driverName = "git.liken.sh"1718// identity answers the Identity service. Its only state is the store19// path, because readiness is whether the store takes a write.20type identity struct {21	csi.UnimplementedIdentityServer22	store string23}2425func (i *identity) GetPluginInfo(26	context.Context, *csi.GetPluginInfoRequest,27) (*csi.GetPluginInfoResponse, error) {28	return &csi.GetPluginInfoResponse{Name: driverName, VendorVersion: version}, nil29}3031// GetPluginCapabilities declares nothing. The controller service32// arrives with plan 05, and the driver has no topology, because a33// checkout is made on whichever node publishes it.34func (i *identity) GetPluginCapabilities(35	context.Context, *csi.GetPluginCapabilitiesRequest,36) (*csi.GetPluginCapabilitiesResponse, error) {37	return &csi.GetPluginCapabilitiesResponse{}, nil38}3940// Probe reports ready when the store takes a write. Every repository41// and work tree lives in the store, so a driver that cannot write there42// can do nothing.43func (i *identity) Probe(context.Context, *csi.ProbeRequest) (*csi.ProbeResponse, error) {44	return &csi.ProbeResponse{Ready: wrapperspb.Bool(i.storeIsWriteable())}, nil45}4647// storeIsWriteable creates and removes a file instead of reading the48// directory's mode. A mode says what a user may do, and a write says49// what this process did.50func (i *identity) storeIsWriteable() bool {51	file, err := os.CreateTemp(i.store, ".probe-")52	if err != nil {53		return false54	}55	file.Close()56	os.Remove(file.Name())57	return true58}
main.go 90.9%
1// git-csi-driver is the CSI driver named git.liken.sh. It mounts git2// repositories as volumes. This file holds the command line and the3// run that serves the socket until the pod stops.4package main56import (7	"context"8	"errors"9	"flag"10	"fmt"11	"io"12	"log/slog"13	"os"14	"os/signal"15	"syscall"16)1718// version is the release the binary was built from. The Dockerfile sets19// it with -ldflags "-X main.version=...", and every other build reports20// dev.21var version = "dev"2223func main() {24	os.Exit(run(context.Background(), os.Args[1:], os.Stdout))25}2627// run parses the command line, serves the socket, and returns the exit28// code. The context is the run's life: a signal ends it in the pod, and29// a test ends it the same way.30func run(ctx context.Context, args []string, out io.Writer) int {31	cfg, err := parse(args, out)32	if err != nil {33		return 134	}35	if cfg == nil {36		return 037	}3839	ctx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)40	defer stop()4142	server, err := newServer(ctx, cfg, slog.Default())43	if err != nil {44		fmt.Fprintln(out, err)45		return 146	}47	if err := server.serve(ctx); err != nil {48		fmt.Fprintln(out, err)49		return 150	}51	return 052}5354// config is what the command line resolves to.55type config struct {56	endpoint string57	nodeID   string58	store    string59	metrics  string60}6162// parse reads the command line and reports every problem to out. When63// the arguments ask for the version alone, it prints it and answers a64// nil config with a nil error, because there is nothing to run.65func parse(args []string, out io.Writer) (*config, error) {66	flags := flag.NewFlagSet("git-csi-driver", flag.ContinueOnError)67	flags.SetOutput(out)6869	endpoint := flags.String("endpoint", "unix:///csi/csi.sock",70		"the address the CSI socket listens on")71	nodeID := flags.String("node-id", "",72		"the name of the node this plugin runs on")73	store := flags.String("store", "/var/lib/liken/pod-storage/git-csi",74		"the directory that holds the repositories and work trees")75	// An empty --metrics serves no metrics.76	metrics := flags.String("metrics", ":9808",77		"the address the metrics listener takes; empty serves none")78	showVersion := flags.Bool("version", false, "print the version and exit")7980	if err := flags.Parse(args); err != nil {81		return nil, err82	}83	if *showVersion {84		fmt.Fprintln(out, version)85		return nil, nil86	}87	if *nodeID == "" {88		err := errors.New("--node-id is required")89		fmt.Fprintln(out, err)90		return nil, err91	}92	return &config{93		endpoint: *endpoint,94		nodeID:   *nodeID,95		store:    *store,96		metrics:  *metrics,97	}, nil98}
metrics.go 100.0%
1package main23// metrics.go holds the gauges the node plugin exports and the listener4// that serves them. Every fact the driver reports reaches the5// condition, the Events, and these numbers.67import (8	"context"9	"log/slog"10	"net"11	"net/http"12	"time"1314	"github.com/prometheus/client_golang/prometheus"15	"github.com/prometheus/client_golang/prometheus/promhttp"16)1718// metricsDeadline bounds a request's headers and the listener's stop.19const metricsDeadline = 30 * time.Second2021// metrics is the registry the listener serves and the two gauges plan22// 04 fills. Plan 05 adds the rest of the design's list.23type metrics struct {24	registry *prometheus.Registry25	armed    *prometheus.GaugeVec26	pending  *prometheus.GaugeVec27}2829// metricLabels name the claim a person would look up.30var metricLabels = []string{"namespace", "claim"}3132func newMetrics() *metrics {33	readings := &metrics{34		registry: prometheus.NewRegistry(),35		// One when a class of this driver arms the volume, zero when none36		// does.37		armed: prometheus.NewGaugeVec(38			prometheus.GaugeOpts{Name: "git_csi_armed", Help: "One when a class of the driver arms the volume, zero when none does."}, metricLabels),39		// How many paths the last scan found that the driver has not40		// committed.41		pending: prometheus.NewGaugeVec(42			prometheus.GaugeOpts{Name: "git_csi_pending_paths", Help: "Paths the last scan found that the driver has not committed."}, metricLabels),43	}44	readings.registry.MustRegister(readings.armed, readings.pending)45	return readings46}4748// record puts the volume's state on the gauges. A volume whose claim49// the driver has not found has no labels, so it reports nothing yet.50func (m *metrics) record(held *volume) {51	claim, armed, pending := held.reading()52	if m == nil || claim.name == "" {53		return54	}55	value := 0.056	if armed {57		value = 158	}59	m.armed.WithLabelValues(claim.namespace, claim.name).Set(value)60	m.pending.WithLabelValues(claim.namespace, claim.name).Set(float64(pending))61}6263// forget takes a volume off the gauges, so a claim that is gone stops64// being reported.65func (m *metrics) forget(held *volume) {66	claim, _, _ := held.reading()67	if m == nil || claim.name == "" {68		return69	}70	m.armed.DeleteLabelValues(claim.namespace, claim.name)71	m.pending.DeleteLabelValues(claim.namespace, claim.name)72}7374// listen opens the address --metrics names. An empty address serves no75// metrics, which is what a driver under test does.76func (m *metrics) listen(address string) (net.Listener, error) {77	if address == "" {78		return nil, nil79	}80	return net.Listen("tcp", address)81}8283// handler serves the registry at /metrics and nothing else.84func (m *metrics) handler() http.Handler {85	served := http.NewServeMux()86	served.Handle("/metrics", promhttp.HandlerFor(m.registry, promhttp.HandlerOpts{}))87	return served88}8990// serveMetrics answers on the listener until the run ends.91func serveMetrics(ctx context.Context, listener net.Listener, readings *metrics, logger *slog.Logger) {92	serving := &http.Server{93		Handler:           readings.handler(),94		ReadHeaderTimeout: metricsDeadline,95	}96	go func() {97		<-ctx.Done()98		_ = serving.Close()99	}()100	if err := serving.Serve(listener); err != nil && ctx.Err() == nil {101		logger.WarnContext(ctx, "the metrics listener stopped", "error", err)102	}103}
mount.go 100.0%
1package main23// mount.go holds the bind mount that puts a tree at the kubelet's4// target path.56import (7	"errors"8	"fmt"910	"golang.org/x/sys/unix"11)1213// mountSyscalls is the two syscalls the driver makes. It is an interface14// so a test without privilege can watch the calls the driver would make.15// Everything else the driver does to a tree is tested for real.16type mountSyscalls interface {17	Mount(source, target, filesystem string, flags uintptr, data string) error18	Unmount(target string, flags int) error19}2021// kernelMounts is the kernel's own syscalls, which the driver uses in22// its pod.23type kernelMounts struct{}2425func (kernelMounts) Mount(source, target, filesystem string, flags uintptr, data string) error {26	return unix.Mount(source, target, filesystem, flags, data)27}2829func (kernelMounts) Unmount(target string, flags int) error {30	return unix.Unmount(target, flags)31}3233// bindReadOnly binds source onto target and makes it read-only. Two34// steps, because the kernel makes the bind first and reads MS_RDONLY35// only on a remount of it. A failed remount unbinds, so a target never36// stays writeable by accident.37func bindReadOnly(calls mountSyscalls, source, target string) error {38	if err := calls.Mount(source, target, "", unix.MS_BIND, ""); err != nil {39		return fmt.Errorf("bind %s onto %s: %w", source, target, err)40	}41	if err := calls.Mount(source, target, "",42		unix.MS_BIND|unix.MS_REMOUNT|unix.MS_RDONLY, ""); err != nil {43		_ = calls.Unmount(target, unix.MNT_DETACH)44		return fmt.Errorf("remount %s read-only: %w", target, err)45	}46	return nil47}4849// unbind detaches the mount at target. A target that holds no mount is50// not an error, so an unpublish the kubelet repeats answers the same51// way twice.52func unbind(calls mountSyscalls, target string) error {53	err := calls.Unmount(target, unix.MNT_DETACH)54	if err == nil || errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ENOENT) {55		return nil56	}57	return fmt.Errorf("unmount %s: %w", target, err)58}5960// bindReadWrite binds source onto target with one call. The pod writes61// the tree, and the driver reads what it wrote.62func bindReadWrite(calls mountSyscalls, source, target string) error {63	if err := calls.Mount(source, target, "", unix.MS_BIND, ""); err != nil {64		return fmt.Errorf("bind %s onto %s: %w", source, target, err)65	}66	return nil67}
node.go 100.0%
1package main23// node.go holds the CSI Node service: the calls the kubelet makes to4// put a volume under a pod and take it away.56import (7	"context"8	"fmt"9	"log/slog"10	"os"11	"path/filepath"12	"strings"13	"sync"14	"time"1516	"github.com/container-storage-interface/spec/lib/go/csi"17	"golang.org/x/sys/unix"18	"google.golang.org/grpc/codes"19	"google.golang.org/grpc/status"20	corev1 "k8s.io/api/core/v1"21)2223// node answers the Node service and holds what this node has published.24type node struct {25	csi.UnimplementedNodeServer26	nodeID string27	store  *store28	mounts mountSyscalls29	events *events30	arms   *arming31	// The gauges every volume reports itself on.32	readings *metrics33	logger   *slog.Logger34	base     context.Context35	// How long a tree rests before the driver reads what is pending, and36	// how often it reads that anyway.37	quiesce time.Duration38	sweep   time.Duration39	// mounted asks the kernel whether a path is still a mount. A restarted40	// driver asks it about every record.41	mounted func(string) bool42	// inotify opens the file the watch reads. It is a seam, so a test can43	// drive a driver whose kernel refused one.44	inotify func(int) (int, error)4546	mu        sync.Mutex47	volumes   map[string]*volume48	followers map[string]*follower49	// The writeable volumes this node has staged, and the loops that watch50	// and arm them.51	staged   map[string]*volume52	watchers map[string]*watcher53	armings  map[string]context.CancelFunc54}5556// newNode builds the service. base is the driver's run, so every fetch57// loop ends when the pod stops.58func newNode(base context.Context, cfg *config, posting *events, readings *metrics, logger *slog.Logger) *node {59	answering := &node{60		nodeID:    cfg.nodeID,61		store:     newStore(cfg.store),62		mounts:    kernelMounts{},63		events:    posting,64		readings:  readings,65		logger:    logger,66		base:      base,67		quiesce:   defaultQuiesce,68		sweep:     defaultSweep,69		mounted:   mountedNow,70		inotify:   unix.InotifyInit1,71		volumes:   map[string]*volume{},72		followers: map[string]*follower{},73		staged:    map[string]*volume{},74		watchers:  map[string]*watcher{},75		armings:   map[string]context.CancelFunc{},76	}77	answering.arms = newArming(answering, posting.client, logger)78	return answering79}8081// NodeGetInfo names the node and no topology. A checkout is made on82// whichever node publishes it, so no node is closer to a volume than83// another.84func (n *node) NodeGetInfo(85	context.Context, *csi.NodeGetInfoRequest,86) (*csi.NodeGetInfoResponse, error) {87	return &csi.NodeGetInfoResponse{NodeId: n.nodeID}, nil88}8990// NodeGetCapabilities declares what the kubelet may ask of this node.91// STAGE_UNSTAGE_VOLUME makes the kubelet stage a persistent volume92// once per node before it publishes it per pod. GET_VOLUME_STATS makes93// it poll NodeGetVolumeStats, and VOLUME_CONDITION makes it read the94// condition in that answer.95func (n *node) NodeGetCapabilities(96	context.Context, *csi.NodeGetCapabilitiesRequest,97) (*csi.NodeGetCapabilitiesResponse, error) {98	declared := []csi.NodeServiceCapability_RPC_Type{99		csi.NodeServiceCapability_RPC_STAGE_UNSTAGE_VOLUME,100		csi.NodeServiceCapability_RPC_GET_VOLUME_STATS,101		csi.NodeServiceCapability_RPC_VOLUME_CONDITION,102	}103	capabilities := make([]*csi.NodeServiceCapability, 0, len(declared))104	for _, rpc := range declared {105		capabilities = append(capabilities, &csi.NodeServiceCapability{106			Type: &csi.NodeServiceCapability_Rpc{107				Rpc: &csi.NodeServiceCapability_RPC{Type: rpc},108			},109		})110	}111	return &csi.NodeGetCapabilitiesResponse{Capabilities: capabilities}, nil112}113114// NodePublishVolume binds a tree under the pod: a read-only volume's115// checkout of the ref, made here, or a writeable volume's work tree,116// made at stage. A repeated call for a published volume answers117// success, because the kubelet retries.118func (n *node) NodePublishVolume(119	ctx context.Context, request *csi.NodePublishVolumeRequest,120) (*csi.NodePublishVolumeResponse, error) {121	id := request.GetVolumeId()122	target := request.GetTargetPath()123	switch {124	case id == "":125		return nil, status.Error(codes.InvalidArgument, "volume_id: the call names no volume")126	case strings.ContainsRune(id, filepath.Separator):127		return nil, status.Error(codes.InvalidArgument, "volume_id: a volume id is one path element")128	case target == "":129		return nil, status.Error(codes.InvalidArgument, "target_path: the call names no path")130	}131132	parsed, err := parseAttributes(request)133	if err != nil {134		n.refused(ctx, podOf(request.GetVolumeContext()), err)135		return nil, err136	}137	holder, err := parseCredentials(request.GetSecrets())138	if err != nil {139		n.refused(ctx, parsed.pod, err)140		return nil, err141	}142	if !parsed.ephemeral {143		if err := n.publishStaged(ctx, request, parsed, holder); err != nil {144			n.refused(ctx, parsed.pod, err)145			return nil, err146		}147		return &csi.NodePublishVolumeResponse{}, nil148	}149150	n.mu.Lock()151	published, found := n.volumes[id]152	n.mu.Unlock()153	if found {154		if published.target != target {155			return nil, status.Errorf(codes.FailedPrecondition,156				"volume_id: %s is published at %s", id, published.target)157		}158		return &csi.NodePublishVolumeResponse{}, nil159	}160161	directory := n.store.volumeDir(id)162	mounting := &volume{163		id:          id,164		attributes:  parsed,165		credentials: holder,166		directory:   directory,167		tree:        filepath.Join(directory, "tree"),168		target:      target,169		pod:         parsed.pod,170	}171	if err := n.publish(ctx, mounting); err != nil {172		n.refused(ctx, parsed.pod, err)173		_ = os.RemoveAll(directory)174		return nil, err175	}176	n.record(ctx, mounting, request.GetVolumeContext())177178	n.mu.Lock()179	n.volumes[id] = mounting180	n.follow(mounting)181	n.mu.Unlock()182	return &csi.NodePublishVolumeResponse{}, nil183}184185// publish makes the volume's directory, stages the ref into it, and186// binds the tree onto the target path.187func (n *node) publish(ctx context.Context, mounting *volume) error {188	if err := os.MkdirAll(mounting.directory, 0o700); err != nil {189		return status.Error(codes.Internal, err.Error())190	}191	if err := n.stage(ctx, mounting); err != nil {192		return err193	}194	if err := os.MkdirAll(mounting.target, 0o755); err != nil {195		return status.Error(codes.Internal, err.Error())196	}197	// A driver that restarted left its mounts behind, so the target198	// comes away before the new bind goes on.199	if err := unbind(n.mounts, mounting.target); err != nil {200		return status.Error(codes.Internal, err.Error())201	}202	if err := bindReadOnly(n.mounts, mounting.tree, mounting.target); err != nil {203		return status.Error(codes.Internal, err.Error())204	}205	return nil206}207208// stage fetches the ref into the shared bare repository and places it209// in the volume's own tree. offline decides what a failed fetch means:210// refuse fails the publish, and allowStale publishes what the store211// holds and reports the failure. A repository the store never fetched212// is refused under both, because there is nothing to publish.213func (n *node) stage(ctx context.Context, mounting *volume) error {214	repo := n.store.repository(mounting.attributes.url)215	defer repo.lock()()216217	first := !repo.exists()218	if first {219		if err := repo.create(ctx); err != nil {220			return status.Error(codes.Internal, err.Error())221		}222	}223	// depth applies to the first fetch of a repository. A later volume224	// with a depth of its own reuses what is there.225	depth := 0226	if first {227		depth = mounting.attributes.depth228	}229230	env, remove, err := mounting.credentials.use(mounting.directory)231	if err != nil {232		return status.Error(codes.Internal, err.Error())233	}234	fetchErr := repo.fetch(ctx, env, mounting.attributes.ref, depth)235	remove()236237	commit, resolveErr := repo.resolve(ctx, mounting.attributes.ref)238	switch {239	case fetchErr != nil && mounting.attributes.offline == offlineRefuse:240		return status.Error(codes.Unavailable, fetchErr.Error())241	case fetchErr != nil && resolveErr != nil:242		return status.Errorf(codes.Unavailable, "%s, and the node holds no copy of %s",243			fetchErr, mounting.attributes.ref)244	case resolveErr != nil:245		return status.Error(codes.Internal, resolveErr.Error())246	}247248	if err := repo.place(ctx, commit, mounting.directory, mounting.tree); err != nil {249		return status.Error(codes.Internal, err.Error())250	}251	mounting.reportCommit(commit)252	if fetchErr != nil {253		mounting.reportTrouble(fetchErr.Error())254		n.events.post(ctx, mounting.attributes.pod, corev1.EventTypeWarning, reasonStale,255			fmt.Sprintf("%s is published from the node's copy at %s: %s",256				mounting.attributes.ref, short(commit), fetchErr))257	}258	return nil259}260261// NodeUnpublishVolume takes the mount away, and a read-only volume's262// directory with it. A writeable volume's work tree stays for the next263// pod on this node, and the bare repository stays for the next volume264// of the same URL.265func (n *node) NodeUnpublishVolume(266	ctx context.Context, request *csi.NodeUnpublishVolumeRequest,267) (*csi.NodeUnpublishVolumeResponse, error) {268	id := request.GetVolumeId()269	target := request.GetTargetPath()270	switch {271	case id == "":272		return nil, status.Error(codes.InvalidArgument, "volume_id: the call names no volume")273	case target == "":274		return nil, status.Error(codes.InvalidArgument, "target_path: the call names no path")275	}276277	n.mu.Lock()278	published, found := n.volumes[id]279	if found {280		delete(n.volumes, id)281		n.unfollow(published)282		n.unwatch(published)283	}284	n.mu.Unlock()285286	if err := unbind(n.mounts, target); err != nil {287		return nil, status.Error(codes.Internal, err.Error())288	}289	if err := os.RemoveAll(target); err != nil {290		return nil, status.Error(codes.Internal, err.Error())291	}292	// A writeable volume gives up only its record of the mount. It stays293	// on the gauges while it is staged, because it is still this node's294	// volume.295	if found && published.writeable {296		n.forget(published)297	}298	if found && !published.writeable {299		if err := os.RemoveAll(published.directory); err != nil {300			return nil, status.Error(codes.Internal, err.Error())301		}302	}303	n.logger.InfoContext(ctx, "unpublished", "volume", id)304	return &csi.NodeUnpublishVolumeResponse{}, nil305}306307// NodeGetVolumeStats reports the tree's size as used. A git volume has308// no free space to report, so available is zero.309//310// The condition is what the volume reports, in its order: trouble, then311// unarmed work, then the commit.312func (n *node) NodeGetVolumeStats(313	_ context.Context, request *csi.NodeGetVolumeStatsRequest,314) (*csi.NodeGetVolumeStatsResponse, error) {315	id := request.GetVolumeId()316	switch {317	case id == "":318		return nil, status.Error(codes.InvalidArgument, "volume_id: the call names no volume")319	case request.GetVolumePath() == "":320		return nil, status.Error(codes.InvalidArgument, "volume_path: the call names no path")321	}322323	n.mu.Lock()324	published, found := n.volumes[id]325	n.mu.Unlock()326	if !found {327		return nil, status.Errorf(codes.NotFound, "volume_id: %s is not published on this node", id)328	}329330	size, err := treeSize(published.tree)331	if err != nil {332		return nil, status.Error(codes.Internal, err.Error())333	}334	abnormal, message := published.report()335	return &csi.NodeGetVolumeStatsResponse{336		Usage: []*csi.VolumeUsage{{337			Unit:      csi.VolumeUsage_BYTES,338			Used:      size,339			Available: 0,340			Total:     size,341		}},342		VolumeCondition: &csi.VolumeCondition{Abnormal: abnormal, Message: message},343	}, nil344}345346func (n *node) NodeExpandVolume(347	context.Context, *csi.NodeExpandVolumeRequest,348) (*csi.NodeExpandVolumeResponse, error) {349	return nil, unimplemented("NodeExpandVolume", "never; git volumes have no size")350}351352// refused posts the refusal on the pod, so a person who describes the353// pod sees why it stays in ContainerCreating.354func (n *node) refused(ctx context.Context, pod podReference, err error) {355	n.events.post(ctx, pod, corev1.EventTypeWarning, reasonRefused, status.Convert(err).Message())356}357358// podOf reads the pod straight from the volume context, so a refused359// parse still knows where its Event goes.360func podOf(context map[string]string) podReference {361	return podReference{362		name:      context[podNameKey],363		namespace: context[podNamespaceKey],364		uid:       context[podUIDKey],365	}366}367368// short is the seven characters git itself prints for a commit.369func short(commit string) string {370	if len(commit) > 7 {371		return commit[:7]372	}373	return commit374}375376// unimplemented answers a call the driver does not serve yet. The377// message names the plan that adds it, so a reader of the log learns378// when the call will work, not only that it does not.379func unimplemented(rpc, when string) error {380	return status.Errorf(codes.Unimplemented, "%s: %s", rpc, when)381}
records.go 98.4%
1package main23// records.go is what a restarted driver reads to find the volumes it4// published. The mounts belong to the kernel and survive the driver;5// its own set does not.67import (8	"bufio"9	"context"10	"encoding/json"11	"io"12	"os"13	"path/filepath"14	"strings"15)1617// recordFile is the file each volume's directory carries beside its18// tree.19const recordFile = "volume.json"2021// record is one published volume as the store holds it. The credentials22// are not in it. A Secret reaches the driver through the kubelet, and23// the node's disk is not where it belongs.24type record struct {25	VolumeID    string            `json:"volumeId"`26	Attributes  map[string]string `json:"attributes"`27	Target      string            `json:"targetPath"`28	Staging     string            `json:"stagingPath,omitempty"`29	Ephemeral   bool              `json:"ephemeral"`30	Credentials bool              `json:"credentials"`31}3233// record writes the volume's own record. A record the driver cannot34// write is logged and nothing more, because a mount is worth more than35// the record of it.36func (n *node) record(ctx context.Context, published *volume, attributes map[string]string) {37	written := &record{38		VolumeID:    published.id,39		Attributes:  attributes,40		Target:      published.target,41		Staging:     published.staging,42		Ephemeral:   !published.writeable,43		Credentials: published.credentials != nil,44	}45	content, err := json.Marshal(written)46	if err == nil {47		err = os.WriteFile(filepath.Join(published.directory, recordFile), content, 0o600)48	}49	if err != nil {50		n.logger.WarnContext(ctx, "the volume's record was not written",51			"volume", published.id, "error", err)52	}53}5455// forget removes the record, so a driver that starts after this56// unpublish does not resume a mount the kubelet took away.57func (n *node) forget(published *volume) {58	if err := os.Remove(filepath.Join(published.directory, recordFile)); err != nil {59		n.logger.Warn("the volume's record was not removed", "volume", published.id, "error", err)60	}61}6263// resume rebuilds the driver's set from the store. A target that is64// still a mount is a volume a pod still reads, so its loops start65// again. A target that is not is a read-only volume the store may drop,66// or a work tree that may hold work nobody has pushed.67func (n *node) resume(ctx context.Context) {68	volumes := filepath.Join(n.store.root, "volumes")69	entries, err := os.ReadDir(volumes)70	if err != nil {71		return72	}73	for _, entry := range entries {74		directory := filepath.Join(volumes, entry.Name())75		held, err := readRecord(filepath.Join(directory, recordFile))76		if err != nil {77			continue78		}79		if !n.mounted(held.Target) {80			n.drop(ctx, held, directory)81			continue82		}83		n.resumeOne(ctx, held, directory)84	}85}8687// drop removes a read-only volume whose mount is gone, because it88// leaves nothing worth keeping. A writeable one keeps its tree until89// plan 06's sweep or a person takes it.90func (n *node) drop(ctx context.Context, held *record, directory string) {91	if !held.Ephemeral {92		return93	}94	if err := os.RemoveAll(directory); err != nil {95		n.logger.WarnContext(ctx, "the volume's directory stayed",96			"volume", held.VolumeID, "error", err)97	}98}99100// resumeOne rebuilds one volume and starts the loops it had. It holds101// no credential: the Secret came with a call the kubelet makes again102// only when the pod restarts, so a fetch that needs one fails and the103// condition says so.104func (n *node) resumeOne(ctx context.Context, held *record, directory string) {105	parsed, err := parseVolumeContext(held.Attributes)106	if err != nil {107		n.logger.WarnContext(ctx, "the volume's record was not read",108			"volume", held.VolumeID, "error", err)109		return110	}111	resumed := &volume{112		id:         held.VolumeID,113		attributes: parsed,114		directory:  directory,115		tree:       filepath.Join(directory, "tree"),116		target:     held.Target,117		staging:    held.Staging,118		writeable:  !held.Ephemeral,119		pod:        parsed.pod,120	}121	if held.Credentials {122		resumed.reportTrouble("the driver restarted and holds no credential for this volume")123	}124125	n.mu.Lock()126	defer n.mu.Unlock()127	n.volumes[resumed.id] = resumed128	if held.Ephemeral {129		n.follow(resumed)130		return131	}132	resumed.work = n.store.workTree(n.store.repository(parsed.url), resumed.id)133	if commit, err := resumed.work.head(ctx); err == nil {134		resumed.commit = commit135	}136	n.staged[resumed.id] = resumed137	n.arm(resumed)138	n.watch(resumed)139}140141// readRecord reads one volume's record.142func readRecord(path string) (*record, error) {143	content, err := os.ReadFile(path)144	if err != nil {145		return nil, err146	}147	held := &record{}148	if err := json.Unmarshal(content, held); err != nil {149		return nil, err150	}151	return held, nil152}153154// mountedNow asks the kernel whether the path is still a mount. Only155// the mount table says what is still there.156func mountedNow(path string) bool {157	table, err := os.Open("/proc/self/mountinfo")158	if err != nil {159		return false160	}161	defer table.Close()162	return mountedIn(table, path)163}164165// mountedIn reads mountinfo. The fifth field of every line is the mount166// point, with a space, a tab, a newline, and a backslash written as167// octal escapes.168func mountedIn(table io.Reader, path string) bool {169	lines := bufio.NewScanner(table)170	for lines.Scan() {171		fields := strings.Fields(lines.Text())172		if len(fields) > 4 && mountEscapes.Replace(fields[4]) == path {173			return true174		}175	}176	return false177}178179var mountEscapes = strings.NewReplacer(`\040`, " ", `\011`, "\t", `\012`, "\n", `\134`, `\`)
server.go 97.2%
1package main23// server.go holds the socket the kubelet connects to, the gRPC server4// that answers on it, and the log line every call writes.56import (7	"context"8	"errors"9	"fmt"10	"io/fs"11	"log/slog"12	"net"13	"os"14	"strings"1516	"github.com/container-storage-interface/spec/lib/go/csi"17	"google.golang.org/grpc"18	"google.golang.org/grpc/status"19)2021// endpointScheme is the only scheme --endpoint accepts. The kubelet22// reaches a CSI plugin through a Unix socket in its plugins directory,23// and nothing else connects to this server.24const endpointScheme = "unix://"2526// server is the listening socket and the gRPC server registered on it.27type server struct {28	grpc     *grpc.Server29	listener net.Listener30	// The gauges and the listener that serves them. An empty --metrics31	// leaves the listener nil.32	readings *metrics33	metrics  net.Listener34	logger   *slog.Logger35}3637// newServer makes the store, takes the socket, and registers the38// Identity and Node services. It fails before it listens when the store39// cannot be made, because a driver with no store can hold no volume.40//41// newServer makes the store, takes the socket, and registers the42// Identity and Node services. ctx is the driver's run, and every fetch43// loop the Node service starts ends with it.44func newServer(ctx context.Context, cfg *config, logger *slog.Logger) (*server, error) {45	socket, found := strings.CutPrefix(cfg.endpoint, endpointScheme)46	if !found {47		return nil, fmt.Errorf("--endpoint %q does not begin with %s", cfg.endpoint, endpointScheme)48	}49	if err := os.MkdirAll(cfg.store, 0o755); err != nil {50		return nil, err51	}5253	// A pod that was killed leaves its socket file on the node, and the54	// next pod has to bind the same path. The file is removed, not55	// reported, because no other process ever owns it.56	if err := os.Remove(socket); err != nil && !errors.Is(err, fs.ErrNotExist) {57		return nil, err58	}59	listener, err := net.Listen("unix", socket)60	if err != nil {61		return nil, err62	}6364	readings := newMetrics()65	answering := newNode(ctx, cfg, newEvents(cfg.nodeID, logger), readings, logger)66	// The mounts outlive the driver, so a driver that starts takes back67	// the volumes its store still records.68	answering.resume(ctx)6970	registered := grpc.NewServer(grpc.UnaryInterceptor(logCalls(logger)))71	csi.RegisterIdentityServer(registered, &identity{store: cfg.store})72	csi.RegisterNodeServer(registered, answering)7374	metricsListener, err := readings.listen(cfg.metrics)75	if err != nil {76		_ = listener.Close()77		return nil, err78	}79	return &server{80		grpc:     registered,81		listener: listener,82		readings: readings,83		metrics:  metricsListener,84		logger:   logger,85	}, nil86}8788// serve blocks until the context ends, then stops the server and lets89// a call in flight finish.90func (s *server) serve(ctx context.Context) error {91	served := make(chan error, 1)92	go func() { served <- s.grpc.Serve(s.listener) }()93	if s.metrics != nil {94		go serveMetrics(ctx, s.metrics, s.readings, s.logger)95	}96	select {97	case err := <-served:98		return err99	case <-ctx.Done():100		s.grpc.GracefulStop()101		// A context that is over before Serve reaches the socket makes102		// Serve return ErrServerStopped. That is the stop this run asked103		// for, not a failure.104		if err := <-served; err != nil && !errors.Is(err, grpc.ErrServerStopped) {105			return err106		}107		return nil108	}109}110111// logCalls writes one line per RPC with its name and its status code.112// The kubelet's calls are the driver's whole input, and a person who113// reads the log has to see them.114func logCalls(logger *slog.Logger) grpc.UnaryServerInterceptor {115	return func(116		ctx context.Context,117		request any,118		call *grpc.UnaryServerInfo,119		handle grpc.UnaryHandler,120	) (any, error) {121		answer, err := handle(ctx, request)122		logger.InfoContext(ctx, "call",123			"rpc", call.FullMethod,124			"code", status.Code(err).String())125		return answer, err126	}127}
stage.go 100.0%
1package main23// stage.go holds the calls the kubelet makes for a writeable volume:4// the stage that brings the work tree to the ref, and the publish that5// binds it under the pod.67import (8	"context"9	"fmt"10	"os"11	"path/filepath"12	"strings"1314	"github.com/container-storage-interface/spec/lib/go/csi"15	"google.golang.org/grpc/codes"16	"google.golang.org/grpc/status"17)1819// NodeStageVolume fetches the ref, makes the work tree on the volume's20// first stage on this node, and starts the loop that reads the claim.21func (n *node) NodeStageVolume(22	ctx context.Context, request *csi.NodeStageVolumeRequest,23) (*csi.NodeStageVolumeResponse, error) {24	id := request.GetVolumeId()25	staging := request.GetStagingTargetPath()26	switch {27	case id == "":28		return nil, status.Error(codes.InvalidArgument, "volume_id: the call names no volume")29	case strings.ContainsRune(id, filepath.Separator):30		return nil, status.Error(codes.InvalidArgument, "volume_id: a volume id is one path element")31	case staging == "":32		return nil, status.Error(codes.InvalidArgument, "staging_target_path: the call names no path")33	}34	if err := checkAccessMode(request.GetVolumeCapability()); err != nil {35		return nil, err36	}37	parsed, err := parseStageAttributes(request.GetVolumeContext())38	if err != nil {39		return nil, err40	}41	holder, err := parseCredentials(request.GetSecrets())42	if err != nil {43		return nil, err44	}4546	n.mu.Lock()47	staged, found := n.staged[id]48	n.mu.Unlock()49	if found {50		if staged.staging != staging {51			return nil, status.Errorf(codes.FailedPrecondition,52				"volume_id: %s is staged at %s", id, staged.staging)53		}54		return &csi.NodeStageVolumeResponse{}, nil55	}5657	directory := n.store.volumeDir(id)58	if err := os.MkdirAll(directory, 0o700); err != nil {59		return nil, status.Error(codes.Internal, err.Error())60	}61	repo := n.store.repository(parsed.url)62	arriving := &volume{63		id:          id,64		attributes:  parsed,65		credentials: holder,66		directory:   directory,67		tree:        filepath.Join(directory, "tree"),68		work:        n.store.workTree(repo, id),69		staging:     staging,70		writeable:   true,71	}72	if err := n.stageTree(ctx, arriving, repo); err != nil {73		return nil, err74	}7576	n.mu.Lock()77	n.staged[id] = arriving78	n.arm(arriving)79	n.mu.Unlock()80	return &csi.NodeStageVolumeResponse{}, nil81}8283// checkAccessMode refuses every mode but the one ReadWriteOncePod asks84// for. The driver pushes what one writer wrote, and ReadWriteOnce85// allows two pods on one node.86func checkAccessMode(capability *csi.VolumeCapability) error {87	mode := capability.GetAccessMode().GetMode()88	if mode != csi.VolumeCapability_AccessMode_SINGLE_NODE_SINGLE_WRITER {89		return status.Errorf(codes.InvalidArgument,90			"access_mode: %s is not SINGLE_NODE_SINGLE_WRITER, which is ReadWriteOncePod", mode)91	}92	return nil93}9495// stageTree fetches the ref into the shared bare repository and brings96// the work tree to it. A tree that already exists is left as the last97// pod left it. A ref that moved under it is reported in the condition;98// plan 06 reconciles it.99func (n *node) stageTree(ctx context.Context, staging *volume, repo *repository) error {100	defer repo.lock()()101102	if !repo.exists() {103		if err := repo.create(ctx); err != nil {104			return status.Error(codes.Internal, err.Error())105		}106	}107	env, remove, err := staging.credentials.use(staging.directory)108	if err != nil {109		return status.Error(codes.Internal, err.Error())110	}111	fetchErr := repo.fetch(ctx, env, staging.attributes.ref, 0)112	remove()113	if fetchErr != nil {114		return status.Error(codes.Unavailable, fetchErr.Error())115	}116	commit, err := repo.resolve(ctx, staging.attributes.ref)117	if err != nil {118		return status.Error(codes.Internal, err.Error())119	}120121	if !staging.work.exists() {122		if err := staging.work.create(ctx, staging.attributes.ref, commit); err != nil {123			return status.Error(codes.Internal, err.Error())124		}125		staging.reportCommit(commit)126		return nil127	}128	head, err := staging.work.head(ctx)129	if err != nil {130		return status.Error(codes.Internal, err.Error())131	}132	staging.reportCommit(head)133	if head != commit {134		staging.reportTrouble(fmt.Sprintf("upstream moved: %s is at %s and the tree is at %s",135			staging.attributes.ref, short(commit), short(head)))136	}137	return nil138}139140// NodeUnstageVolume stops the loops and keeps the work tree, because141// the next stage on this node starts from what the pod wrote.142func (n *node) NodeUnstageVolume(143	ctx context.Context, request *csi.NodeUnstageVolumeRequest,144) (*csi.NodeUnstageVolumeResponse, error) {145	id := request.GetVolumeId()146	switch {147	case id == "":148		return nil, status.Error(codes.InvalidArgument, "volume_id: the call names no volume")149	case request.GetStagingTargetPath() == "":150		return nil, status.Error(codes.InvalidArgument, "staging_target_path: the call names no path")151	}152153	n.mu.Lock()154	staged, found := n.staged[id]155	if found {156		delete(n.staged, id)157		n.disarm(staged)158	}159	n.mu.Unlock()160	n.logger.InfoContext(ctx, "unstaged", "volume", id)161	return &csi.NodeUnstageVolumeResponse{}, nil162}163164// publishStaged binds the work tree read-write under the pod. A volume165// the kubelet never staged is refused, because the tree it would bind166// does not exist.167func (n *node) publishStaged(168	ctx context.Context,169	request *csi.NodePublishVolumeRequest,170	parsed *attributes,171	holder *credentials,172) error {173	id, target := request.GetVolumeId(), request.GetTargetPath()174	n.mu.Lock()175	staged, found := n.staged[id]176	published, standing := n.volumes[id]177	n.mu.Unlock()178	if !found {179		return status.Errorf(codes.FailedPrecondition,180			"volume_id: %s is not staged on this node", id)181	}182	if standing {183		if published.target != target {184			return status.Errorf(codes.FailedPrecondition,185				"volume_id: %s is published at %s", id, published.target)186		}187		return nil188	}189190	staged.setPod(parsed.pod)191	// A Secret named on the publish reaches the driver here and nowhere192	// else, so it replaces what the stage held.193	if holder != nil {194		staged.credentials = holder195	}196	if err := os.MkdirAll(target, 0o755); err != nil {197		return status.Error(codes.Internal, err.Error())198	}199	if err := unbind(n.mounts, target); err != nil {200		return status.Error(codes.Internal, err.Error())201	}202	if err := bindReadWrite(n.mounts, staged.tree, target); err != nil {203		return status.Error(codes.Internal, err.Error())204	}205	staged.target = target206	n.record(ctx, staged, request.GetVolumeContext())207208	n.mu.Lock()209	n.volumes[id] = staged210	n.watch(staged)211	n.mu.Unlock()212	return nil213}
store.go 100.0%
1package main23// store.go holds the driver's directories on the node: one bare4// repository per URL under repos/, and one published tree per volume5// under volumes/.67import (8	"context"9	"crypto/sha256"10	"encoding/hex"11	"os"12	"path/filepath"13	"strconv"14	"sync"15)1617// refPrefix is where a store repository keeps the refs it follows. The18// driver's own namespace keeps them apart from anything the remote19// names, and lets one bare repository serve volumes on different refs.20const refPrefix = "refs/git-csi/"2122// store is the root directory and one lock per repository, made on23// first use.24type store struct {25	root string2627	mu    sync.Mutex28	locks map[string]*sync.Mutex29}3031func newStore(root string) *store {32	return &store{root: root, locks: map[string]*sync.Mutex{}}33}3435// repository is one bare repository, shared by every volume of the same36// URL on this node.37type repository struct {38	store *store39	url   string40	name  string41	dir   string42}4344// repository names the directory by the sha256 of the URL, because a URL45// is not a file name. create writes the URL beside it for a reader.46func (s *store) repository(url string) *repository {47	sum := sha256.Sum256([]byte(url))48	name := hex.EncodeToString(sum[:])49	return &repository{50		store: s,51		url:   url,52		name:  name,53		dir:   filepath.Join(s.root, "repos", name),54	}55}5657// volumeDir is where a volume keeps its published tree and, around each58// git invocation, its credential files.59func (s *store) volumeDir(id string) string {60	return filepath.Join(s.root, "volumes", id)61}6263// lock takes this repository's lock and returns the release. A fetch64// and a publish of the same URL never run at once.65func (r *repository) lock() func() {66	r.store.mu.Lock()67	held, found := r.store.locks[r.name]68	if !found {69		held = &sync.Mutex{}70		r.store.locks[r.name] = held71	}72	r.store.mu.Unlock()7374	held.Lock()75	return held.Unlock76}7778// exists reports whether the store already holds this repository.79func (r *repository) exists() bool {80	_, err := os.Stat(filepath.Join(r.dir, "HEAD"))81	return err == nil82}8384// create makes the bare repository and writes the URL beside it, so a85// person can read the store without hashing anything.86func (r *repository) create(ctx context.Context) error {87	if err := os.MkdirAll(r.dir, 0o755); err != nil {88		return err89	}90	if _, err := runGit(ctx, r.dir, nil, "init", "--quiet", "--bare"); err != nil {91		return err92	}93	return os.WriteFile(filepath.Join(r.dir, "url"), []byte(r.url+"\n"), 0o644)94}9596// fetch moves the driver's own ref to what the remote holds now. depth97// applies only when the caller asks, which is the first fetch of a new98// repository.99func (r *repository) fetch(ctx context.Context, env []string, ref string, depth int) error {100	args := []string{"fetch", "--quiet", "--no-tags"}101	if depth > 0 {102		args = append(args, "--depth="+strconv.Itoa(depth))103	}104	args = append(args, r.url, "+"+ref+":"+refPrefix+ref)105	_, err := runGit(ctx, r.dir, env, args...)106	return err107}108109// resolve is the commit the driver's own ref names, or an error when110// the store holds no copy of the ref.111func (r *repository) resolve(ctx context.Context, ref string) (string, error) {112	output, err := runGit(ctx, r.dir, nil, "rev-parse", "--verify", "--end-of-options",113		refPrefix+ref+"^{commit}")114	if err != nil {115		return "", err116	}117	return trimLine(output.stdout), nil118}119120// checkout writes the commit into dir. The index file lives outside the121// tree and is removed after, so the tree holds only what the commit122// holds and no pod ever sees a git file.123func (r *repository) checkout(ctx context.Context, commit, dir string) error {124	if err := os.MkdirAll(dir, 0o755); err != nil {125		return err126	}127	index := dir + ".index"128	defer os.Remove(index)129130	_, err := runGit(ctx, r.dir, []string{"GIT_INDEX_FILE=" + index},131		"--work-tree="+dir, "-c", "core.bare=false",132		"checkout", "--force", commit, "--", ".")133	return err134}135136// nextTree is the checkout a placement makes beside the published tree.137const nextTree = "next"138139// place puts the commit in the published tree. A tree that is not there140// yet arrives whole, with one rename. A tree a pod already reads is141// replaced entry by entry, because a bind mount follows the directory142// the driver bound and not its name: a renamed directory leaves every143// reader on the old tree. So the new tree arrives inside the directory144// the pod holds, and each file appears in one rename.145func (r *repository) place(ctx context.Context, commit, directory, tree string) error {146	fresh := filepath.Join(directory, nextTree)147	if err := os.RemoveAll(fresh); err != nil {148		return err149	}150	if err := r.checkout(ctx, commit, fresh); err != nil {151		return err152	}153	if _, err := os.Stat(tree); err != nil {154		return os.Rename(fresh, tree)155	}156	if err := replaceTree(fresh, tree); err != nil {157		return err158	}159	// The pod already reads the new tree, and the next placement removes160	// this checkout again, so a removal that fails is not a failure of161	// the placement.162	_ = os.RemoveAll(fresh)163	return nil164}165166// trimLine removes the newline from one line of git output.167func trimLine(out string) string {168	for len(out) > 0 && (out[len(out)-1] == '\n' || out[len(out)-1] == '\r') {169		out = out[:len(out)-1]170	}171	return out172}173174// replaceTree makes published hold exactly what fresh holds. Entries175// upstream removed go first. A directory present on both sides is176// recursed into, so its inode and the pod's view of it stay. Every other177// entry moves with one rename, so a reader sees the old file or the178// new one and never a partial write.179func replaceTree(fresh, published string) error {180	arriving, err := os.ReadDir(fresh)181	if err != nil {182		return err183	}184	standing, err := os.ReadDir(published)185	if err != nil {186		return err187	}188189	keep := make(map[string]bool, len(arriving))190	for _, entry := range arriving {191		keep[entry.Name()] = true192	}193	for _, entry := range standing {194		if !keep[entry.Name()] {195			if err := os.RemoveAll(filepath.Join(published, entry.Name())); err != nil {196				return err197			}198		}199	}200201	for _, entry := range arriving {202		from := filepath.Join(fresh, entry.Name())203		to := filepath.Join(published, entry.Name())204		there, err := os.Lstat(to)205		switch {206		case err == nil && entry.IsDir() && there.IsDir():207			if err := replaceTree(from, to); err != nil {208				return err209			}210			continue211		case err == nil && entry.IsDir() != there.IsDir():212			if err := os.RemoveAll(to); err != nil {213				return err214			}215		}216		if err := os.Rename(from, to); err != nil {217			return err218		}219	}220	return nil221}222223// treeSize is the bytes the tree's regular files hold, which224// NodeGetVolumeStats reports as used.225func treeSize(dir string) (int64, error) {226	var total int64227	err := filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error {228		if err != nil {229			return err230		}231		if info.Mode().IsRegular() {232			total += info.Size()233		}234		return nil235	})236	return total, err237}
volume.go 100.0%
1package main23// volume.go holds one volume this node has and everything the driver4// reports about it.56import (7	"fmt"8	"sync"9)1011// volume is one volume this node holds: the commit its tree stands on,12// the trouble since the last good fetch, what the pod wrote and the13// driver has not committed, and the claim and class that say whether it14// may commit.15type volume struct {16	id          string17	attributes  *attributes18	credentials *credentials19	directory   string20	tree        string21	target      string22	// A writeable volume has a git directory and a staging path. A read-23	// only volume has neither.24	work      *workTree25	staging   string26	writeable bool2728	mu      sync.Mutex29	commit  string30	trouble string31	// The pod the kubelet named at publish. A stage call names no pod.32	pod podReference33	// What the pod wrote and the driver has not committed, and the claim34	// and class that say whether it may.35	pending []change36	claim   claimReference37	class   string38	armed   bool39}4041// setPod records the pod a publish named, so an Event from a loop that42// started at stage reaches it.43func (v *volume) setPod(pod podReference) {44	v.mu.Lock()45	defer v.mu.Unlock()46	v.pod = pod47}4849func (v *volume) podRef() podReference {50	v.mu.Lock()51	defer v.mu.Unlock()52	return v.pod53}5455// reportPending records what the last scan found and reports whether56// the set went from empty to not empty, which is the one moment an57// Event is worth posting.58func (v *volume) reportPending(found []change) bool {59	v.mu.Lock()60	defer v.mu.Unlock()61	first := len(v.pending) == 0 && len(found) > 062	v.pending = found63	return first64}6566// reportArmed records the claim and the class and reports whether the67// volume moved between armed and unarmed.68func (v *volume) reportArmed(claim claimReference, class string, armed bool) bool {69	v.mu.Lock()70	defer v.mu.Unlock()71	changed := armed != v.armed72	v.claim = claim73	v.class = class74	v.armed = armed75	return changed76}7778// reading is what the gauges carry: the claim that labels them, whether79// the volume is armed, and how many paths are pending.80func (v *volume) reading() (claimReference, bool, int) {81	v.mu.Lock()82	defer v.mu.Unlock()83	return v.claim, v.armed, len(v.pending)84}8586// report is the condition every NodeGetVolumeStats answer carries. A87// failure comes first, then an unarmed volume with work the driver may88// not commit, then the commit the tree stands on.89func (v *volume) report() (bool, string) {90	v.mu.Lock()91	defer v.mu.Unlock()92	switch {93	case v.trouble != "":94		return true, v.trouble95	case v.writeable && !v.armed && len(v.pending) > 0:96		return true, fmt.Sprintf("unarmed: %d paths pending, no class on claim %s/%s",97			len(v.pending), v.claim.namespace, v.claim.name)98	case len(v.pending) > 0:99		return false, fmt.Sprintf("%s at %s, %d paths pending",100			v.attributes.ref, short(v.commit), len(v.pending))101	}102	return false, fmt.Sprintf("%s at %s", v.attributes.ref, short(v.commit))103}104105// reportCommit records that the tree holds commit and nothing is wrong.106func (v *volume) reportCommit(commit string) {107	v.mu.Lock()108	defer v.mu.Unlock()109	v.commit = commit110	v.trouble = ""111}112113// reportTrouble records a failure and reports whether it is the first114// since the last success, which is when an Event is worth posting.115func (v *volume) reportTrouble(message string) bool {116	v.mu.Lock()117	defer v.mu.Unlock()118	first := v.trouble == ""119	v.trouble = message120	return first121}122123func (v *volume) condition() (string, string) {124	v.mu.Lock()125	defer v.mu.Unlock()126	return v.commit, v.trouble127}
watch.go 100.0%
1package main23// watch.go holds the inotify watch on a published work tree and the4// sweep that backs it up. Together they decide when the driver reads5// what the pod wrote.67import (8	"context"9	"encoding/binary"10	"fmt"11	"io/fs"12	"os"13	"path/filepath"14	"sync"15	"time"1617	"golang.org/x/sys/unix"18	corev1 "k8s.io/api/core/v1"19)2021// defaultQuiesce is how long a tree rests before the driver reads what22// is pending. defaultSweep is how often it reads that anyway. The class23// sets the quiesce in plan 05.24const (25	defaultQuiesce = 30 * time.Second26	defaultSweep   = time.Minute27)2829// watchMask is the events that mean the pod changed the tree: a write,30// a create, a delete, and both halves of a rename.31const watchMask = unix.IN_CREATE | unix.IN_DELETE | unix.IN_MODIFY |32	unix.IN_MOVED_FROM | unix.IN_MOVED_TO | unix.IN_CLOSE_WRITE3334// watcher is the inotify watch and the sweep of one published work35// tree.36type watcher struct {37	node    *node38	volume  *volume39	quiesce time.Duration40	sweep   time.Duration41	cancel  context.CancelFunc42	changes chan struct{}43	running sync.WaitGroup4445	// The raw descriptor adds watches, and the file reads events. Calling46	// Fd on the file would take it out of the runtime's poller and make47	// the read blocking, so both are kept.48	descriptor int49	inotify    *os.File50	watched    map[int32]string51}5253// watch starts the loops that read one published work tree. The caller54// holds the node's lock.55func (n *node) watch(published *volume) {56	if !published.writeable {57		return58	}59	ctx, cancel := context.WithCancel(n.base)60	seeing := &watcher{61		node:    n,62		volume:  published,63		quiesce: n.quiesce,64		sweep:   n.sweep,65		cancel:  cancel,66		changes: make(chan struct{}, 1),67		watched: map[int32]string{},68	}69	n.watchers[published.id] = seeing70	seeing.running.Add(2)71	seeing.open(ctx)72	go seeing.read(ctx)73	go seeing.run(ctx)74}7576// unwatch ends both loops and waits for them, so a volume the kubelet77// unpublished holds no file open. The caller holds the node's lock.78func (n *node) unwatch(published *volume) {79	seeing, found := n.watchers[published.id]80	if !found {81		return82	}83	delete(n.watchers, published.id)84	seeing.cancel()85	seeing.running.Wait()86}8788// open takes an inotify file and adds a watch for the tree and every89// directory under it. A watch the kernel refuses, for example at the90// inotify limit, leaves the sweep as the whole watch, which is why the91// sweep exists.92func (w *watcher) open(ctx context.Context) {93	fd, err := w.node.inotify(unix.IN_CLOEXEC | unix.IN_NONBLOCK)94	if err != nil {95		w.node.logger.WarnContext(ctx, "the watch did not start",96			"volume", w.volume.id, "error", err)97		return98	}99	w.descriptor = fd100	w.inotify = os.NewFile(uintptr(fd), "inotify")101	w.add(ctx, w.volume.tree)102}103104// add watches the directory and everything under it, because inotify105// watches one directory at a time.106func (w *watcher) add(ctx context.Context, dir string) {107	err := filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error) error {108		if err != nil || !entry.IsDir() {109			return nil110		}111		watched, err := unix.InotifyAddWatch(w.descriptor, path, watchMask)112		if err != nil {113			return err114		}115		w.watched[int32(watched)] = path116		return nil117	})118	if err != nil {119		w.node.logger.WarnContext(ctx, "the watch missed a directory",120			"volume", w.volume.id, "directory", dir, "error", err)121	}122}123124// read turns every batch of inotify events into one nudge and adds a125// watch for each directory the pod created. It ends when run closes the126// file.127func (w *watcher) read(ctx context.Context) {128	defer w.running.Done()129	if w.inotify == nil {130		return131	}132	buffer := make([]byte, 16*1024)133	for {134		count, err := w.inotify.Read(buffer)135		if err != nil {136			return137		}138		for _, event := range w.events(buffer[:count]) {139			if event.directory != "" {140				w.add(ctx, event.directory)141			}142		}143		w.nudge()144	}145}146147// arrival is one inotify event: the directory the pod created, or148// nothing.149type arrival struct {150	directory string151}152153// events reads the kernel's own record: a watch descriptor, a mask, a154// cookie, the name's length, and the name.155func (w *watcher) events(buffer []byte) []arrival {156	found := []arrival{}157	for offset := 0; offset+unix.SizeofInotifyEvent <= len(buffer); {158		descriptor := int32(binary.NativeEndian.Uint32(buffer[offset:]))159		mask := binary.NativeEndian.Uint32(buffer[offset+4:])160		length := int(binary.NativeEndian.Uint32(buffer[offset+12:]))161		name := ""162		if length > 0 {163			name = string(trimZeros(buffer[offset+unix.SizeofInotifyEvent : offset+unix.SizeofInotifyEvent+length]))164		}165		one := arrival{}166		if mask&unix.IN_CREATE != 0 && mask&unix.IN_ISDIR != 0 {167			one.directory = filepath.Join(w.watched[descriptor], name)168		}169		found = append(found, one)170		offset += unix.SizeofInotifyEvent + length171	}172	return found173}174175// trimZeros removes the zero bytes inotify pads a name with.176func trimZeros(name []byte) []byte {177	for len(name) > 0 && name[len(name)-1] == 0 {178		name = name[:len(name)-1]179	}180	return name181}182183// nudge restarts the quiesce timer. The channel has one slot and the184// send never blocks, so a burst of writes costs one nudge.185func (w *watcher) nudge() {186	select {187	case w.changes <- struct{}{}:188	default:189	}190}191192// run waits until the tree has been quiet for the quiesce, then reads193// what is pending. The sweep reads it anyway on a timer, because an194// inotify watch the kernel refused reports nothing.195func (w *watcher) run(ctx context.Context) {196	defer w.running.Done()197	quiesce := time.NewTimer(w.quiesce)198	quiesce.Stop()199	defer quiesce.Stop()200	sweep := time.NewTicker(w.sweep)201	defer sweep.Stop()202203	w.scan(ctx)204	for {205		select {206		case <-ctx.Done():207			w.close()208			return209		case <-w.changes:210			quiesce.Reset(w.quiesce)211		case <-quiesce.C:212			w.scan(ctx)213		case <-sweep.C:214			w.scan(ctx)215		}216	}217}218219// close ends the read loop, because a read of a closed file answers an220// error.221func (w *watcher) close() {222	if w.inotify != nil {223		_ = w.inotify.Close()224	}225}226227// scan records what git finds in the tree. An unarmed volume commits228// none of it. This is the report a person reads before a class arms the229// volume.230func (w *watcher) scan(ctx context.Context) {231	found, err := w.volume.work.pending(ctx)232	if err != nil {233		w.node.logger.WarnContext(ctx, "the tree was not read",234			"volume", w.volume.id, "error", err)235		return236	}237	if w.volume.reportPending(found) {238		claim, _, count := w.volume.reading()239		w.node.logger.InfoContext(ctx, "the tree holds work",240			"volume", w.volume.id, "paths", count)241		w.node.report(ctx, w.volume, claim, corev1.EventTypeNormal, reasonPending,242			pendingMessage(count))243	}244	w.node.readings.record(w.volume)245}246247// pendingMessage is what the Event says about a tree the driver has not248// committed.249func pendingMessage(count int) string {250	return fmt.Sprintf("%d paths pending", count)251}
worktree.go 95.3%
1package main23// worktree.go holds the tree a writeable volume's pod writes, the git4// directory beside it, and the changes git finds in it.56import (7	"context"8	"os"9	"path/filepath"10	"strings"11	"sync"12)1314// alternatesFile names the bare repository whose objects this work tree15// reads, so history is stored once per URL.16const alternatesFile = "objects/info/alternates"1718// workTree is one volume's git directory and the checkout beside it.19// The checkout holds no .git of its own, so the pod cannot commit or20// push around the driver.21type workTree struct {22	repository *repository23	directory  string24	gitDir     string25	tree       string2627	mu sync.Mutex28}2930// workTree is the work tree of a volume, sharing the bare repository of31// its URL.32func (s *store) workTree(repo *repository, id string) *workTree {33	directory := s.volumeDir(id)34	return &workTree{35		repository: repo,36		directory:  directory,37		gitDir:     filepath.Join(directory, "git"),38		tree:       filepath.Join(directory, "tree"),39	}40}4142// exists reports whether create finished. HEAD is what create writes43// last.44func (w *workTree) exists() bool {45	_, err := os.Stat(filepath.Join(w.gitDir, "HEAD"))46	return err == nil47}4849// create makes the git directory beside the tree, shares the bare50// repository's objects through the alternates file, points HEAD at the51// ref, and resets the tree to the commit. reset sets HEAD, the index,52// and the tree in one call, so git status is meaningful from the first53// stage.54func (w *workTree) create(ctx context.Context, ref, commit string) error {55	if err := os.MkdirAll(w.tree, 0o755); err != nil {56		return err57	}58	if _, err := w.git(ctx, "init", "--quiet"); err != nil {59		return err60	}61	alternates := filepath.Join(w.gitDir, alternatesFile)62	if err := os.MkdirAll(filepath.Dir(alternates), 0o755); err != nil {63		return err64	}65	objects := filepath.Join(w.repository.dir, "objects")66	if err := os.WriteFile(alternates, []byte(objects+"\n"), 0o644); err != nil {67		return err68	}69	if _, err := w.git(ctx, "symbolic-ref", "HEAD", "refs/heads/"+ref); err != nil {70		return err71	}72	_, err := w.git(ctx, "reset", "--hard", "--quiet", commit)73	return err74}7576// head is the commit the tree stands on.77func (w *workTree) head(ctx context.Context) (string, error) {78	output, err := w.git(ctx, "rev-parse", "--verify", "--end-of-options", "HEAD")79	if err != nil {80		return "", err81	}82	return trimLine(output.stdout), nil83}8485// change is one path git reports and the bytes it holds now.86type change struct {87	path string88	size int6489}9091// pending is what the pod wrote and the driver has not committed. Every92// untracked file is named, so three files under a new directory count93// as three paths and not one.94func (w *workTree) pending(ctx context.Context) ([]change, error) {95	output, err := w.git(ctx, "status", "--porcelain", "-z", "--untracked-files=all")96	if err != nil {97		return nil, err98	}99	return w.changes(output.stdout), nil100}101102// changes reads git's -z report: two status letters, a space, the path,103// and a second path after a rename or a copy.104func (w *workTree) changes(report string) []change {105	entries := strings.Split(report, "\x00")106	found := []change{}107	for i := 0; i < len(entries); i++ {108		entry := entries[i]109		if len(entry) < 4 {110			continue111		}112		if entry[0] == 'R' || entry[0] == 'C' {113			i++114		}115		found = append(found, change{path: entry[3:], size: w.sizeOf(entry[3:])})116	}117	return found118}119120// sizeOf is the size of a path git named, and zero for a path the pod121// deleted.122func (w *workTree) sizeOf(path string) int64 {123	info, err := os.Lstat(filepath.Join(w.tree, filepath.FromSlash(path)))124	if err != nil || !info.Mode().IsRegular() {125		return 0126	}127	return info.Size()128}129130// git runs git against the work tree with the git directory beside it,131// so the pod never sees a .git. The lock keeps a stage and a status of132// the same tree apart.133func (w *workTree) git(ctx context.Context, args ...string) (gitOutput, error) {134	w.mu.Lock()135	defer w.mu.Unlock()136	return runGit(ctx, w.directory, nil,137		append([]string{"--git-dir=" + w.gitDir, "--work-tree=" + w.tree}, args...)...)138}