// Copyright 2019-2024, Pulumi Corporation. // // 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 httpstate import ( "bytes" "context" "encoding/json" "fmt" "io" "os" "path/filepath" "strconv" "strings" "github.com/pulumi/pulumi/pkg/v3/backend" "github.com/pulumi/pulumi/pkg/v3/backend/httpstate/client" "github.com/pulumi/pulumi/pkg/v3/engine" resourceanalyzer "github.com/pulumi/pulumi/pkg/v3/resource/analyzer" "github.com/pulumi/pulumi/sdk/v3/go/common/apitype" "github.com/pulumi/pulumi/sdk/v3/go/common/tokens" "github.com/pulumi/pulumi/sdk/v3/go/common/util/archive" "github.com/pulumi/pulumi/sdk/v3/go/common/util/contract" "github.com/pulumi/pulumi/sdk/v3/go/common/util/logging" "github.com/pulumi/pulumi/sdk/v3/go/common/workspace" "github.com/pulumi/pulumi/sdk/v3/nodejs/npm" "github.com/pulumi/pulumi/sdk/v3/python/toolchain" ) type cloudRequiredPolicy struct { apitype.RequiredPolicy client *client.Client orgName string } var _ engine.RequiredPolicy = (*cloudRequiredPolicy)(nil) func newCloudRequiredPolicy(client *client.Client, policy apitype.RequiredPolicy, orgName string, ) *cloudRequiredPolicy { return &cloudRequiredPolicy{ client: client, RequiredPolicy: policy, orgName: orgName, } } func (rp *cloudRequiredPolicy) Name() string { return rp.RequiredPolicy.Name } func (rp *cloudRequiredPolicy) Version() string { return rp.RequiredPolicy.VersionTag } func (rp *cloudRequiredPolicy) OrgName() string { return rp.orgName } func (rp *cloudRequiredPolicy) Install(ctx context.Context) (string, error) { policy := rp.RequiredPolicy // If version tag is empty, we use the version tag. This is to support older version of // pulumi/policy that do not have a version tag. version := policy.VersionTag if version == "" { version = strconv.Itoa(policy.Version) } policyPackPath, installed, err := workspace.GetPolicyPath(rp.OrgName(), strings.ReplaceAll(policy.Name, tokens.QNameDelimiter, "_"), version) if err != nil { // Failed to get a sensible PolicyPack path. return "", err } else if installed { // We've already downloaded and installed the PolicyPack. Return. return policyPackPath, nil } fmt.Printf("Installing policy pack %s %s...\r\n", policy.Name, version) // PolicyPack has not been downloaded and installed. Do this now. policyPackTarball, err := rp.client.DownloadPolicyPack(ctx, policy.PackLocation) if err != nil { return "", err } return policyPackPath, installRequiredPolicy(ctx, policyPackPath, policyPackTarball) } func (rp *cloudRequiredPolicy) Config() map[string]*json.RawMessage { return rp.RequiredPolicy.Config } func newCloudBackendPolicyPackReference( cloudConsoleURL, orgName string, name tokens.QName, ) *cloudBackendPolicyPackReference { return &cloudBackendPolicyPackReference{ orgName: orgName, name: name, cloudConsoleURL: cloudConsoleURL, } } // cloudBackendPolicyPackReference is a reference to a PolicyPack implemented by the Pulumi service. type cloudBackendPolicyPackReference struct { // name of the PolicyPack. name tokens.QName // orgName that administrates the PolicyPack. orgName string // versionTag of the Policy Pack. This is typically the version specified in // a package.json, setup.py, or similar file. versionTag string // cloudConsoleURL is the root URL of where the Policy Pack can be found in the console. The // version must be appended to the returned URL. cloudConsoleURL string } var _ backend.PolicyPackReference = (*cloudBackendPolicyPackReference)(nil) func (pr *cloudBackendPolicyPackReference) String() string { return fmt.Sprintf("%s/%s", pr.orgName, pr.name) } func (pr *cloudBackendPolicyPackReference) OrgName() string { return pr.orgName } func (pr *cloudBackendPolicyPackReference) Name() tokens.QName { return pr.name } func (pr *cloudBackendPolicyPackReference) CloudConsoleURL() string { return fmt.Sprintf("%s/%s/policypacks/%s", pr.cloudConsoleURL, pr.orgName, pr.Name()) } // cloudPolicyPack is a the Pulumi service implementation of the PolicyPack interface. type cloudPolicyPack struct { // ref uniquely identifies the PolicyPack in the Pulumi service. ref *cloudBackendPolicyPackReference // b is a pointer to the backend that this PolicyPack belongs to. b *cloudBackend // cl is the client used to interact with the backend. cl *client.Client } var _ backend.PolicyPack = (*cloudPolicyPack)(nil) func (pack *cloudPolicyPack) Ref() backend.PolicyPackReference { return pack.ref } func (pack *cloudPolicyPack) Backend() backend.Backend { return pack.b } func (pack *cloudPolicyPack) Publish( ctx context.Context, op backend.PublishOperation, ) error { // // Get PolicyPack metadata from the plugin. // fmt.Println("Obtaining policy metadata from policy plugin") abs, err := filepath.Abs(op.PlugCtx.Pwd) if err != nil { return err } analyzer, err := op.PlugCtx.Host.PolicyAnalyzer(tokens.QName(abs), op.PlugCtx.Pwd, nil /*opts*/) if err != nil { return err } analyzerInfo, err := analyzer.GetAnalyzerInfo() if err != nil { return err } // Update the name and version tag from the metadata. pack.ref.name = tokens.QName(analyzerInfo.Name) pack.ref.versionTag = analyzerInfo.Version fmt.Println("Compressing policy pack") var packTarball []byte // TODO[pulumi/pulumi#1334]: move to the language plugins so we don't have to hard code here. runtime := op.PolicyPack.Runtime.Name() if strings.EqualFold(runtime, "nodejs") { packTarball, err = npm.Pack(ctx, npm.AutoPackageManager, op.PlugCtx.Pwd, os.Stderr) if err != nil { return fmt.Errorf("could not publish policies because of error running npm pack: %w", err) } } else { // npm pack puts all the files in a "package" subdirectory inside the .tgz it produces, so we'll do // the same for other runtimes. That way, after unpacking, we can look for the PulumiPolicy.yaml inside the // package directory to determine the runtime of the policy pack. packTarball, err = archive.TGZ(op.PlugCtx.Pwd, "package", true /*useDefaultExcludes*/) if err != nil { return fmt.Errorf("could not publish policies because of error creating the .tgz: %w", err) } } // // Publish. // fmt.Println("Uploading policy pack to Pulumi service") publishedVersion, err := pack.cl.PublishPolicyPack(ctx, pack.ref.orgName, analyzerInfo, bytes.NewReader(packTarball)) if err != nil { return err } fmt.Printf("\nPermalink: %s/%s\n", pack.ref.CloudConsoleURL(), publishedVersion) return nil } func (pack *cloudPolicyPack) Enable(ctx context.Context, policyGroup string, op backend.PolicyPackOperation) error { if op.VersionTag == nil { return pack.cl.ApplyPolicyPack(ctx, pack.ref.orgName, policyGroup, string(pack.ref.name), "" /* versionTag */, op.Config) } return pack.cl.ApplyPolicyPack(ctx, pack.ref.orgName, policyGroup, string(pack.ref.name), *op.VersionTag, op.Config) } func (pack *cloudPolicyPack) Validate(ctx context.Context, op backend.PolicyPackOperation) error { schema, err := pack.cl.GetPolicyPackSchema(ctx, pack.ref.orgName, string(pack.ref.name), *op.VersionTag) if err != nil { return err } err = resourceanalyzer.ValidatePolicyPackConfig(schema.ConfigSchema, op.Config) if err != nil { return err } return nil } func (pack *cloudPolicyPack) Disable(ctx context.Context, policyGroup string, op backend.PolicyPackOperation) error { if op.VersionTag == nil { return pack.cl.DisablePolicyPack(ctx, pack.ref.orgName, policyGroup, string(pack.ref.name), "" /* versionTag */) } return pack.cl.DisablePolicyPack(ctx, pack.ref.orgName, policyGroup, string(pack.ref.name), *op.VersionTag) } func (pack *cloudPolicyPack) Remove(ctx context.Context, op backend.PolicyPackOperation) error { if op.VersionTag == nil { return pack.cl.RemovePolicyPack(ctx, pack.ref.orgName, string(pack.ref.name)) } return pack.cl.RemovePolicyPackByVersion(ctx, pack.ref.orgName, string(pack.ref.name), *op.VersionTag) } const packageDir = "package" func installRequiredPolicy(ctx context.Context, finalDir string, tgz io.ReadCloser) error { // If part of the directory tree is missing, os.MkdirTemp will return an error, so make sure // the path we're going to create the temporary folder in actually exists. if err := os.MkdirAll(filepath.Dir(finalDir), 0o700); err != nil { return fmt.Errorf("creating plugin root: %w", err) } tempDir, err := os.MkdirTemp(filepath.Dir(finalDir), filepath.Base(finalDir)+".tmp") if err != nil { return fmt.Errorf("creating plugin directory %s: %w", tempDir, err) } // The policy pack files are actually in a directory called `package`. tempPackageDir := filepath.Join(tempDir, packageDir) if err := os.MkdirAll(tempPackageDir, 0o700); err != nil { return fmt.Errorf("creating plugin root: %w", err) } // If we early out of this function, try to remove the temp folder we created. defer func() { contract.IgnoreError(os.RemoveAll(tempDir)) }() // Uncompress the policy pack. err = archive.ExtractTGZ(tgz, tempDir) if err != nil { return fmt.Errorf("failed to extract tarball: %w", err) } logging.V(7).Infof("Unpacking policy pack %q %q\n", tempDir, finalDir) // If two calls to `plugin install` for the same plugin are racing, the second one will be // unable to rename the directory. That's OK, just ignore the error. The temp directory created // as part of the install will be cleaned up when we exit by the defer above. if err := os.Rename(tempPackageDir, finalDir); err != nil && !os.IsExist(err) { return fmt.Errorf("moving plugin: %w", err) } projPath := filepath.Join(finalDir, "PulumiPolicy.yaml") proj, err := workspace.LoadPolicyPack(projPath) if err != nil { return fmt.Errorf("failed to load policy project at %s: %w", finalDir, err) } // TODO[pulumi/pulumi#1334]: move to the language plugins so we don't have to hard code here. if strings.EqualFold(proj.Runtime.Name(), "nodejs") { if err := completeNodeJSInstall(ctx, finalDir); err != nil { return err } } else if strings.EqualFold(proj.Runtime.Name(), "python") { if err := completePythonInstall(ctx, finalDir, projPath, proj); err != nil { return err } } fmt.Println("Finished installing policy pack\r") fmt.Println() return nil } func completeNodeJSInstall(ctx context.Context, finalDir string) error { if bin, err := npm.Install(ctx, npm.AutoPackageManager, finalDir, false /*production*/, nil, os.Stderr); err != nil { return fmt.Errorf("failed to install dependencies of policy pack; you may need to re-run `%s install` "+ "in %q before this policy pack works"+": %w", bin, finalDir, err) } return nil } func completePythonInstall(ctx context.Context, finalDir, projPath string, proj *workspace.PolicyPackProject) error { const venvDir = "venv" // TODO[pulumi/pulumi/issues/16286]: Allow using different toolchains for policy packs. tc, err := toolchain.ResolveToolchain(toolchain.PythonOptions{ Toolchain: toolchain.Pip, Root: finalDir, Virtualenv: venvDir, }) if err != nil { return fmt.Errorf("failed to get python toolchain: %w", err) } if err := tc.InstallDependencies(ctx, finalDir, false /* useLanguageVersionTools */, false, /*showOutput*/ os.Stdout, os.Stderr); err != nil { return err } // Save project with venv info. proj.Runtime.SetOption("virtualenv", venvDir) if err := proj.Save(projPath); err != nil { return fmt.Errorf("saving project at %s: %w", projPath, err) } return nil }