// Copyright Istio Authors
//
// 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.

// This is a sample chained plugin that supports multiple CNI versions. It
// parses prevResult according to the cniVersion
package plugin

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"path/filepath"
	"runtime/debug"
	"strconv"
	"strings"
	"time"

	"github.com/cenkalti/backoff/v4"
	"github.com/containernetworking/cni/pkg/skel"
	"github.com/containernetworking/cni/pkg/types"
	cniv1 "github.com/containernetworking/cni/pkg/types/100"
	"github.com/containernetworking/cni/pkg/version"
	v1 "k8s.io/api/core/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/client-go/kubernetes"

	"istio.io/api/annotation"
	"istio.io/api/label"
	"istio.io/istio/cni/pkg/constants"
	"istio.io/istio/cni/pkg/util"
	"istio.io/istio/pkg/file"
	"istio.io/istio/pkg/log"
	"istio.io/istio/pkg/slices"
	"istio.io/istio/pkg/util/sets"
)

var (
	injectAnnotationKey = annotation.SidecarInject.Name
	sidecarStatusKey    = annotation.SidecarStatus.Name

	podRetrievalMaxRetries = 30
	podRetrievalInterval   = 1 * time.Second
)

const (
	ISTIOINIT  = "istio-init"
	ISTIOPROXY = "istio-proxy"
)

// Config is whatever you expect your configuration json to be. This is whatever
// is passed in on stdin. Your plugin may wish to expose its functionality via
// runtime args, see CONVENTIONS.md in the CNI spec.
type Config struct {
	types.NetConf

	// Add plugin-specific flags here
	PluginLogLevel              string                    `json:"plugin_log_level"`
	CNIAgentRunDir              string                    `json:"cni_agent_run_dir"`
	AmbientEnabled              bool                      `json:"ambient_enabled"`
	EnablementSelectors         []util.EnablementSelector `json:"enablement_selectors"`
	ExcludeNamespaces           []string                  `json:"exclude_namespaces"`
	PodNamespace                string                    `json:"pod_namespace"`
	NativeNftables              bool                      `json:"native_nftables"`
	EnableAmbientDetectionRetry bool                      `json:"enable_ambient_detection_retry"`
}

// K8sArgs is the valid CNI_ARGS used for Kubernetes
// The field names need to match exact keys in containerd args for unmarshalling
// https://github.com/containerd/containerd/blob/ced9b18c231a28990617bc0a4b8ce2e81ee2ffa1/pkg/cri/server/sandbox_run.go#L526-L532
type K8sArgs struct {
	types.CommonArgs
	K8S_POD_NAME               types.UnmarshallableString // nolint: revive, stylecheck
	K8S_POD_NAMESPACE          types.UnmarshallableString // nolint: revive, stylecheck
	K8S_POD_INFRA_CONTAINER_ID types.UnmarshallableString // nolint: revive, stylecheck
	K8S_POD_UID                types.UnmarshallableString // nolint: revive, stylecheck
}

// parseConfig parses the supplied configuration (and prevResult) from stdin.
func parseConfig(stdin []byte) (*Config, error) {
	conf := Config{}

	if err := json.Unmarshal(stdin, &conf); err != nil {
		return nil, fmt.Errorf("failed to parse network configuration: %v", err)
	}

	log.Debugf("istio-cni: Config is: %+v", conf)
	// Parse previous result. Remove this if your plugin is not chained.
	if conf.RawPrevResult != nil {
		resultBytes, err := json.Marshal(conf.RawPrevResult)
		if err != nil {
			return nil, fmt.Errorf("could not serialize prevResult: %v", err)
		}
		res, err := version.NewResult(conf.CNIVersion, resultBytes)
		if err != nil {
			return nil, fmt.Errorf("could not parse prevResult: %v", err)
		}
		conf.RawPrevResult = nil
		conf.PrevResult, err = cniv1.NewResultFromResult(res)
		if err != nil {
			return nil, fmt.Errorf("could not convert result to current version: %v", err)
		}
	}
	// End previous result parsing

	return &conf, nil
}

