go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/lucictx/lucictx.go (about)

     1  // Copyright 2016 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  // Package lucictx implements a Go client for the protocol defined here:
    16  //
    17  //	https://github.com/luci/luci-py/blob/master/client/LUCI_CONTEXT.md
    18  //
    19  // It differs from the python client in a couple ways:
    20  //   - The initial LUCI_CONTEXT value is captured once at application start.
    21  //   - Writes are cached into the golang context.Context, not a global variable.
    22  //   - The LUCI_CONTEXT environment variable is not changed automatically when
    23  //     using the Set function. To pass the new context on to a child process,
    24  //     you must use the Export function to dump the current context state to
    25  //     disk and call exported.SetInCmd(cmd) to configure new command's
    26  //     environment.
    27  package lucictx
    28  
    29  import (
    30  	"bytes"
    31  	"context"
    32  	"encoding/json"
    33  	"fmt"
    34  	"io"
    35  	"io/ioutil"
    36  	"os"
    37  	"reflect"
    38  	"sync"
    39  
    40  	"github.com/golang/protobuf/jsonpb"
    41  	protoV1 "github.com/golang/protobuf/proto"
    42  	"google.golang.org/protobuf/encoding/protojson"
    43  	"google.golang.org/protobuf/proto"
    44  
    45  	"go.chromium.org/luci/common/errors"
    46  )
    47  
    48  // EnvKey is the environment variable key for the LUCI_CONTEXT file.
    49  const EnvKey = "LUCI_CONTEXT"
    50  
    51  // lctx is wrapper around top-level JSON dict of a LUCI_CONTEXT file.
    52  //
    53  // Note that we must use '*json.RawMessage' as dict value type since only
    54  // pointer *json.RawMessage type implements json.Marshaler and json.Unmarshaler
    55  // interfaces. Without '*' the JSON library treats json.RawMessage as []byte and
    56  // marshals it as base64 blob.
    57  type lctx struct {
    58  	sections map[string]*json.RawMessage // readonly! lives outside the lock
    59  
    60  	lock sync.Mutex
    61  	path string // non-empty if exists as file on disk
    62  	refs int    // number of open references to the dropped file
    63  }
    64  
    65  func alloc(size int) *lctx {
    66  	return &lctx{sections: make(map[string]*json.RawMessage, size)}
    67  }
    68  
    69  func (l *lctx) clone() *lctx {
    70  	ret := alloc(len(l.sections))
    71  	for k, v := range l.sections {
    72  		ret.sections[k] = v
    73  	}
    74  	return ret
    75  }
    76  
    77  var lctxKey = "Holds the current lctx"
    78  
    79  // This is the LUCI_CONTEXT loaded from the environment once when the process
    80  // starts.
    81  var externalContext = extractFromEnv(os.Stderr)
    82  
    83  func extractFromEnv(out io.Writer) *lctx {
    84  	path := os.Getenv(EnvKey)
    85  	if path == "" {
    86  		return &lctx{}
    87  	}
    88  	f, err := os.Open(path)
    89  	if err != nil {
    90  		fmt.Fprintf(out, "Could not open LUCI_CONTEXT file %q: %s\n", path, err)
    91  		return &lctx{}
    92  	}
    93  	defer f.Close()
    94  
    95  	dec := json.NewDecoder(f)
    96  	dec.UseNumber()
    97  	tmp := map[string]any{}
    98  	if err := dec.Decode(&tmp); err != nil {
    99  		fmt.Fprintf(out, "Could not decode LUCI_CONTEXT file %q: %s\n", path, err)
   100  		return &lctx{}
   101  	}
   102  
   103  	ret := alloc(len(tmp))
   104  	for k, v := range tmp {
   105  		if reflect.TypeOf(v).Kind() != reflect.Map {
   106  			fmt.Fprintf(out, "Could not re-encode LUCI_CONTEXT file %q, section %q: Not a map.\n", path, k)
   107  			continue
   108  		}
   109  		item, err := json.Marshal(v)
   110  		if err != nil {
   111  			fmt.Fprintf(out, "Could not marshal LUCI_CONTEXT %v: %s\n", v, err)
   112  			return &lctx{}
   113  		}
   114  
   115  		// This section just came from json.Unmarshal, so we know that json.Marshal
   116  		// will work on it.
   117  		raw := json.RawMessage(item)
   118  		ret.sections[k] = &raw
   119  	}
   120  
   121  	ret.path = path // reuse existing external file in Export()
   122  	ret.refs = 1    // never decremented, ensuring we don't delete the external file
   123  	return ret
   124  }
   125  
   126  // Note: it never returns nil.
   127  func getCurrent(ctx context.Context) *lctx {
   128  	if l := ctx.Value(&lctxKey); l != nil {
   129  		return l.(*lctx)
   130  	}
   131  	return externalContext
   132  }
   133  
   134  // Get retrieves the current section from the current LUCI_CONTEXT, and
   135  // deserializes it into out. Out may be any target for json.Unmarshal. If the
   136  // section exists, it deserializes it into the provided out object. If not, then
   137  // out is unmodified.
   138  func Get(ctx context.Context, section string, out proto.Message) error {
   139  	_, err := Lookup(ctx, section, out)
   140  	return err
   141  }
   142  
   143  // Lookup retrieves the current section from the current LUCI_CONTEXT, and
   144  // deserializes it into out. Out may be any target for json.Unmarshal. It
   145  // returns a deserialization error (if any), and a boolean indicating if the
   146  // section was actually found.
   147  func Lookup(ctx context.Context, section string, out proto.Message) (bool, error) {
   148  	data, _ := getCurrent(ctx).sections[section]
   149  	if data == nil {
   150  		return false, nil
   151  	}
   152  	unmarshaler := &jsonpb.Unmarshaler{
   153  		AllowUnknownFields: true,
   154  	}
   155  	if err := unmarshaler.Unmarshal(bytes.NewReader(*data), protoV1.MessageV1(out)); err != nil {
   156  		return true, errors.Annotate(err, "failed to unmarshal json: %s", string(*data)).Err()
   157  	}
   158  	return true, nil
   159  }
   160  
   161  // Set writes the json serialization of `in` as the given section into the
   162  // LUCI_CONTEXT, returning the new ctx object containing it. This ctx can be
   163  // passed to Export to serialize it to disk.
   164  //
   165  // If in is nil, it will clear that section of the LUCI_CONTEXT.
   166  //
   167  // The returned context is always safe to use, even if this returns an error.
   168  func Set(ctx context.Context, section string, in proto.Message) context.Context {
   169  	var data json.RawMessage
   170  	if in != nil && !reflect.ValueOf(in).IsNil() {
   171  		buf, err := protojson.Marshal(in)
   172  		if err != nil {
   173  			panic(err) // Only errors could be from writing to buf.
   174  		}
   175  		data = buf
   176  	}
   177  	cur := getCurrent(ctx)
   178  	if _, alreadyHas := cur.sections[section]; data == nil && !alreadyHas {
   179  		// Removing a section which is already missing is a no-op
   180  		return ctx
   181  	}
   182  	newLctx := cur.clone()
   183  	if data == nil {
   184  		delete(newLctx.sections, section)
   185  	} else {
   186  		newLctx.sections[section] = &data
   187  	}
   188  	return context.WithValue(ctx, &lctxKey, newLctx)
   189  }
   190  
   191  // Export takes the current LUCI_CONTEXT information from ctx, writes it to
   192  // a file in os.TempDir and returns a wrapping Exported object. This exported
   193  // value must then be installed into the environment of any subcommands (see
   194  // the methods on Exported).
   195  //
   196  // It is required that the caller of this function invoke Close() on the
   197  // returned Exported object, or they will leak temporary files.
   198  //
   199  // Internally this function reuses existing files, when possible, so if you
   200  // anticipate calling a lot of subcommands with exported LUCI_CONTEXT, you can
   201  // export it in advance (thus grabbing a reference to the exported file). Then
   202  // subsequent Export() calls with this context will be extremely cheap, since
   203  // they will just reuse the existing file. Don't forget to release it with
   204  // Close() when done.
   205  func Export(ctx context.Context) (Exported, error) {
   206  	return getCurrent(ctx).export("")
   207  }
   208  
   209  // ExportInto is like Export, except it places the temporary file into the given
   210  // directory.
   211  //
   212  // Exports done via this method are not reused: each individual ExportInto call
   213  // produces a new temporary file.
   214  func ExportInto(ctx context.Context, dir string) (Exported, error) {
   215  	return getCurrent(ctx).export(dir)
   216  }
   217  
   218  func (l *lctx) export(dir string) (Exported, error) {
   219  	if len(l.sections) == 0 {
   220  		return &nullExport{}, nil
   221  	}
   222  
   223  	if dir != "" {
   224  		path, err := dropToDisk(l.sections, dir)
   225  		if err != nil {
   226  			return nil, err
   227  		}
   228  		return &liveExport{
   229  			path:   path,
   230  			closer: func() { removeFromDisk(path) },
   231  		}, nil
   232  	}
   233  
   234  	l.lock.Lock()
   235  	defer l.lock.Unlock()
   236  
   237  	if l.refs == 0 {
   238  		if l.path != "" {
   239  			panic("lctx.path is supposed to be empty here")
   240  		}
   241  		path, err := dropToDisk(l.sections, "")
   242  		if err != nil {
   243  			return nil, err
   244  		}
   245  		l.path = path
   246  	}
   247  
   248  	l.refs++
   249  	return &liveExport{
   250  		path: l.path,
   251  		closer: func() {
   252  			l.lock.Lock()
   253  			defer l.lock.Unlock()
   254  			if l.refs == 0 {
   255  				panic("lctx.refs can't be zero here")
   256  			}
   257  			l.refs--
   258  			if l.refs == 0 {
   259  				removeFromDisk(l.path)
   260  				l.path = ""
   261  			}
   262  		},
   263  	}, nil
   264  }
   265  
   266  func dropToDisk(sections map[string]*json.RawMessage, dir string) (string, error) {
   267  	// Note: this makes a file in 0600 mode. This is what we want, the context
   268  	// may have secrets.
   269  	f, err := ioutil.TempFile(dir, "luci_context.")
   270  	if err != nil {
   271  		return "", errors.Annotate(err, "creating luci_context file").Err()
   272  	}
   273  
   274  	err = json.NewEncoder(f).Encode(sections)
   275  	if clErr := f.Close(); err == nil {
   276  		err = clErr
   277  	}
   278  	if err != nil {
   279  		removeFromDisk(f.Name())
   280  		return "", errors.Annotate(err, "writing luci_context").Err()
   281  	}
   282  
   283  	return f.Name(), nil
   284  }
   285  
   286  func removeFromDisk(path string) {
   287  	if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
   288  		fmt.Fprintf(os.Stderr, "Could not remove LUCI_CONTEXT file %q: %s\n", path, err)
   289  	}
   290  }