From 29865e2c19f478cd643401b7c7c5161aef4afe51 Mon Sep 17 00:00:00 2001 From: Ben Meier <1651305+astromechza@users.noreply.github.com> Date: Tue, 29 Oct 2024 10:23:41 +0000 Subject: [PATCH] feat: added implementation example (#1) * feat: added implementation example Signed-off-by: Ben Meier * fix: add resolves resource params to the output spec Signed-off-by: Ben Meier * fix: test Signed-off-by: Ben Meier * chore: expand readme example Signed-off-by: Ben Meier --------- Signed-off-by: Ben Meier --- .github/workflows/ci.yaml | 20 +++ .gitignore | 4 + README.md | 85 ++++++++- cmd/score-implementation-sample/main.go | 29 +++ go.mod | 28 +++ go.sum | 43 +++++ internal/command/generate.go | 228 ++++++++++++++++++++++++ internal/command/generate_test.go | 175 ++++++++++++++++++ internal/command/init.go | 104 +++++++++++ internal/command/init_test.go | 93 ++++++++++ internal/command/root.go | 39 ++++ internal/command/root_test.go | 68 +++++++ internal/convert/convert.go | 122 +++++++++++++ internal/provisioners/provisioning.go | 63 +++++++ internal/state/state.go | 90 ++++++++++ 15 files changed, 1190 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yaml create mode 100644 .gitignore create mode 100644 cmd/score-implementation-sample/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/command/generate.go create mode 100644 internal/command/generate_test.go create mode 100644 internal/command/init.go create mode 100644 internal/command/init_test.go create mode 100644 internal/command/root.go create mode 100644 internal/command/root_test.go create mode 100644 internal/convert/convert.go create mode 100644 internal/provisioners/provisioning.go create mode 100644 internal/state/state.go diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..5722276 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,20 @@ +name: CI +on: + push: + pull_request: +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v4 + - name: setup-go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: test + run: go run gotest.tools/gotestsum@latest --format github-actions + - name: lint + uses: golangci/golangci-lint-action@v6 + with: + version: latest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c3bacd8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.score-implementation-sample/ +score.yaml +.idea/ +manifests.yaml diff --git a/README.md b/README.md index db0196c..6084c9b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,85 @@ # score-implementation-sample -score-implementation-sample + +This score-implementation-sample is a template repo for creating a new Score implementation following the conventions laid out in [Score Compose](https://github.com/score-spec/score-compose) and [Score K8s](https://github.com/score-spec/score-k8s). + +This sample comes complete with: + +1. CLI skeleton including `init` and`generate` subcommands + - `generate --overrides-file` and `generate --override-property` for applying Score overrides before conversion + - `generate --image` for overriding the workload image before conversion. + - Full placeholder support for `${metadata...}` and `${resource...}` expressions in the workload variables, files, and resource params. +2. State directory storage in `.score-implementation-sample/` +3. `TODO` in place of resource provisioning and workload conversion + +To adapt this for your target platform, you should: + +1. Rename the go module by replacing all instances of `github.com/score-spec/score-implementation-sample` with your own module name. +2. Replace all other instances of `score-implementation-sample` with your own `score-xyz` name including renaming the `cmd/score-implementation-sample` directory. +3. Run the tests with `go test -v ./...`. +4. Change the `TODO` in [provisioning.go](./internal/provisioners/provisioning.go) to provision resources and set the resource outputs. The existing implementation resolves placeholders in the resource params but does not set any resource outputs. +5. Change the `TODO` in [convert.go](./internal/convert/convert.go) to convert workloads into the target manifest form. The existing implementation resolves placeholders in the variables and files sections but just returns the workload spec as yaml content in the manifests. + +Good luck, and have fun! + +## Demo + +Write the following to `score.yaml`: + +```yaml +apiVersion: score.dev/v1b1 +metadata: + name: example +containers: + main: + image: stefanprodan/podinfo + variables: + key: value + dynamic: ${metadata.name} + files: + - target: /somefile + content: | + ${metadata.name} +resources: + thing: + type: something + params: + x: ${metadata.name} +``` + +And run: + +``` +go run ./ init +go run ./ generate score.yaml +``` + +The output `manifests.yaml` contains the following which indicates: + +1. Resources were "provisioned" and their parameters interpolated. +2. Workloads were converted by copying them to the output manifests with variables or files interpolated as required. + +```yaml +apiVersion: score.dev/v1b1 +metadata: + name: example +containers: + main: + files: + - content: | + example + noExpand: true + target: /somefile + image: stefanprodan/podinfo + variables: + dynamic: example + key: value +resources: + thing: + params: + x: example + type: something +``` + +## A note on licensing + +Most code files here retain the Apache licence header since they were copied or adapted from the reference `score-compose` which is Apache licensed. Any modifications to these files should retain the Apache licence and attribution. diff --git a/cmd/score-implementation-sample/main.go b/cmd/score-implementation-sample/main.go new file mode 100644 index 0000000..082b25a --- /dev/null +++ b/cmd/score-implementation-sample/main.go @@ -0,0 +1,29 @@ +// Copyright 2024 Humanitec +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "fmt" + "os" + + "github.com/score-spec/score-implementation-sample/internal/command" +) + +func main() { + if err := command.Execute(); err != nil { + _, _ = fmt.Fprintln(os.Stderr, "Error: "+err.Error()) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e84f05e --- /dev/null +++ b/go.mod @@ -0,0 +1,28 @@ +module github.com/score-spec/score-implementation-sample + +go 1.23.0 + +toolchain go1.23.2 + +require ( + github.com/imdario/mergo v1.0.1 + github.com/score-spec/score-go v1.8.3 + github.com/spf13/cobra v1.8.1 + github.com/spf13/pflag v1.0.5 + github.com/stretchr/testify v1.9.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + dario.cat/mergo v1.0.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect + github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect +) + +replace github.com/imdario/mergo => dario.cat/mergo v1.0.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6c82829 --- /dev/null +++ b/go.sum @@ -0,0 +1,43 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= +github.com/score-spec/score-go v1.8.3 h1:5flFFFQO/I3ollH02vW+ycDrnhgjQzgQZ86o1Db+hUo= +github.com/score-spec/score-go v1.8.3/go.mod h1:v8UV2rUfz5obXNMtndKvjskOMP4xmwhG1RSBrBK2B1M= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/command/generate.go b/internal/command/generate.go new file mode 100644 index 0000000..8dfa5e6 --- /dev/null +++ b/internal/command/generate.go @@ -0,0 +1,228 @@ +// Copyright 2024 Humanitec +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package command + +import ( + "bytes" + "fmt" + "log/slog" + "os" + "slices" + "strings" + + "github.com/imdario/mergo" + "github.com/score-spec/score-go/framework" + scoreloader "github.com/score-spec/score-go/loader" + scoreschema "github.com/score-spec/score-go/schema" + scoretypes "github.com/score-spec/score-go/types" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + + "github.com/score-spec/score-implementation-sample/internal/convert" + "github.com/score-spec/score-implementation-sample/internal/provisioners" + "github.com/score-spec/score-implementation-sample/internal/state" +) + +const ( + generateCmdOverridesFileFlag = "overrides-file" + generateCmdOverridePropertyFlag = "override-property" + generateCmdImageFlag = "image" + generateCmdOutputFlag = "output" +) + +var generateCmd = &cobra.Command{ + Use: "generate", + Short: "Run the conversion from score file to output manifests", + Args: cobra.ArbitraryArgs, + CompletionOptions: cobra.CompletionOptions{ + HiddenDefaultCmd: true, + }, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + + sd, ok, err := state.LoadStateDirectory(".") + if err != nil { + return fmt.Errorf("failed to load existing state directory: %w", err) + } else if !ok { + return fmt.Errorf("state directory does not exist, please run \"init\" first") + } + currentState := &sd.State + + if len(args) != 1 && (cmd.Flags().Lookup(generateCmdOverridesFileFlag).Changed || cmd.Flags().Lookup(generateCmdOverridePropertyFlag).Changed || cmd.Flags().Lookup(generateCmdImageFlag).Changed) { + return fmt.Errorf("cannot use --%s, --%s, or --%s when 0 or more than 1 score files are provided", generateCmdOverridePropertyFlag, generateCmdOverridesFileFlag, generateCmdImageFlag) + } + + slices.Sort(args) + for _, arg := range args { + var rawWorkload map[string]interface{} + if raw, err := os.ReadFile(arg); err != nil { + return fmt.Errorf("failed to read input score file: %s: %w", arg, err) + } else if err = yaml.Unmarshal(raw, &rawWorkload); err != nil { + return fmt.Errorf("failed to decode input score file: %s: %w", arg, err) + } + + // apply overrides + + if v, _ := cmd.Flags().GetString(generateCmdOverridesFileFlag); v != "" { + if err := parseAndApplyOverrideFile(v, generateCmdOverridesFileFlag, rawWorkload); err != nil { + return err + } + } + + // Now read, parse, and apply any override properties to the score files + if v, _ := cmd.Flags().GetStringArray(generateCmdOverridePropertyFlag); len(v) > 0 { + for _, overridePropertyEntry := range v { + if rawWorkload, err = parseAndApplyOverrideProperty(overridePropertyEntry, generateCmdOverridePropertyFlag, rawWorkload); err != nil { + return err + } + } + } + + // Ensure transforms are applied (be a good citizen) + if changes, err := scoreschema.ApplyCommonUpgradeTransforms(rawWorkload); err != nil { + return fmt.Errorf("failed to upgrade spec: %w", err) + } else if len(changes) > 0 { + for _, change := range changes { + slog.Info(fmt.Sprintf("Applying backwards compatible upgrade %s", change)) + } + } + + var workload scoretypes.Workload + if err = scoreschema.Validate(rawWorkload); err != nil { + return fmt.Errorf("invalid score file: %s: %w", arg, err) + } else if err = scoreloader.MapSpec(&workload, rawWorkload); err != nil { + return fmt.Errorf("failed to decode input score file: %s: %w", arg, err) + } + + // Apply image override + for containerName, container := range workload.Containers { + if container.Image == "." { + if v, _ := cmd.Flags().GetString(generateCmdImageFlag); v != "" { + container.Image = v + slog.Info(fmt.Sprintf("Set container image for container '%s' to %s from --%s", containerName, v, generateCmdImageFlag)) + workload.Containers[containerName] = container + } else { + return fmt.Errorf("failed to convert '%s' because container '%s' has no image and --image was not provided: %w", arg, containerName, err) + } + } + } + + if currentState, err = currentState.WithWorkload(&workload, &arg, state.WorkloadExtras{}); err != nil { + return fmt.Errorf("failed to add score file to project: %s: %w", arg, err) + } + slog.Info("Added score file to project", "file", arg) + } + + if len(currentState.Workloads) == 0 { + return fmt.Errorf("project is empty, please add a score file") + } + + if currentState, err = currentState.WithPrimedResources(); err != nil { + return fmt.Errorf("failed to prime resources: %w", err) + } + + slog.Info("Primed resources", "#workloads", len(currentState.Workloads), "#resources", len(currentState.Resources)) + + outputManifests := make([]map[string]interface{}, 0) + + if currentState, err = provisioners.ProvisionResources(currentState); err != nil { + return fmt.Errorf("failed to provision resources: %w", err) + } + + sd.State = *currentState + if err := sd.Persist(); err != nil { + return fmt.Errorf("failed to persist state file: %w", err) + } + slog.Info("Persisted state file") + + for workloadName := range currentState.Workloads { + if manifest, err := convert.Workload(currentState, workloadName); err != nil { + return fmt.Errorf("failed to convert workloads: %w", err) + } else { + outputManifests = append(outputManifests, manifest) + } + slog.Info(fmt.Sprintf("Wrote manifest to manifests buffer for workload '%s'", workloadName)) + } + + out := new(bytes.Buffer) + for _, manifest := range outputManifests { + out.WriteString("---\n") + _ = yaml.NewEncoder(out).Encode(manifest) + } + v, _ := cmd.Flags().GetString(generateCmdOutputFlag) + if v == "" { + return fmt.Errorf("no output file specified") + } else if v == "-" { + _, _ = fmt.Fprint(cmd.OutOrStdout(), out.String()) + } else if err := os.WriteFile(v+".tmp", out.Bytes(), 0644); err != nil { + return fmt.Errorf("failed to write output file: %w", err) + } else if err := os.Rename(v+".tmp", v); err != nil { + return fmt.Errorf("failed to complete writing output file: %w", err) + } else { + slog.Info(fmt.Sprintf("Wrote manifests to '%s'", v)) + } + return nil + }, +} + +func parseAndApplyOverrideFile(entry string, flagName string, spec map[string]interface{}) error { + if raw, err := os.ReadFile(entry); err != nil { + return fmt.Errorf("--%s '%s' is invalid, failed to read file: %w", flagName, entry, err) + } else { + slog.Info(fmt.Sprintf("Applying overrides from %s to workload", entry)) + var out map[string]interface{} + if err := yaml.Unmarshal(raw, &out); err != nil { + return fmt.Errorf("--%s '%s' is invalid: failed to decode yaml: %w", flagName, entry, err) + } else if err := mergo.Merge(&spec, out, mergo.WithOverride); err != nil { + return fmt.Errorf("--%s '%s' failed to apply: %w", flagName, entry, err) + } + } + return nil +} + +func parseAndApplyOverrideProperty(entry string, flagName string, spec map[string]interface{}) (map[string]interface{}, error) { + parts := strings.SplitN(entry, "=", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("--%s '%s' is invalid, expected a =-separated path and value", flagName, entry) + } + if parts[1] == "" { + slog.Info(fmt.Sprintf("Overriding '%s' in workload", parts[0])) + after, err := framework.OverridePathInMap(spec, framework.ParseDotPathParts(parts[0]), true, nil) + if err != nil { + return nil, fmt.Errorf("--%s '%s' could not be applied: %w", flagName, entry, err) + } + return after, nil + } else { + var value interface{} + if err := yaml.Unmarshal([]byte(parts[1]), &value); err != nil { + return nil, fmt.Errorf("--%s '%s' is invalid, failed to unmarshal value as json: %w", flagName, entry, err) + } + slog.Info(fmt.Sprintf("Overriding '%s' in workload", parts[0])) + after, err := framework.OverridePathInMap(spec, framework.ParseDotPathParts(parts[0]), false, value) + if err != nil { + return nil, fmt.Errorf("--%s '%s' could not be applied: %w", flagName, entry, err) + } + return after, nil + } +} + +func init() { + generateCmd.Flags().StringP(generateCmdOutputFlag, "o", "manifests.yaml", "The output manifests file to write the manifests to") + generateCmd.Flags().String(generateCmdOverridesFileFlag, "", "An optional file of Score overrides to merge in") + generateCmd.Flags().StringArray(generateCmdOverridePropertyFlag, []string{}, "An optional set of path=key overrides to set or remove") + generateCmd.Flags().String(generateCmdImageFlag, "", "An optional container image to use for any container with image == '.'") + rootCmd.AddCommand(generateCmd) +} diff --git a/internal/command/generate_test.go b/internal/command/generate_test.go new file mode 100644 index 0000000..0f29f91 --- /dev/null +++ b/internal/command/generate_test.go @@ -0,0 +1,175 @@ +// Copyright 2024 Humanitec +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package command + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/score-spec/score-implementation-sample/internal/state" +) + +func changeToDir(t *testing.T, dir string) string { + t.Helper() + wd, _ := os.Getwd() + require.NoError(t, os.Chdir(dir)) + t.Cleanup(func() { + require.NoError(t, os.Chdir(wd)) + }) + return dir +} + +func changeToTempDir(t *testing.T) string { + return changeToDir(t, t.TempDir()) +} + +func TestGenerateWithoutInit(t *testing.T) { + _ = changeToTempDir(t) + stdout, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"generate"}) + assert.EqualError(t, err, "state directory does not exist, please run \"init\" first") + assert.Equal(t, "", stdout) +} + +func TestGenerateWithoutScoreFiles(t *testing.T) { + _ = changeToTempDir(t) + stdout, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init"}) + assert.NoError(t, err) + assert.Equal(t, "", stdout) + stdout, _, err = executeAndResetCommand(context.Background(), rootCmd, []string{"generate"}) + assert.EqualError(t, err, "project is empty, please add a score file") + assert.Equal(t, "", stdout) +} + +func TestInitAndGenerateWithBadFile(t *testing.T) { + td := changeToTempDir(t) + stdout, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init"}) + assert.NoError(t, err) + assert.Equal(t, "", stdout) + + assert.NoError(t, os.WriteFile(filepath.Join(td, "thing"), []byte(`"blah"`), 0644)) + + stdout, _, err = executeAndResetCommand(context.Background(), rootCmd, []string{"generate", "thing"}) + assert.EqualError(t, err, "failed to decode input score file: thing: yaml: unmarshal errors:\n line 1: cannot unmarshal !!str `blah` into map[string]interface {}") + assert.Equal(t, "", stdout) +} + +func TestInitAndGenerateWithBadScore(t *testing.T) { + td := changeToTempDir(t) + stdout, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init"}) + assert.NoError(t, err) + assert.Equal(t, "", stdout) + + assert.NoError(t, os.WriteFile(filepath.Join(td, "thing"), []byte(`{}`), 0644)) + + stdout, _, err = executeAndResetCommand(context.Background(), rootCmd, []string{"generate", "thing"}) + assert.EqualError(t, err, "invalid score file: thing: jsonschema: '' does not validate with https://score.dev/schemas/score#/required: missing properties: 'apiVersion', 'metadata', 'containers'") + assert.Equal(t, "", stdout) +} + +func TestInitAndGenerate_with_sample(t *testing.T) { + td := changeToTempDir(t) + stdout, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init"}) + require.NoError(t, err) + assert.Equal(t, "", stdout) + stdout, _, err = executeAndResetCommand(context.Background(), rootCmd, []string{ + "generate", "-o", "manifests.yaml", "--", "score.yaml", + }) + require.NoError(t, err) + assert.Equal(t, "", stdout) + raw, err := os.ReadFile(filepath.Join(td, "manifests.yaml")) + assert.NoError(t, err) + assert.Equal(t, `--- +apiVersion: score.dev/v1b1 +containers: + main: + image: stefanprodan/podinfo +metadata: + name: example +service: + ports: + web: + port: 8080 +`, string(raw)) + + // check that state was persisted + sd, ok, err := state.LoadStateDirectory(td) + assert.NoError(t, err) + assert.True(t, ok) + assert.Equal(t, "score.yaml", *sd.State.Workloads["example"].File) + assert.Len(t, sd.State.Workloads, 1) + assert.Len(t, sd.State.Resources, 0) +} + +func TestInitAndGenerate_with_full_example(t *testing.T) { + td := changeToTempDir(t) + stdout, _, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init"}) + require.NoError(t, err) + assert.Equal(t, "", stdout) + + _ = os.Remove(filepath.Join(td, "score.yaml")) + assert.NoError(t, os.WriteFile(filepath.Join(td, "score.yaml"), []byte(` +apiVersion: score.dev/v1b1 +metadata: + name: example +containers: + main: + image: stefanprodan/podinfo + variables: + key: value + dynamic: ${metadata.name} + files: + - target: /somefile + content: | + ${metadata.name} +resources: + thing: + type: something + params: + x: ${metadata.name} +`), 0755)) + + _, _, err = executeAndResetCommand(context.Background(), rootCmd, []string{ + "generate", "-o", "manifests.yaml", "--", "score.yaml", + }) + require.NoError(t, err) + raw, err := os.ReadFile(filepath.Join(td, "manifests.yaml")) + assert.NoError(t, err) + assert.Equal(t, `--- +apiVersion: score.dev/v1b1 +containers: + main: + files: + - content: | + example + noExpand: true + target: /somefile + image: stefanprodan/podinfo + variables: + dynamic: example + key: value +metadata: + name: example +resources: + thing: + params: + x: example + type: something +`, string(raw)) +} diff --git a/internal/command/init.go b/internal/command/init.go new file mode 100644 index 0000000..2d8ce7a --- /dev/null +++ b/internal/command/init.go @@ -0,0 +1,104 @@ +// Copyright 2024 Humanitec +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package command + +import ( + "errors" + "fmt" + "log/slog" + "os" + + "github.com/score-spec/score-go/framework" + scoretypes "github.com/score-spec/score-go/types" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + + "github.com/score-spec/score-implementation-sample/internal/state" +) + +const ( + initCmdFileFlag = "file" +) + +var initCmd = &cobra.Command{ + Use: "init", + Short: "Initialise the local state directory and sample score file", + Args: cobra.NoArgs, + CompletionOptions: cobra.CompletionOptions{ + HiddenDefaultCmd: true, + }, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + + sd, ok, err := state.LoadStateDirectory(".") + if err != nil { + return fmt.Errorf("failed to load existing state directory: %w", err) + } else if ok { + slog.Info("Found existing state directory", "dir", sd.Path) + } else { + slog.Info("Writing new state directory", "dir", state.DefaultRelativeStateDirectory) + sd = &state.StateDirectory{ + Path: state.DefaultRelativeStateDirectory, + State: state.State{ + Workloads: map[string]framework.ScoreWorkloadState[state.WorkloadExtras]{}, + Resources: map[framework.ResourceUid]framework.ScoreResourceState[state.ResourceExtras]{}, + SharedState: map[string]interface{}{}, + }, + } + slog.Info("Writing new state directory", "dir", sd.Path) + if err := sd.Persist(); err != nil { + return fmt.Errorf("failed to persist new state directory: %w", err) + } + } + + initCmdScoreFile, _ := cmd.Flags().GetString(initCmdFileFlag) + if _, err := os.Stat(initCmdScoreFile); err != nil { + if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("failed to check for existing Score file: %w", err) + } + workload := &scoretypes.Workload{ + ApiVersion: "score.dev/v1b1", + Metadata: map[string]interface{}{ + "name": "example", + }, + Containers: map[string]scoretypes.Container{ + "main": { + Image: "stefanprodan/podinfo", + }, + }, + Service: &scoretypes.WorkloadService{ + Ports: map[string]scoretypes.ServicePort{ + "web": {Port: 8080}, + }, + }, + } + rawScore, _ := yaml.Marshal(workload) + if err := os.WriteFile(initCmdScoreFile, rawScore, 0755); err != nil { + return fmt.Errorf("failed to write Score file: %w", err) + } + slog.Info("Created initial Score file", "file", initCmdScoreFile) + } else { + slog.Info("Skipping creation of initial Score file since it already exists", "file", initCmdScoreFile) + } + + return nil + }, +} + +func init() { + initCmd.Flags().StringP(initCmdFileFlag, "f", "score.yaml", "The score file to initialize") + rootCmd.AddCommand(initCmd) +} diff --git a/internal/command/init_test.go b/internal/command/init_test.go new file mode 100644 index 0000000..11de02a --- /dev/null +++ b/internal/command/init_test.go @@ -0,0 +1,93 @@ +// Copyright 2024 Humanitec +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package command + +import ( + "context" + "os" + "strings" + "testing" + + "github.com/score-spec/score-go/framework" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/score-spec/score-implementation-sample/internal/state" +) + +func TestInitNominal(t *testing.T) { + td := t.TempDir() + + wd, _ := os.Getwd() + require.NoError(t, os.Chdir(td)) + defer func() { + require.NoError(t, os.Chdir(wd)) + }() + + stdout, stderr, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init"}) + assert.NoError(t, err) + assert.Equal(t, "", stdout) + assert.NotEqual(t, "", strings.TrimSpace(stderr)) + + stdout, stderr, err = executeAndResetCommand(context.Background(), rootCmd, []string{"generate", "score.yaml"}) + assert.NoError(t, err) + assert.Equal(t, ``, stdout) + assert.NotEqual(t, "", strings.TrimSpace(stderr)) + + sd, ok, err := state.LoadStateDirectory(".") + assert.NoError(t, err) + if assert.True(t, ok) { + assert.Equal(t, state.DefaultRelativeStateDirectory, sd.Path) + assert.Len(t, sd.State.Workloads, 1) + assert.Equal(t, map[framework.ResourceUid]framework.ScoreResourceState[state.ResourceExtras]{}, sd.State.Resources) + assert.Equal(t, map[string]interface{}{}, sd.State.SharedState) + } +} + +func TestInitNominal_run_twice(t *testing.T) { + td := t.TempDir() + + wd, _ := os.Getwd() + require.NoError(t, os.Chdir(td)) + defer func() { + require.NoError(t, os.Chdir(wd)) + }() + + // first init + stdout, stderr, err := executeAndResetCommand(context.Background(), rootCmd, []string{"init", "--file", "score2.yaml"}) + assert.NoError(t, err) + assert.Equal(t, "", stdout) + assert.NotEqual(t, "", strings.TrimSpace(stderr)) + + // init again + stdout, stderr, err = executeAndResetCommand(context.Background(), rootCmd, []string{"init"}) + assert.NoError(t, err) + assert.Equal(t, "", stdout) + assert.NotEqual(t, "", strings.TrimSpace(stderr)) + + _, err = os.Stat("score.yaml") + assert.NoError(t, err) + _, err = os.Stat("score2.yaml") + assert.NoError(t, err) + + sd, ok, err := state.LoadStateDirectory(".") + assert.NoError(t, err) + if assert.True(t, ok) { + assert.Equal(t, state.DefaultRelativeStateDirectory, sd.Path) + assert.Equal(t, map[string]framework.ScoreWorkloadState[state.WorkloadExtras]{}, sd.State.Workloads) + assert.Equal(t, map[framework.ResourceUid]framework.ScoreResourceState[state.ResourceExtras]{}, sd.State.Resources) + assert.Equal(t, map[string]interface{}{}, sd.State.SharedState) + } +} diff --git a/internal/command/root.go b/internal/command/root.go new file mode 100644 index 0000000..01c5454 --- /dev/null +++ b/internal/command/root.go @@ -0,0 +1,39 @@ +// Copyright 2024 Humanitec +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package command + +import ( + "log/slog" + + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "score-implementation-sample", + SilenceErrors: true, + CompletionOptions: cobra.CompletionOptions{ + HiddenDefaultCmd: true, + }, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + slog.SetDefault(slog.New(slog.NewTextHandler(cmd.ErrOrStderr(), &slog.HandlerOptions{ + Level: slog.LevelDebug, AddSource: true, + }))) + return nil + }, +} + +func Execute() error { + return rootCmd.Execute() +} diff --git a/internal/command/root_test.go b/internal/command/root_test.go new file mode 100644 index 0000000..60dfa1d --- /dev/null +++ b/internal/command/root_test.go @@ -0,0 +1,68 @@ +// Copyright 2024 Humanitec +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package command + +import ( + "bytes" + "context" + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/stretchr/testify/assert" +) + +// executeAndResetCommand is a test helper that runs and then resets a command for executing in another test. +func executeAndResetCommand(ctx context.Context, cmd *cobra.Command, args []string) (string, string, error) { + beforeOut, beforeErr := cmd.OutOrStdout(), cmd.ErrOrStderr() + defer func() { + cmd.SetOut(beforeOut) + cmd.SetErr(beforeErr) + // also have to remove completion commands which get auto added and bound to an output buffer + for _, command := range cmd.Commands() { + if command.Name() == "completion" { + cmd.RemoveCommand(command) + break + } + } + }() + + nowOut, nowErr := new(bytes.Buffer), new(bytes.Buffer) + cmd.SetOut(nowOut) + cmd.SetErr(nowErr) + cmd.SetArgs(args) + subCmd, err := cmd.ExecuteContextC(ctx) + if subCmd != nil { + subCmd.SetOut(nil) + subCmd.SetErr(nil) + subCmd.SetContext(context.TODO()) + subCmd.SilenceUsage = false + subCmd.Flags().VisitAll(func(f *pflag.Flag) { + if f.Value.Type() == "stringArray" { + _ = f.Value.(pflag.SliceValue).Replace(nil) + } else { + _ = f.Value.Set(f.DefValue) + } + }) + } + return nowOut.String(), nowErr.String(), err +} + +func TestRootUnknown(t *testing.T) { + stdout, stderr, err := executeAndResetCommand(context.Background(), rootCmd, []string{"unknown"}) + assert.EqualError(t, err, "unknown command \"unknown\" for \"score-implementation-sample\"") + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) +} diff --git a/internal/convert/convert.go b/internal/convert/convert.go new file mode 100644 index 0000000..b987dc0 --- /dev/null +++ b/internal/convert/convert.go @@ -0,0 +1,122 @@ +// Copyright 2024 Humanitec +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package convert + +import ( + "fmt" + "maps" + "os" + "path/filepath" + + "github.com/score-spec/score-go/framework" + scoretypes "github.com/score-spec/score-go/types" + "gopkg.in/yaml.v3" + + "github.com/score-spec/score-implementation-sample/internal/state" +) + +func Workload(currentState *state.State, workloadName string) (map[string]interface{}, error) { + resOutputs, err := currentState.GetResourceOutputForWorkload(workloadName) + if err != nil { + return nil, fmt.Errorf("failed to generate outputs: %w", err) + } + sf := framework.BuildSubstitutionFunction(currentState.Workloads[workloadName].Spec.Metadata, resOutputs) + + spec := currentState.Workloads[workloadName].Spec + containers := maps.Clone(spec.Containers) + for containerName, container := range containers { + if container.Variables, err = convertContainerVariables(container.Variables, sf); err != nil { + return nil, fmt.Errorf("workload: %s: container: %s: variables: %w", workloadName, containerName, err) + } + + if container.Files, err = convertContainerFiles(container.Files, currentState.Workloads[workloadName].File, sf); err != nil { + return nil, fmt.Errorf("workload: %s: container: %s: files: %w", workloadName, containerName, err) + } + containers[containerName] = container + } + spec.Containers = containers + resources := maps.Clone(spec.Resources) + for resName, res := range resources { + resUid := framework.NewResourceUid(workloadName, resName, res.Type, res.Class, res.Id) + resState, ok := currentState.Resources[resUid] + if !ok { + return nil, fmt.Errorf("workload '%s': resource '%s' (%s) is not primed", workloadName, resName, resUid) + } + res.Params = resState.Params + resources[resName] = res + } + spec.Resources = resources + + // =============================================================================== + // TODO: HERE IS WHERE YOU MAY CONVERT THE WORKLOAD INTO YOUR TARGET MANIFEST TYPE + // =============================================================================== + + raw, err := yaml.Marshal(spec) + if err != nil { + return nil, fmt.Errorf("workload: %s: failed to serialise manifest: %w", workloadName, err) + } + var intermediate map[string]interface{} + _ = yaml.Unmarshal(raw, &intermediate) + + return intermediate, nil +} + +func convertContainerVariables(input scoretypes.ContainerVariables, sf func(string) (string, error)) (map[string]string, error) { + outMap := make(map[string]string, len(input)) + for key, value := range input { + out, err := framework.SubstituteString(value, sf) + if err != nil { + return nil, fmt.Errorf("%s: %w", key, err) + } + outMap[key] = out + } + return outMap, nil +} + +func convertContainerFiles(input []scoretypes.ContainerFilesElem, scoreFile *string, sf func(string) (string, error)) ([]scoretypes.ContainerFilesElem, error) { + outSlice := make([]scoretypes.ContainerFilesElem, 0, len(input)) + for i, fileElem := range input { + var content string + if fileElem.Content != nil { + content = *fileElem.Content + } else if fileElem.Source != nil { + sourcePath := *fileElem.Source + if !filepath.IsAbs(sourcePath) && scoreFile != nil { + sourcePath = filepath.Join(filepath.Dir(*scoreFile), sourcePath) + } + if rawContent, err := os.ReadFile(sourcePath); err != nil { + return nil, fmt.Errorf("%d: source: failed to read file '%s': %w", i, sourcePath, err) + } else { + content = string(rawContent) + } + } else { + return nil, fmt.Errorf("%d: missing 'content' or 'source'", i) + } + + var err error + if fileElem.NoExpand == nil || !*fileElem.NoExpand { + content, err = framework.SubstituteString(string(content), sf) + if err != nil { + return nil, fmt.Errorf("%d: failed to substitute in content: %w", i, err) + } + } + fileElem.Source = nil + fileElem.Content = &content + bTrue := true + fileElem.NoExpand = &bTrue + outSlice = append(outSlice, fileElem) + } + return outSlice, nil +} diff --git a/internal/provisioners/provisioning.go b/internal/provisioners/provisioning.go new file mode 100644 index 0000000..b84d024 --- /dev/null +++ b/internal/provisioners/provisioning.go @@ -0,0 +1,63 @@ +// Copyright 2024 Humanitec +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package provisioners + +import ( + "fmt" + "maps" + + "github.com/score-spec/score-go/framework" + + "github.com/score-spec/score-implementation-sample/internal/state" +) + +func ProvisionResources(currentState *state.State) (*state.State, error) { + out := currentState + + // provision in sorted order + orderedResources, err := currentState.GetSortedResourceUids() + if err != nil { + return nil, fmt.Errorf("failed to determine sort order for provisioning: %w", err) + } + + out.Resources = maps.Clone(out.Resources) + for _, resUid := range orderedResources { + resState := out.Resources[resUid] + + var params map[string]interface{} + if len(resState.Params) > 0 { + resOutputs, err := out.GetResourceOutputForWorkload(resState.SourceWorkload) + if err != nil { + return nil, fmt.Errorf("%s: failed to find resource params for resource: %w", resUid, err) + } + sf := framework.BuildSubstitutionFunction(out.Workloads[resState.SourceWorkload].Spec.Metadata, resOutputs) + rawParams, err := framework.Substitute(resState.Params, sf) + if err != nil { + return nil, fmt.Errorf("%s: failed to substitute params for resource: %w", resUid, err) + } + params = rawParams.(map[string]interface{}) + } + resState.Params = params + + // ========================================================================================== + // TODO: HERE IS WHERE YOU WOULD USE THE RESOURCE TYPE, CLASS, ID, AND PARAMS TO PROVISION IT + // ========================================================================================== + + resState.Outputs = map[string]interface{}{} + out.Resources[resUid] = resState + } + + return out, nil +} diff --git a/internal/state/state.go b/internal/state/state.go new file mode 100644 index 0000000..caeee55 --- /dev/null +++ b/internal/state/state.go @@ -0,0 +1,90 @@ +// Copyright 2024 Humanitec +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package state + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/score-spec/score-go/framework" + "gopkg.in/yaml.v3" +) + +const ( + DefaultRelativeStateDirectory = ".score-implementation-sample" + FileName = "state.yaml" +) + +type WorkloadExtras struct{} + +type ResourceExtras struct{} + +type State = framework.State[framework.NoExtras, WorkloadExtras, ResourceExtras] + +// The StateDirectory holds the local state of the project, including any configuration, extensions, +// plugins, or resource provisioning state when possible. +type StateDirectory struct { + // The path to the state directory + Path string + // The current state file + State State +} + +// Persist ensures that the directory is created and that the current config file has been written with the latest settings. +func (sd *StateDirectory) Persist() error { + if sd.Path == "" { + return fmt.Errorf("path not set") + } + if err := os.Mkdir(sd.Path, 0755); err != nil && !errors.Is(err, os.ErrExist) { + return fmt.Errorf("failed to create directory '%s': %w", sd.Path, err) + } + out := new(bytes.Buffer) + enc := yaml.NewEncoder(out) + enc.SetIndent(2) + if err := enc.Encode(sd.State); err != nil { + return fmt.Errorf("failed to encode content: %w", err) + } + + // important that we overwrite this file atomically via an inode move + if err := os.WriteFile(filepath.Join(sd.Path, FileName+".temp"), out.Bytes(), 0755); err != nil { + return fmt.Errorf("failed to write state: %w", err) + } else if err := os.Rename(filepath.Join(sd.Path, FileName+".temp"), filepath.Join(sd.Path, FileName)); err != nil { + return fmt.Errorf("failed to complete writing state: %w", err) + } + return nil +} + +// LoadStateDirectory loads the state directory for the given directory (usually PWD). +func LoadStateDirectory(directory string) (*StateDirectory, bool, error) { + d := filepath.Join(directory, DefaultRelativeStateDirectory) + content, err := os.ReadFile(filepath.Join(d, FileName)) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, false, nil + } + return nil, true, fmt.Errorf("state file couldn't be read: %w", err) + } + + var out State + dec := yaml.NewDecoder(bytes.NewReader(content)) + dec.KnownFields(true) + if err := dec.Decode(&out); err != nil { + return nil, true, fmt.Errorf("state file couldn't be decoded: %w", err) + } + return &StateDirectory{d, out}, true, nil +}