// Logging with CNI plugins is special - we *cannot* log to stdout, as the CNI spec uses stdin/stdout to pass context between invoked plugins.
// So, we log to a rolling logfile, and also forward logs via UDS to the node agent (if available)
func GetLoggingOptions(cfg *Config) *log.Options {
	loggingOptions := log.DefaultOptions()
	loggingOptions.OutputPaths = []string{"stderr"}
	loggingOptions.JSONEncoding = true
	if cfg != nil {

		udsAddr := filepath.Join(cfg.CNIAgentRunDir, constants.LogUDSSocketName)
		// Tee all logs to UDS. Stdout will go to kubelet (hard to access, UDS will be read by the CNI DaemonSet and exposed
		// by normal `kubectl logs`
		if file.Exists(udsAddr) {
			loggingOptions.WithTeeToUDS(udsAddr, constants.UDSLogPath)
		}

		// Also tee to a rolling log on the node's local filesystem, in case the UDS server is down.
		loggingOptions.WithTeeToRollingLocal(filepath.Join(cfg.CNIAgentRunDir, constants.LocalRollingLogName), constants.RollingLogMaxSizeMB)

		// Override plugin log level based on their config. Note we use "all" (OverrideScopeName) since there is no scoping in the plugin.
		if cfg.PluginLogLevel != "" {
			loggingOptions.SetDefaultOutputLevel(log.OverrideScopeName, log.StringToLevel(cfg.PluginLogLevel))
		}
	}
	return loggingOptions
}

// CmdAdd is called for ADD requests
func CmdAdd(args *skel.CmdArgs) (err error) {
	// Defer a panic recover, so that in case if panic we can still return
	// a proper error to the runtime.
	defer func() {
		if e := recover(); e != nil {
			msg := fmt.Sprintf("istio-cni panicked during cmdAdd: %v\n%v", e, string(debug.Stack()))
			if err != nil {
				// If we're recovering and there was also an error, then we need to
				// present both.
				msg = fmt.Sprintf("%s: %v", msg, err)
			}
			err = errors.New(msg)
		}
		if err != nil {
			log.Errorf("istio-cni cmdAdd error: %v", err)
		}
	}()

	conf, err := parseConfig(args.StdinData)
	if err != nil {
		log.Errorf("istio-cni cmdAdd failed to parse config %v %v", string(args.StdinData), err)
		return err
	}

	// Preemptively check if the pod is a CNI pod.
	// It is possible that the kubeconfig is not available if it hasn't been written yet
	// by the CNI pod or it is invalid which cause the CNI pod to be unable to start if
	// on the creation of a new K8s client. We preemptively check if the pod is a CNI pod
	// to avoid a deadlock on the kubeconfig when the k8s client is unnecessary to process
	// the CNI add event for the CNI pod itself. This applies to both ambient and sidecar
	// mode, since newK8sClient below runs unconditionally and would otherwise deadlock the
	// CNI pod when its kubeconfig is not yet present (for example after a node reboot).
	k8sArgs := K8sArgs{}
	if err := types.LoadArgs(args.Args, &k8sArgs); err != nil {
		return fmt.Errorf("failed to load args: %v", err)
	}
	if isCNIPod(conf, &k8sArgs) {
		// This is our own agent pod, so the kube client is not needed to process the
		// event. Skip it rather than block on a kubeconfig that may not exist yet.
		log.Infof("%v looks like our own agent pod, skipping", k8sArgs.K8S_POD_NAME)
		return pluginResponse(conf)
	}

	// Create a kube client
	client, err := newK8sClient(*conf)
	if err != nil {
		return fmt.Errorf("failed to createNewK8sClient: %v", err)
	}

	// Actually do the add
	mgr := IptablesInterceptRuleMgr()
	if conf.NativeNftables {
		mgr = NftablesInterceptRuleMgr()
	}
	if err := doAddRun(args, conf, client, mgr); err != nil {
		return err
	}
	return pluginResponse(conf)
}

func doAddRun(args *skel.CmdArgs, conf *Config, kClient kubernetes.Interface, rulesMgr InterceptRuleMgr) error {
	if err := log.Configure(GetLoggingOptions(conf)); err != nil {
		log.Error("Failed to configure istio-cni logging")
	}

	var loggedPrevResult any
	if conf.PrevResult == nil {
		loggedPrevResult = "none"
	} else {
		loggedPrevResult = conf.PrevResult
	}
	log.WithLabels("if", args.IfName).Debugf("istio-cni CmdAdd config: %+v", conf)
	log.Debugf("istio-cni CmdAdd previous result: %+v", loggedPrevResult)

	// Determine if running under k8s by checking the CNI args
	k8sArgs := K8sArgs{}
	if err := types.LoadArgs(args.Args, &k8sArgs); err != nil {
		return err
	}

	// Check if the workload is running under Kubernetes.
	podNamespace := string(k8sArgs.K8S_POD_NAMESPACE)
	podName := string(k8sArgs.K8S_POD_NAME)
	log := log.WithLabels("pod", podNamespace+"/"+podName)
	if podNamespace == "" || podName == "" {
		log.Debugf("Not a kubernetes pod")
		return nil
	}

	if slices.Contains(conf.ExcludeNamespaces, podNamespace) {
		log.Infof("pod namespace %s excluded", podNamespace)
		return nil
	}

	// Begin ambient plugin logic
	// For ambient pods, this is all the logic we need to run
	if conf.AmbientEnabled {
		log.Debugf("istio-cni ambient cmdAdd podName: %s - checking if ambient enabled", podName)
		podIsAmbient, err := isAmbientPod(kClient, podName, podNamespace, conf.EnablementSelectors, conf.EnableAmbientDetectionRetry)
		if err != nil {
			log.Errorf("istio-cni cmdAdd failed to check if pod is ambient: %s", err)
			// Conditionally return the error based on whether istio owned CNI is configured
			// to support gradual rollout of feature in critical code path
			if conf.EnableAmbientDetectionRetry {
				return err
			}
		}

		var prevResIps []*cniv1.IPConfig
		if conf.PrevResult != nil {
			prevResult := conf.PrevResult.(*cniv1.Result)
			prevResIps = prevResult.IPs
		}

		// Only send event if this pod "would be" an ambient-watched pod - otherwise skip
		if podIsAmbient {
			cniEventAddr := filepath.Join(conf.CNIAgentRunDir, constants.CNIEventSocketName)
			cniClient := newCNIClient(cniEventAddr, constants.CNIAddEventPath)
			if err = PushCNIEvent(cniClient, args, prevResIps, podName, podNamespace); err != nil {
				// return a more informative error in the pod event log if CNI plugin fails
				wrapErr := fmt.Errorf("istio-cni cmdAdd failed to contact node Istio CNI agent: %s", err)
				return wrapErr
			}
			return nil
		}
		log.Debugf("istio-cni ambient cmdAdd podName: %s - not ambient enabled, ignoring", podName)
	}
	// End ambient plugin logic

	pi := &PodInfo{}
	var k8sErr error
	for attempt := 1; attempt <= podRetrievalMaxRetries; attempt++ {
		pi, k8sErr = getK8sPodInfo(kClient, podName, podNamespace)
		if k8sErr == nil {
			break
		}
		log.Debugf("Failed to get pod info: %v", k8sErr)

		// Failsafe - if we get here, we could be in a state where
		// 1. We are being upgraded - `istio-cni` node agent pod is gone
		// 2. This plugin was left in place to stall pod spawns until the
		// replacement arrives.
		// 3. This plugin can't contact the K8S API server (creds expired/invalid)
		// 4. The pod this plugin would be blocking by returning this error
		// *is* our replacement `istio-cni` pod (which would refresh our creds)
		//
		// So, if we can't contact the K8S API server at all, fall back to checking the
		// K8S_POD/K8S_NAMESPACE values from the CNI layer, and let this pod through
		// if it looks like it might be our `istio-cni` node agent.
		//
		// We could do this check unconditionally above, but it seems smarter to only
		// fall back to this (lightly) relaxed check when we know we are in a degraded state.
		//
		// Is this fail open? Not really, the K8S args come from the cluster's CNI and are as-authoritative
		// as the hard query we would otherwise make against the API.
		//
		// TODO NRI could probably give us more identifying information here OOB from k8s.
		if isCNIPod(conf, &k8sArgs) {
			log.Infof("in a degraded state and %v looks like our own agent pod, skipping", k8sArgs.K8S_POD_NAME)
			return nil
		}
		if attempt == 1 {
			// Only warn once: this loop retries up to podRetrievalMaxRetries times and the
			// verdict does not change between attempts.
			log.Warnf("in a degraded state and %s is not a CNI pod, podNamespace: %s, conf pod namespace: %s",
				k8sArgs.K8S_POD_NAME, k8sArgs.K8S_POD_NAMESPACE, conf.PodNamespace)
		}

		time.Sleep(podRetrievalInterval)
	}

	if k8sErr != nil {
		log.Errorf("Failed to get pod info: %v", k8sErr)
		return k8sErr
	}

	// Check if istio-init container is present; in that case exclude pod
	if pi.Containers.Contains(ISTIOINIT) {
		log.Infof("excluded due to being already injected with istio-init container")
		return nil
	}

	if val, ok := pi.ProxyEnvironments["DISABLE_ENVOY"]; ok {
		if val, err := strconv.ParseBool(val); err == nil && val {
			log.Infof("excluded due to DISABLE_ENVOY on istio-proxy")
			return nil
		}
	}

	if !pi.Containers.Contains(ISTIOPROXY) {
		log.Infof("excluded because it does not have istio-proxy container (have %v)", sets.SortedList(pi.Containers))
		return nil
	}

	if pi.ProxyType != "" && pi.ProxyType != "sidecar" {
		log.Infof("excluded %s/%s pod because it has proxy type %s", podNamespace, podName, pi.ProxyType)
		return nil
	}

	val := pi.Annotations[injectAnnotationKey]
	if lbl, labelPresent := pi.Labels[label.SidecarInject.Name]; labelPresent {
		// The label is the new API; if both are present we prefer the label
		val = lbl
	}
	if val != "" {
		log.Debugf("contains inject annotation: %s", val)
		if injectEnabled, err := strconv.ParseBool(val); err == nil {
			if !injectEnabled {
				log.Infof("excluded due to inject-disabled annotation")
				return nil
			}
		}
	}

	if _, ok := pi.Annotations[sidecarStatusKey]; !ok {
		log.Infof("excluded due to not containing sidecar annotation")
		return nil
	}

	log.Debugf("Setting up redirect")

	redirect, err := NewRedirect(pi)
	if err != nil {
		log.Errorf("redirect failed due to bad params: %v", err)
		return err
	}

	if err := rulesMgr.Program(podName, args.Netns, redirect); err != nil {
		return err
	}

	return nil
}

func pluginResponse(conf *Config) error {
	var result *cniv1.Result
	if conf.PrevResult == nil {
		result = &cniv1.Result{
			CNIVersion: cniv1.ImplementedSpecVersion,
		}
		return types.PrintResult(result, conf.CNIVersion)
	}

	// Pass through the result for the next plugin
	return types.PrintResult(conf.PrevResult, conf.CNIVersion)
}

func CmdCheck(args *skel.CmdArgs) (err error) {
	return nil
}

func CmdDelete(args *skel.CmdArgs) (err error) {
	return nil
}

func isAmbientPod(client kubernetes.Interface, podName, podNamespace string, selectors []util.EnablementSelector, enableRetry bool) (bool, error) {
	compiledSelectors, err := util.NewCompiledEnablementSelectors(selectors)
	if err != nil {
		return false, fmt.Errorf("failed to instantiate ambient enablement selector: %v", err)
	}

	maxRetries := 1
	if enableRetry {
		maxRetries = podRetrievalMaxRetries
	}

	var pod *v1.Pod
	var ns *v1.Namespace
	var podErr, nsErr error

	err = backoff.Retry(func() error {
		// attempt to get pod and namespace
		pod, podErr = client.CoreV1().Pods(podNamespace).Get(context.Background(), podName, metav1.GetOptions{})
		ns, nsErr = client.CoreV1().Namespaces().Get(context.Background(), podNamespace, metav1.GetOptions{})
		if podErr != nil || nsErr != nil {
			errMsg := fmt.Sprintf("failed to get pod or namespace info, retrying: podErr=%v, nsErr=%v", podErr, nsErr)
			log.Debug(errMsg)
			// reset pod and ns to force re-get to avoid stale resources
			pod, ns = nil, nil
			return errors.New(errMsg)
		}
		return nil
	}, backoff.WithMaxRetries(backoff.NewConstantBackOff(podRetrievalInterval), uint64(maxRetries)))
	if err != nil {
		return false, fmt.Errorf("failed to get pod or namespace info: podErr=%v, nsErr=%v", podErr, nsErr)
	}

	return compiledSelectors.Matches(pod, ns.Labels), nil
}

// isCNIPod reports whether the CNI args look like our own istio-cni node agent pod.
// It deliberately does not log: it is called both on the normal path for every ADD,
// where a false result is unremarkable, and from the degraded failsafe, where a false
// result is worth a warning. The caller has the context to pick the right level.
func isCNIPod(conf *Config, k8sArgs *K8sArgs) bool {
	return strings.HasPrefix(string(k8sArgs.K8S_POD_NAME), "istio-cni-node-") &&
		string(k8sArgs.K8S_POD_NAMESPACE) == conf.PodNamespace
}
